Skip to content

Commit bf0ae99

Browse files
fix(spec): 三个手写 unrecognized_keys error map 把处方排到说明句之前 (#6416) (#6453)
`strictVisibilityError` (shared/visibility.ts)、`strictWidgetAnalyticsError` (ui/dashboard.zod.ts)、`strictTenancyError` (data/object.zod.ts) 都是独立的 `$ZodErrorMap`,既不经过 `strictUnknownKeyError`(所以 #5955 的修法够不到), 也不在 #5593 迁移的 44 个直接调用点里。三者都复现了 #5955 针对的形状:一句 不可操作的说明句夹在「哪个键错了」与修复它的处方之间 —— 在多个消费方的单行 渲染上(`os validate` 的 `• where: message`、CI 日志、会主动抹平换行的 `validateFlowTriggerReadiness`),处方被推出作者真正会读的那一段。 现在统一为 PR #6375 为共享模板落地的顺序:前置语(哪个键错了)→ 全部修复 通道(`visibleWhen` 别名指针;ADR-0021 dataset / objectui 隔离 / #5022 drill 三个分支;`tenancy` 的逐键 tombstone bullets)→ 说明句放最后。没有删除任何 文本,也没有让任何句子变成有条件的:七个消息变体在长度与字符多重集上与原拼接 逐字节相同。接受面不动 —— 这些 map 只塑造一个已经失败的 parse 的文本, `visibility.ts` 的别名表原样未改。 Claude-Session: https://claude.ai/code/session_018ffcE95NaMJcL9XJ9VDYgk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fb42d2 commit bf0ae99

7 files changed

Lines changed: 307 additions & 18 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
Reorder the three hand-written `unrecognized_keys` error maps so the fix is read before the explanation (#6416, applying #5955's ruling).
6+
7+
`strictVisibilityError` (`shared/visibility.ts`), `strictWidgetAnalyticsError` (`ui/dashboard.zod.ts`) and `strictTenancyError` (`data/object.zod.ts`) are independent `$ZodErrorMap` functions rather than `strictUnknownKeyError` call sites, so #5955's reorder of the shared template did not reach them and #5593's `strictObject` migration cannot either. Each reproduced the exact shape #5955 was filed against: a non-actionable explanatory sentence sitting between the offending key and the prescription that fixes it, which on the single-line renders several consumers use (`os validate`'s `• where: message`, CI logs, `validateFlowTriggerReadiness`) pushed the fix out of the part an author actually reads.
8+
9+
Every message now emits front matter (which key is wrong) → every fix channel (the `visibleWhen` alias pointer; the ADR-0021 dataset / objectui-quarantine / #5022 drill branches; the per-key `tenancy` tombstone bullets) → the explanatory sentence last. Nothing is deleted and nothing becomes conditional — each sentence is still emitted verbatim, once per message, and all seven message variants are byte-identical in length and character multiset to their previous spelling. No input changes acceptance: these maps only shape the text of an already-failing parse, and the `visibility.ts` alias tables are untouched.

packages/spec/src/data/object.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,73 @@ describe('TenancyConfigSchema — #2763 strategy/crossTenantAccess removal', ()
15211521
});
15221522
});
15231523

1524+
/**
1525+
* Message ORDER on `strictTenancyError` (#6416, applying #5955's ruling).
1526+
*
1527+
* A hand-written `$ZodErrorMap`: it never calls `strictUnknownKeyError`, so
1528+
* #5955's reorder of the shared template did not reach it, and it is not one of
1529+
* the 44 direct call sites #5593 migrates to `strictObject` either. Its
1530+
* explanatory sentence is the standing two-modes explainer, and its FIX channel
1531+
* is the per-key ` • ` bullets built just above it — the tombstone that tells
1532+
* an upgrading author what to write instead. Those bullets used to sit BEHIND
1533+
* ~160 characters of standing background, which is past the front of the
1534+
* single-line renders several consumers use (`os validate`'s `• where: message`,
1535+
* CI logs).
1536+
*
1537+
* ORDER pins, not presence checks. Every `toContain` in the block above stays
1538+
* green under either order — that is exactly why they cannot carry this fact.
1539+
*/
1540+
describe('strictTenancyError message order — bullets before the explainer (#6416)', () => {
1541+
const EXPLAINER =
1542+
'The two supported tenancy modes are: database-per-tenant = environment-level ' +
1543+
'deployment (no object config); row-level isolation = `tenancy.enabled` + ' +
1544+
'`tenancy.tenantField`.';
1545+
1546+
const messageFor = (body: Record<string, unknown>) => {
1547+
const res = TenancyConfigSchema.safeParse({ enabled: true, ...body });
1548+
expect(res.success).toBe(false);
1549+
const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys');
1550+
expect(unknown).toBeDefined();
1551+
return unknown!.message;
1552+
};
1553+
1554+
it('names the wrong key first, then the tombstone bullet, then the explainer', () => {
1555+
const m = messageFor({ strategy: 'isolated' });
1556+
// 1. which key is wrong — and nothing before it
1557+
expect(m.startsWith('Unrecognized key(s) on `tenancy`: `strategy`.\n')).toBe(true);
1558+
// 2. the fix channel: the per-key bullet, on the line right after
1559+
expect(m).toContain('\n • `tenancy.strategy` was removed from @objectstack/spec after v15.0');
1560+
// 3. the explainer, verbatim, last — moved, never dropped
1561+
expect(m.indexOf('Delete the key.')).toBeLessThan(m.indexOf(EXPLAINER));
1562+
expect(m.endsWith(` ${EXPLAINER}`)).toBe(true);
1563+
});
1564+
1565+
it('keeps EVERY per-key bullet ahead of the explainer, not just the first', () => {
1566+
// One issue names every offending key, so the explainer is a per-MESSAGE
1567+
// sentence: a reorder that put it after the first bullet would bury the rest.
1568+
const m = messageFor({ strategy: 'isolated', crossTenantAccess: true, tenantfield: 'org_id' });
1569+
for (const bullet of [
1570+
'`tenancy.strategy` was removed',
1571+
'`tenancy.crossTenantAccess` was removed',
1572+
'`tenantfield` is not a `tenancy` key.',
1573+
]) {
1574+
expect(m).toContain(bullet);
1575+
expect(m.indexOf(bullet), bullet).toBeLessThan(m.indexOf(EXPLAINER));
1576+
}
1577+
expect(m.split(EXPLAINER)).toHaveLength(2);
1578+
expect(m.endsWith(` ${EXPLAINER}`)).toBe(true);
1579+
});
1580+
1581+
it('is a full-message pin for the plain unknown-key case', () => {
1582+
// Any stray separator, dropped newline or duplicated clause fails here.
1583+
expect(messageFor({ tenantfield: 'org_id' })).toBe(
1584+
'Unrecognized key(s) on `tenancy`: `tenantfield`.\n' +
1585+
' • `tenantfield` is not a `tenancy` key. ' +
1586+
EXPLAINER,
1587+
);
1588+
});
1589+
});
1590+
15241591
describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-0066)', () => {
15251592
it('is true only for an explicit tenancy.enabled === false', () => {
15261593
expect(isTenancyDisabled({ name: 'sys_license', tenancy: { enabled: false } })).toBe(true);

packages/spec/src/data/object.zod.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,24 @@ const TENANCY_RETIRED_KEY_GUIDANCE: Record<string, string> = {
421421
* `strategy`/`crossTenantAccess` or a typo — is a loud, *fixable* parse error
422422
* instead of a silent strip (#1535), and a retired key's error carries its
423423
* upgrade prescription. Every other issue code defers to zod's default.
424+
*
425+
* ## Message order: the fix comes before the explainer (#5955 / #6416)
426+
*
427+
* ```text
428+
* Unrecognized key(s) on `tenancy`: `k1`. ← which key is wrong
429+
* • {per-key tombstone / "not a `tenancy` key"} ← the fix
430+
* The two supported tenancy modes are: … ← the standing explainer
431+
* ```
432+
*
433+
* Same emission order the shared `strictUnknownKeyError` template took in
434+
* #5955 — bullets first, the surface-level sentence appended to the last one.
435+
* A hand-written `$ZodErrorMap` is reachable by neither that fix nor #5593's
436+
* `strictObject` migration, so #6416 applies the ruling here directly. The
437+
* two-modes explainer used to sit between the key statement and the bullets,
438+
* which on the single-line renders several consumers use (`os validate`'s
439+
* `• where: message`, CI logs) buried each key's actual prescription behind
440+
* ~160 characters of standing background. Nothing is dropped: the explainer is
441+
* still emitted verbatim, just last.
424442
*/
425443
const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
426444
if (issue.code !== 'unrecognized_keys') return undefined;
@@ -429,11 +447,11 @@ const strictTenancyError: z.core.$ZodErrorMap = (issue) => {
429447
TENANCY_RETIRED_KEY_GUIDANCE[key] ?? `\`${key}\` is not a \`tenancy\` key.`,
430448
);
431449
return (
432-
`Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}. ` +
433-
'The two supported tenancy modes are: database-per-tenant = environment-level ' +
450+
`Unrecognized key(s) on \`tenancy\`: ${keys.map((k) => `\`${k}\``).join(', ')}.\n` +
451+
lines.map((l) => ` • ${l}`).join('\n') +
452+
' The two supported tenancy modes are: database-per-tenant = environment-level ' +
434453
'deployment (no object config); row-level isolation = `tenancy.enabled` + ' +
435-
'`tenancy.tenantField`.\n' +
436-
lines.map((l) => ` • ${l}`).join('\n')
454+
'`tenancy.tenantField`.'
437455
);
438456
};
439457

packages/spec/src/shared/visibility.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,42 @@ function looksLikeVisibilityKey(key: string): boolean {
9090
* .strict()
9191
* .transform(normalizeVisibleWhen)
9292
* ```
93+
*
94+
* ## Message order: the fix comes before the history (#5955 / #6416)
95+
*
96+
* ```text
97+
* Unrecognized key(s) on this view/page schema: `k1`. ← which key is wrong
98+
* [ If this is the conditional-visibility predicate … ] ← the fix
99+
* Before ADR-0089 D3a these were dropped silently … ← why it used to be silent
100+
* ```
101+
*
102+
* This map is a hand-written `$ZodErrorMap`, so #5955's fix to the shared
103+
* `strictUnknownKeyError` template could not reach it and #5593's
104+
* `strictObject` migration cannot either — #6416 applies the same ruling here.
105+
* The history sentence used to sit between the key statement and the alias
106+
* pointer, which is the position several consumers render on ONE line
107+
* (`os validate`'s `• where: message`, CI logs, and
108+
* `validateFlowTriggerReadiness`, which flattens the newlines): an author —
109+
* often an AI — reads the front of that line and acts on it, so the canonical
110+
* key has to be there. Nothing is dropped and nothing is conditional; the
111+
* sentence is still emitted verbatim, just last.
93112
*/
94113
export const strictVisibilityError: z.core.$ZodErrorMap = (issue) => {
95114
if (issue.code !== 'unrecognized_keys') return undefined;
96115
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
97116
const list = keys.map((k) => `\`${k}\``).join(', ');
98-
const base =
99-
`Unrecognized key(s) on this view/page schema: ${list}. ` +
117+
const front = `Unrecognized key(s) on this view/page schema: ${list}.`;
118+
const history =
100119
`Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ` +
101120
`a mis-layered or stale key is now a loud parse error.`;
102121
if (keys.some(looksLikeVisibilityKey)) {
103122
return (
104-
base +
123+
front +
105124
' If this is the conditional-visibility predicate, the canonical key is ' +
106125
'`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' +
107-
'component) are still accepted as deprecated aliases.'
126+
'component) are still accepted as deprecated aliases. ' +
127+
history
108128
);
109129
}
110-
return base;
130+
return `${front} ${history}`;
111131
};

packages/spec/src/ui/dashboard.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,91 @@ describe('DashboardWidgetSchema (dataset-bound)', () => {
125125
});
126126
});
127127

128+
/**
129+
* Message ORDER on `strictWidgetAnalyticsError` (#6416, applying #5955's ruling).
130+
*
131+
* This map is a hand-written `$ZodErrorMap`, so neither #5955 (which moved the
132+
* history sentence to the end inside the shared `strictUnknownKeyError`) nor
133+
* #5593 (which migrates the direct call sites to `strictObject`) reached it. It
134+
* carried the same defect: a ~150-char history sentence sitting BETWEEN the
135+
* offending key and whichever of the three prescription branches fixes it —
136+
* the ADR-0021 dataset migration, the objectui `component`/`data` quarantine,
137+
* and the #5022 drill near-key answer — pushing all three past the front of the
138+
* single-line renders several consumers use.
139+
*
140+
* ORDER pins, not presence checks: the reorder deletes nothing, so every
141+
* `toContain` in the block above stays green either way. An edit that folds the
142+
* sentence back into the middle passes all of them and fails here.
143+
*/
144+
describe('strictWidgetAnalyticsError message order — fix before history (#6416)', () => {
145+
const HISTORY =
146+
'Undeclared top-level keys were dropped silently before strict validation, ' +
147+
'shipping inert metadata; a stale or mis-layered key is now a loud parse error.';
148+
149+
const base = { id: 'w1', type: 'metric', dataset: 'sales', values: ['revenue'] };
150+
const messageFor = (extra: Record<string, unknown>) => {
151+
const res = DashboardWidgetSchema.safeParse({ ...base, ...extra } as any);
152+
expect(res.success).toBe(false);
153+
const unknown = res.error!.issues.find((i) => i.code === 'unrecognized_keys');
154+
expect(unknown).toBeDefined();
155+
return unknown!.message;
156+
};
157+
158+
const orderPin = (label: string, extra: Record<string, unknown>, key: string, fix: string) => {
159+
it(label, () => {
160+
const m = messageFor(extra);
161+
// 1. which key is wrong — and nothing before it
162+
expect(m.startsWith(`Unrecognized key(s) on this dashboard widget: \`${key}\`.`)).toBe(true);
163+
// 2. the fix, ahead of the history
164+
expect(m).toContain(fix);
165+
expect(m.indexOf(fix)).toBeLessThan(m.indexOf(HISTORY));
166+
// 3. the history sentence, verbatim, last — moved, never dropped
167+
expect(m.endsWith(` ${HISTORY}`)).toBe(true);
168+
});
169+
};
170+
171+
orderPin(
172+
'legacy inline-analytics branch: the ADR-0021 dataset prescription comes first',
173+
{ categoryField: 'stage' },
174+
'categoryField',
175+
'The pre-ADR-0021 inline analytics shape',
176+
);
177+
178+
orderPin(
179+
'quarantine branch: the objectui-internal verdict comes first',
180+
{ component: {} },
181+
'component',
182+
'`component` and inline `data` are objectui-internal renderer capabilities',
183+
);
184+
185+
orderPin(
186+
'drill branch (#5022): the "AUTOMATIC" answer comes first',
187+
{ drillDown: { enabled: true } },
188+
'drillDown',
189+
'Drill-through on a dashboard is AUTOMATIC and not configurable per widget',
190+
);
191+
192+
it('keeps the whole drill answer ahead of the history, not just its opening', () => {
193+
// The #5022 branch is the longest of the three; its two "where the real
194+
// drills live" pointers are the actionable part and must not slip behind.
195+
const m = messageFor({ drillDown: { enabled: true } });
196+
expect(m.indexOf('`ChartDrillDownSchema`')).toBeLessThan(m.indexOf(HISTORY));
197+
expect(m.indexOf('`ReportSchema.drilldown` (ADR-0021 D2, on by default).'))
198+
.toBeLessThan(m.indexOf(HISTORY));
199+
});
200+
201+
it('is unchanged in SHAPE when no branch matches — full-message pin', () => {
202+
expect(messageFor({ colourVariant: 'blue' }))
203+
.toBe(`Unrecognized key(s) on this dashboard widget: \`colourVariant\`. ${HISTORY}`);
204+
});
205+
206+
it('emits the history exactly once, whatever the key count', () => {
207+
const m = messageFor({ categoryField: 'stage', alsoWrong: 1, andThis: 2 });
208+
expect(m.split(HISTORY)).toHaveLength(2);
209+
expect(m.endsWith(` ${HISTORY}`)).toBe(true);
210+
});
211+
});
212+
128213
describe('DashboardSchema', () => {
129214
it('parses a dataset-bound dashboard', () => {
130215
const d = DashboardSchema.parse({

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,29 +141,48 @@ const QUARANTINED_WIDGET_KEYS = new Set(['component', 'data']);
141141
* objectui-internal prop it points the author at the ADR-0021 dataset shape
142142
* (and `options` for renderer-specific extras). Mirrors `strictVisibilityError`
143143
* (ADR-0089 D3a); every other issue code defers to zod's default.
144+
*
145+
* ## Message order: the fix comes before the history (#5955 / #6416)
146+
*
147+
* ```text
148+
* Unrecognized key(s) on this dashboard widget: `k1`. ← which key is wrong
149+
* [ one of the three prescription branches ] ← the fix
150+
* Undeclared top-level keys were dropped silently … ← why it used to be silent
151+
* ```
152+
*
153+
* Hand-written `$ZodErrorMap`s were out of reach of both #5955 (which moved the
154+
* sentence inside the shared `strictUnknownKeyError`) and #5593 (which migrates
155+
* the direct call sites to `strictObject`); #6416 applies the same ruling here.
156+
* The history sentence used to sit between the key statement and the branch that
157+
* fixes it, pushing every prescription — the ADR-0021 dataset migration, the
158+
* objectui quarantine, the #5022 drill answer — past character ~220 of a message
159+
* several consumers render on ONE line. Nothing is dropped or made conditional:
160+
* the sentence is still emitted verbatim, just last.
144161
*/
145162
const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => {
146163
if (issue.code !== 'unrecognized_keys') return undefined;
147164
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
148165
const list = keys.map((k) => `\`${k}\``).join(', ');
149-
const base =
150-
`Unrecognized key(s) on this dashboard widget: ${list}. ` +
166+
const front = `Unrecognized key(s) on this dashboard widget: ${list}.`;
167+
const history =
151168
`Undeclared top-level keys were dropped silently before strict validation, ` +
152169
`shipping inert metadata; a stale or mis-layered key is now a loud parse error.`;
153170
if (keys.some((k) => LEGACY_WIDGET_ANALYTICS_KEYS.has(k))) {
154171
return (
155-
base +
172+
front +
156173
' The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' +
157174
'`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' +
158175
'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' +
159-
'settings belong under `options`.'
176+
'settings belong under `options`. ' +
177+
history
160178
);
161179
}
162180
if (keys.some((k) => QUARANTINED_WIDGET_KEYS.has(k))) {
163181
return (
164-
base +
182+
front +
165183
' `component` and inline `data` are objectui-internal renderer capabilities, ' +
166-
'not part of the author-facing dashboard spec (framework#3251).'
184+
'not part of the author-facing dashboard spec (framework#3251). ' +
185+
history
167186
);
168187
}
169188
// #5022 — the drill near-key, in all three spellings an author reaches for.
@@ -175,18 +194,19 @@ const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => {
175194
// between two real keys on two other surfaces.
176195
if (keys.some((k) => k === 'drillDown' || k === 'drilldown' || k === 'drill')) {
177196
return (
178-
base +
197+
front +
179198
' Drill-through on a dashboard is AUTOMATIC and not configurable per widget: ' +
180199
'a dataset-bound widget derives the drill target and filter from the dataset row ' +
181200
'that was clicked, and a `table`/`pivot` widget is the one to reach for when you ' +
182201
'want the detail to be clickable (`metric`/`chart` render the aggregate only). ' +
183202
'The two configurable drills live elsewhere and neither is a widget key: ' +
184203
'`drillDown` (camelCase, a config object) is the react-tier `<ObjectChart drillDown={…}>` ' +
185204
'prop — `ChartDrillDownSchema`; `drilldown` (all lowercase, a boolean) is ' +
186-
'`ReportSchema.drilldown` (ADR-0021 D2, on by default).'
205+
'`ReportSchema.drilldown` (ADR-0021 D2, on by default). ' +
206+
history
187207
);
188208
}
189-
return base;
209+
return `${front} ${history}`;
190210
};
191211

192212
/**

0 commit comments

Comments
 (0)