Skip to content

Commit fb363b2

Browse files
hotlongclaude
andauthored
docs(lint): 按实测改正 normalized 输入层的三条依据,并补回归 pin (#6073) (#6340)
#6073 要求「先测量、后行动」。测量做完了,结论是假设不成立——但不成立的方式 和单子预期的两个分支都不同,所以按实测如实记录,不套模板。 `AuthoringRuleInputTier` 的 `normalized` 层此前用三个例子自证:「规则要读 parse 会剥掉的键(views: [] 里的扁平 list view、对象 list view 上的 userFilters、visibleOn 别名)」。三条**全部实测为假**,各有各的原因: 1. defineStack 在定义期就 parse,所以 TS 配置交给三条命令的值已经是 result.data(实测:flow.runAs === 'user'、status === 'draft' 已填充); 2. 但什么都没丢——#4001 之后 ViewSchema / ObjectListViewSchema 是 strict, 不是 strip:defineStack 直接抛错并点名 type/data/columns 与 quickFilters, 还附 defineView 包裹修法。example app 上 os lint 与 os validate 均在 LOAD 阶段拒绝配置,规则根本没机会跑,而这比规则报得更早、更准; 3. visibleOn 别名在任何输入形状下都到不了这一层:ADR-0087 D2 的两条转换在 normalizeStackInput **之内**折叠它,而该函数的输出就是这一层。单独立单 #6318 记录。 保留该层的真实理由(实测确认):无关的 schema 错误会中止 parse,而 normalized 层的 findings 仍能到达作者——raw 路径上 os validate 停在 schema 步零 finding,os lint 仍点名两条规则。 改动仅为注释 + 一个新的回归 pin 测试(12 例),非注释代码行零改动; examples/ 终态零 diff。 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4d552af commit fb363b2

5 files changed

Lines changed: 447 additions & 31 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #6073 — what `AuthoringRuleInputTier`'s `normalized` value actually buys.
4+
//
5+
// ## Why this file exists
6+
//
7+
// The tier's doc comment used to justify itself with three examples — "the rules
8+
// that need it check keys the parse strips (a flat list view in `views: []`,
9+
// `userFilters` on an object list view, a `visibleOn` alias): by the time
10+
// `result.data` exists the evidence is gone". All three were measured FALSE
11+
// under #6073, each for a different reason, and a comment is not something CI
12+
// can keep honest. Every claim the corrected comment makes is pinned here, so
13+
// the next reader inherits the measurement instead of re-deriving it — and so a
14+
// change that silently restores one of the old premises goes red.
15+
//
16+
// The mechanism half was never in doubt (#5693 measured it, this file re-pins
17+
// it): `defineStack` PARSES at definition time, so for a TS config — the
18+
// documented and universal way to declare a stack — the value the CLI hands the
19+
// registry is already `result.data`, and re-normalizing it cannot resurrect
20+
// anything the parse resolved. What was in doubt was the CONSEQUENCE, and the
21+
// consequence is not the blind spot it looked like: the two view schemas the
22+
// comment cited went strict at #4001, so they REFUSE where the comment says they
23+
// STRIP, and refuse earlier and better than any lint rule could.
24+
//
25+
// ## What this file does NOT claim
26+
//
27+
// It does not claim the tier is useless. The reason
28+
// `validate-functional-completeness.ts` gives for it is real and pinned in the
29+
// last describe block: on the raw (non-`defineStack`) door, `os lint` — which
30+
// never parses — still reports rule findings on a stack whose schema step would
31+
// have failed. `normalized` means "needs no PARSED stack", never "is guaranteed
32+
// to see pre-parse evidence".
33+
34+
import { describe, expect, it, vi } from 'vitest';
35+
import { defineStack, normalizeStackInput } from '@objectstack/spec';
36+
37+
import { validateListViewMode } from './validate-list-view-mode.js';
38+
import { validateViewContainers } from './validate-view-containers.js';
39+
import { validateVisibilityPredicates } from './validate-visibility-predicates.js';
40+
import { runAuthoringRules } from './authoring-rules.js';
41+
42+
type AnyRec = Record<string, unknown>;
43+
44+
const manifest = {
45+
id: 'com.example.tier',
46+
namespace: 'tier',
47+
version: '1.0.0',
48+
type: 'app',
49+
name: 'Tier Probe',
50+
engines: { protocol: '^17' },
51+
};
52+
53+
/** `defineStack` warns on the D2 conversion channel; keep test output clean. */
54+
function quietly<T>(fn: () => T): { value?: T; error?: Error; warnings: string[] } {
55+
const warnings: string[] = [];
56+
const spy = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
57+
warnings.push(args.join(' '));
58+
});
59+
try {
60+
return { value: fn(), warnings };
61+
} catch (e) {
62+
return { error: e as Error, warnings };
63+
} finally {
64+
spy.mockRestore();
65+
}
66+
}
67+
68+
/**
69+
* The value the three commands hand `runAuthoringRules` for a `defineStack`
70+
* config: `loadConfig()` returns the module's default export (= `result.data`),
71+
* and `lint.ts` / `validate.ts` / `compile.ts` then call `normalizeStackInput`
72+
* on THAT. Modelled here rather than imported so this package does not depend
73+
* on the CLI; the shape is asserted against reality in the first block.
74+
*/
75+
const cliTierFor = (stack: AnyRec): AnyRec =>
76+
normalizeStackInput(defineStack(stack as never) as unknown as AnyRec);
77+
78+
// ───────────────────────────────────────────────────────────────────────────
79+
describe('the mechanism: for a defineStack config the `normalized` tier is POST-parse', () => {
80+
const flowStack = {
81+
manifest,
82+
flows: [
83+
{
84+
name: 'tier_flow',
85+
label: 'Tier Flow',
86+
type: 'schedule',
87+
nodes: [
88+
{ id: 'start', type: 'start', label: 'Start' },
89+
{ id: 'end', type: 'end', label: 'End' },
90+
],
91+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
92+
},
93+
],
94+
};
95+
96+
it('carries parse-time DEFAULTS that a truly pre-parse stack does not have', () => {
97+
// The tell #5693 tripped over: `os lint` printed a message arm reachable only
98+
// when `flow.runAs` IS a string, which only `FlowSchema`'s `.default('user')`
99+
// can produce. If this ever goes back to `undefined`, the whole premise below
100+
// changes and the comment on `AuthoringRuleInputTier` must be re-measured.
101+
const trulyPreParse = normalizeStackInput(structuredClone(flowStack)) as AnyRec;
102+
const cliTier = quietly(() => cliTierFor(structuredClone(flowStack)));
103+
expect(cliTier.error).toBeUndefined();
104+
105+
const pre = (trulyPreParse.flows as AnyRec[])[0];
106+
const post = (cliTier.value!.flows as AnyRec[])[0];
107+
108+
expect(pre.runAs).toBeUndefined();
109+
expect(pre.status).toBeUndefined();
110+
expect(post.runAs).toBe('user');
111+
expect(post.status).toBe('draft');
112+
});
113+
});
114+
115+
// ───────────────────────────────────────────────────────────────────────────
116+
describe('premise 1 (FALSE): "the parse strips a flat list view in `views: []`"', () => {
117+
const flatView = {
118+
name: 'tier_flat',
119+
label: 'Tier Flat',
120+
type: 'grid',
121+
data: { provider: 'object', object: 'tier_task' },
122+
columns: [{ field: 'name' }],
123+
};
124+
125+
it('defineStack REFUSES it by name instead — ViewSchema is strict since #4001', () => {
126+
const { error } = quietly(() => defineStack({ manifest, views: [flatView] } as never));
127+
expect(error).toBeDefined();
128+
// The schema names every offending key AND prints the wrap-it fix, which is
129+
// strictly more than `view-container-shape` would have said.
130+
expect(error!.message).toContain('views.0');
131+
expect(error!.message).toContain('Unrecognized key(s) on this view container');
132+
for (const key of ['type', 'data', 'columns']) expect(error!.message).toContain(key);
133+
expect(error!.message).toContain('defineView({ list:');
134+
});
135+
136+
it('still fires on the doors the strict parse never sees (raw input, no defineStack)', () => {
137+
// `os lint` on a raw object-literal config, `defineStack(x, { strict: false })`,
138+
// and direct API callers all reach the rule with the flat shape intact. This
139+
// is the arm that keeps the `looksFlat` branch alive — deleting it would take
140+
// the only diagnostic those three doors get.
141+
const findings = validateViewContainers(
142+
normalizeStackInput({ manifest, views: [flatView] }) as AnyRec,
143+
);
144+
expect(findings).toHaveLength(1);
145+
expect(findings[0].rule).toBe('view-container-shape');
146+
expect(findings[0].message).toContain('Flat list-view object');
147+
});
148+
149+
it('the arm that DOES survive the parse is the all-slots-empty container', () => {
150+
// Every key here is declared, so nothing is refused and nothing is stripped:
151+
// this is the shape `validateViewContainers` is genuinely the only reporter of.
152+
const emptyContainer = { manifest, views: [{ name: 'tier_empty' }] };
153+
const cli = quietly(() => cliTierFor(structuredClone(emptyContainer)));
154+
expect(cli.error).toBeUndefined();
155+
156+
const findings = validateViewContainers(cli.value!);
157+
expect(findings).toHaveLength(1);
158+
expect(findings[0].rule).toBe('view-container-shape');
159+
expect(findings[0].message).toContain('defines no views');
160+
});
161+
});
162+
163+
// ───────────────────────────────────────────────────────────────────────────
164+
describe('premise 2 (FALSE): "the parse strips `userFilters`/`quickFilters` on an object list view"', () => {
165+
const objectWithBadFilters = {
166+
manifest,
167+
objects: [
168+
{
169+
name: 'tier_task',
170+
label: 'Task',
171+
fields: {
172+
name: { type: 'text', label: 'Name' },
173+
status: { type: 'text', label: 'Status' },
174+
},
175+
listViews: {
176+
my_pending: {
177+
label: 'My Pending',
178+
type: 'grid',
179+
columns: [{ field: 'name' }],
180+
quickFilters: [{ field: 'status', label: 'Status' }],
181+
userFilters: { element: 'tabs', fields: ['status'] },
182+
},
183+
},
184+
},
185+
],
186+
};
187+
188+
it('defineStack REFUSES both — strict key rejection AND an enum refusal', () => {
189+
const { error } = quietly(() => defineStack(structuredClone(objectWithBadFilters) as never));
190+
expect(error).toBeDefined();
191+
// `quickFilters` — refused as an unrecognized KEY, with the rename suggestion.
192+
expect(error!.message).toContain('Unrecognized key(s) on this list view');
193+
expect(error!.message).toContain('quickFilters');
194+
// `element: 'tabs'` — refused as an invalid VALUE. Two different rejection
195+
// mechanisms; both louder than the rule, both at definition time.
196+
expect(error!.message).toContain("Invalid value 'tabs'");
197+
expect(error!.message).toContain('dropdown');
198+
});
199+
200+
it('still fires on raw input, which is the door that keeps the rule honest', () => {
201+
const findings = validateListViewMode(
202+
normalizeStackInput(structuredClone(objectWithBadFilters)) as AnyRec,
203+
);
204+
expect(findings.map((f) => f.rule)).toEqual([
205+
'list-view-filters-in-views-mode',
206+
'list-view-filters-in-views-mode',
207+
]);
208+
});
209+
});
210+
211+
// ───────────────────────────────────────────────────────────────────────────
212+
describe('premise 3 (FALSE): "the `visibleOn` alias survives until the parse"', () => {
213+
// The fold is an ADR-0087 D2 conversion inside `normalizeStackInput` — one
214+
// layer BEFORE this tier — not a parse-time `.transform()`. So the alias is
215+
// gone from the tier's own input on every door, `os lint` included. #6318.
216+
const aliasSites: Array<[string, AnyRec]> = [
217+
['views[].form.sections[]', {
218+
manifest,
219+
views: [{
220+
name: 'tier_form',
221+
form: { type: 'simple', sections: [{ label: 'S', visibleOn: 'record.a == 1', fields: [{ field: 'name' }] }] },
222+
}],
223+
}],
224+
['views[].formViews.edit.sections[]', {
225+
manifest,
226+
views: [{
227+
name: 'tier_form2',
228+
formViews: { edit: { type: 'simple', sections: [{ label: 'S', visibleOn: 'record.a == 1', fields: [{ field: 'name' }] }] } },
229+
}],
230+
}],
231+
['pages[].regions[].components[]', {
232+
manifest,
233+
pages: [{
234+
name: 'tier_page',
235+
label: 'P',
236+
type: 'home',
237+
object: 'tier_task',
238+
regions: [{ name: 'main', components: [{ type: 'element:text', visibility: "page.selectedId != ''" }] }],
239+
}],
240+
}],
241+
];
242+
243+
it.each(aliasSites)('%s: the alias is folded BEFORE the tier, so the rule reports 0', (_site, stack) => {
244+
// Fed the raw authored object (what the rule's own unit tests do) it reports.
245+
expect(validateVisibilityPredicates(structuredClone(stack))).toHaveLength(1);
246+
// Fed the `normalized` tier (what all three commands do) it does not.
247+
expect(validateVisibilityPredicates(normalizeStackInput(structuredClone(stack)) as AnyRec)).toEqual([]);
248+
});
249+
250+
it('the author is NOT left silent — the D2 conversion notice names the site and its retirement', () => {
251+
const { error, warnings } = quietly(() => defineStack(structuredClone(aliasSites[0][1]) as never));
252+
expect(error).toBeUndefined();
253+
expect(warnings).toHaveLength(1);
254+
expect(warnings[0]).toContain("'visibleOn' → 'visibleWhen'");
255+
expect(warnings[0]).toContain('views[0].form.sections[0].visibleWhen');
256+
expect(warnings[0]).toContain('retires in protocol 16');
257+
});
258+
259+
it('the predicate-VALUE rules in the same file are unaffected — do not connect them', () => {
260+
// The value moves into `visibleWhen` intact, so these two still report on the
261+
// tier. #6318 is about the alias-KEY rule only.
262+
const bare = {
263+
manifest,
264+
views: [{
265+
name: 'tier_form3',
266+
form: { type: 'simple', sections: [{ label: 'S', visibleWhen: "status == 'active'", fields: [{ field: 'name' }] }] },
267+
}],
268+
};
269+
const cli = quietly(() => cliTierFor(structuredClone(bare)));
270+
expect(cli.error).toBeUndefined();
271+
expect(validateVisibilityPredicates(cli.value!).map((f) => f.rule)).toEqual([
272+
'visibility-bare-identifier',
273+
]);
274+
});
275+
});
276+
277+
// ───────────────────────────────────────────────────────────────────────────
278+
describe('what `normalized` DOES buy: findings survive a schema error that stops the parse', () => {
279+
it('the registry reports on a stack whose parse would have failed outright', () => {
280+
// This is the surviving justification, and it is not theoretical: on the raw
281+
// door `os validate` stops at the schema step and prints zero rule findings,
282+
// while `os lint` — which never parses — still names both rules. A `parsed`
283+
// tier could not have run here at all.
284+
const unparseable = {
285+
manifest,
286+
objects: [{
287+
name: 'tier_task',
288+
label: 'Task',
289+
fields: { name: { type: 'text', label: 'Name' } },
290+
listViews: {
291+
my_pending: {
292+
label: 'My Pending',
293+
type: 'grid',
294+
columns: [{ field: 'name' }],
295+
quickFilters: [{ field: 'status', label: 'Status' }],
296+
},
297+
},
298+
}],
299+
views: [{ name: 'tier_flat', label: 'F', type: 'grid', columns: [{ field: 'name' }] }],
300+
};
301+
302+
// The parse refuses it — that is the premise of this test, not an aside.
303+
expect(quietly(() => defineStack(structuredClone(unparseable) as never)).error).toBeDefined();
304+
305+
// …and the `normalized`-tier rules still deliver their verdicts.
306+
const findings = runAuthoringRules('lint', {
307+
normalized: normalizeStackInput(structuredClone(unparseable)) as AnyRec,
308+
});
309+
const rules = new Set(findings.map((f) => f.rule));
310+
expect(rules.has('list-view-filters-in-views-mode')).toBe(true);
311+
expect(rules.has('view-container-shape')).toBe(true);
312+
});
313+
});

0 commit comments

Comments
 (0)