Skip to content

Commit c1c954b

Browse files
baozhoutaoclaude
andcommitted
feat(lint): 视图 searchableFields 按运行时同一套判定做构建期校验 —— lookup 笔误在 validate 期就报错,而非运行时 400 (#4830)
视图级 searchableFields 会被客户端逐字回显为 $searchFields,#4254 入口闸按 resolveSearchFieldResolution 判定后整条查询 400 INVALID_FIELD;此前 compile/validate 只查存在性,类型笔误全绿放行。 新规则 searchable-field-unsearchable(error):对象内建 listViews、defineView 的 list/listViews、react <ListView> 四个 narrowing 面,直接消费运行时同一个 resolveSearchFieldResolution(@objectstack/spec/data),不复制类型清单, declared = enforced。对象自身 canonical 集合维持只查存在性(运行时按存在过滤、 不按类型过滤,构建期拒绝会误伤运行时接受的元数据,ADR-0072 D1)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c4ab50b commit c1c954b

6 files changed

Lines changed: 466 additions & 55 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830)
6+
7+
视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而
8+
REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定
9+
该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400
10+
(`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查
11+
字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。
12+
13+
新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级
14+
narrowing(对象内建 `listViews``defineView``list`/`listViews`、react 页面的
15+
`<ListView searchableFields>`)按**运行时同一个函数**(`resolveSearchFieldResolution`,
16+
非复制的类型清单,杜绝再度漂移)判定 declared = enforced:
17+
18+
- 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等
19+
auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本
20+
对象 text/formula 字段」的处方;
21+
- 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、
22+
不能放宽,ADR-0061);
23+
- 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按
24+
存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据
25+
(ADR-0072 D1);
26+
- 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报
27+
不可误报)。
28+
29+
内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包
30+
barrel)签名有变:索引值从 `Set<string> | null` 变为 `ObjectSearchTarget | null`,并新增
31+
可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。

packages/lint/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,12 @@ export type { NavTargetRefFinding, NavTargetRefSeverity } from './validate-nav-t
225225
export {
226226
validateSearchableFields,
227227
SEARCHABLE_FIELD_UNKNOWN,
228+
SEARCHABLE_FIELD_UNSEARCHABLE,
228229
} from './validate-searchable-fields.js';
229230
export type {
230231
SearchableFieldFinding,
231232
SearchableFieldSeverity,
233+
SearchableFieldRole,
232234
} from './validate-searchable-fields.js';
233235

234236
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';

packages/lint/src/validate-react-page-props.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import {
88
REACT_BLOCK_NEEDS_RECORD_CONTEXT,
99
type ReactPropFinding as PropFinding,
1010
} from './validate-react-page-props.js';
11-
import { SEARCHABLE_FIELD_UNKNOWN } from './validate-searchable-fields.js';
11+
import {
12+
SEARCHABLE_FIELD_UNKNOWN,
13+
SEARCHABLE_FIELD_UNSEARCHABLE,
14+
} from './validate-searchable-fields.js';
1215
import { PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';
1316

1417
const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] });
@@ -303,11 +306,26 @@ describe('validateReactPageProps — <ListView> searchableFields (#4329)', () =>
303306

304307
it('accepts registry-injected system columns absent from authored fields', () => {
305308
const f = validateReactPageProps(
306-
listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at', 'owner_id']}`)),
309+
listPage(list(`objectName="crm_account" searchableFields={['name', 'created_at']}`)),
307310
);
308311
expect(f).toEqual([]);
309312
});
310313

314+
it('flags an authored lookup — the runtime refuses the echoed override (#4830)', () => {
315+
// <ListView searchableFields> is echoed verbatim as `$searchFields`, and
316+
// the #4254 ingress gate rejects a lookup with 400 INVALID_FIELD — the
317+
// whole toolbar search, for every role. Same judgment as the metadata
318+
// list-view surface, by the shared core.
319+
const f = validateReactPageProps(
320+
listPage(list(`objectName="crm_account" searchableFields={['name', 'owner_id']}`)),
321+
);
322+
expect(f).toHaveLength(1);
323+
expect(f[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
324+
expect(f[0].severity).toBe('error');
325+
expect(f[0].path).toBe('pages[0].source › searchableFields[1]');
326+
expect(f[0].message).toContain('400 INVALID_FIELD');
327+
});
328+
311329
it('flags a dotted path — search cannot resolve the traversal', () => {
312330
const f = validateReactPageProps(
313331
listPage(list(`objectName="crm_account" searchableFields={['owner_id.name']}`)),

packages/lint/src/validate-react-page-props.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -869,11 +869,14 @@ export function validateReactPageProps(stack: AnyRec): ReactPropFinding[] {
869869
checkObjectChart({ values, where, path }, objectFields, findings);
870870
}
871871
// <ListView searchableFields> names fields on the bound object — the
872-
// react-surface twin of `searchable-field-unknown` (#4329). It runs
873-
// the metadata rule's own core, so the skips (cross-package object,
874-
// no authored field map, system columns) and the dotted-path
875-
// strictness match by construction. A non-static value — either
876-
// attribute — bails inside the checker: unresolvable is not wrong.
872+
// react-surface twin of `searchable-field-unknown` (#4329) and, as a
873+
// view-level narrowing echoed to the runtime as `$searchFields`, of
874+
// `searchable-field-unsearchable` too (#4830, the checker's default
875+
// role). It runs the metadata rule's own core, so the skips
876+
// (cross-package object, no authored field map, system columns) and
877+
// the dotted-path strictness match by construction. A non-static
878+
// value — either attribute — bails inside the checker: unresolvable
879+
// is not wrong.
877880
if (tag === 'ListView' && !hasSpread) {
878881
findings.push(
879882
...checkSearchableFieldList(

packages/lint/src/validate-searchable-fields.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest';
44
import {
55
validateSearchableFields,
66
SEARCHABLE_FIELD_UNKNOWN,
7+
SEARCHABLE_FIELD_UNSEARCHABLE,
78
} from './validate-searchable-fields.js';
89

910
/**
@@ -287,6 +288,173 @@ describe('validateSearchableFields — list views that narrow the set', () => {
287288
expect(findings).toEqual([]);
288289
});
289290

291+
it('flags a lookup entry the runtime would refuse — the #4830 defect', () => {
292+
// The issue's repro verbatim: `searchableFields: ['name', '<lookup>']` on a
293+
// view, validate all green, first keystroke in the toolbar search → the
294+
// whole query 400s (INVALID_FIELD) for every role. The runtime judgment
295+
// is `resolveSearchFieldResolution` (@objectstack/spec/data); this rule
296+
// consults the same function, so declared = enforced.
297+
const findings = validateSearchableFields({
298+
objects: [
299+
{
300+
name: 'ehr_task',
301+
fields: {
302+
name: { type: 'text' },
303+
project_id: { type: 'lookup', reference: 'ehr_project' },
304+
},
305+
listViews: { all: { type: 'grid', searchableFields: ['name', 'project_id'] } },
306+
},
307+
],
308+
});
309+
310+
expect(findings).toHaveLength(1);
311+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
312+
expect(findings[0].severity).toBe('error');
313+
expect(findings[0].path).toBe('objects[0].listViews.all.searchableFields[1]');
314+
expect(findings[0].message).toContain("type 'lookup'");
315+
expect(findings[0].message).toContain('400 INVALID_FIELD');
316+
// The lookup-specific prescription: search cannot cross objects, so the
317+
// related record's title must be mirrored onto a local text/formula field.
318+
expect(findings[0].hint).toContain('mirror');
319+
});
320+
321+
it('flags a real field outside the object\'s declared searchableFields', () => {
322+
// Runtime parity, declared branch: the object declares the canonical set,
323+
// and the #4254 gate refuses a `$searchFields` entry outside it even when
324+
// the field exists and is text-like.
325+
const findings = validateSearchableFields({
326+
objects: [
327+
{
328+
name: 'crm_account',
329+
fields: {
330+
name: { type: 'text' },
331+
billing_email: { type: 'email' },
332+
notes: { type: 'textarea' },
333+
},
334+
searchableFields: ['name', 'billing_email'],
335+
listViews: { all: { type: 'grid', searchableFields: ['notes'] } },
336+
},
337+
],
338+
});
339+
340+
expect(findings).toHaveLength(1);
341+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
342+
expect(findings[0].message).toContain('name, billing_email');
343+
expect(findings[0].hint).toContain('crm_account.searchableFields');
344+
});
345+
346+
it('passes a view entry of odd type once the object declares it searchable', () => {
347+
// The runtime's declared branch filters by EXISTENCE, never by type: a
348+
// json/lookup column declared on the OBJECT is honored by the engine and
349+
// admitted by the gate, so the view echoing it must stay green — flagging
350+
// it would reject metadata the runtime accepts.
351+
const findings = validateSearchableFields({
352+
objects: [
353+
{
354+
name: 'crm_account',
355+
fields: { name: { type: 'text' }, payload: { type: 'json' } },
356+
searchableFields: ['name', 'payload'],
357+
listViews: { all: { type: 'grid', searchableFields: ['payload'] } },
358+
},
359+
],
360+
});
361+
362+
expect(findings).toEqual([]);
363+
});
364+
365+
it('flags a hidden field in a view narrowing (auto-default excludes it)', () => {
366+
const findings = validateSearchableFields({
367+
objects: [
368+
{
369+
name: 'crm_account',
370+
fields: { name: { type: 'text' }, secret_note: { type: 'text', hidden: true } },
371+
listViews: { all: { type: 'grid', searchableFields: ['secret_note'] } },
372+
},
373+
],
374+
});
375+
376+
expect(findings).toHaveLength(1);
377+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
378+
expect(findings[0].message).toContain('hidden');
379+
});
380+
381+
it('checks defineView list and named listViews the same way', () => {
382+
const findings = validateSearchableFields({
383+
objects: [
384+
{
385+
name: 'crm_account',
386+
fields: { name: { type: 'text' }, owner_ref: { type: 'lookup', reference: 'sys_user' } },
387+
},
388+
],
389+
views: [
390+
{
391+
objectName: 'crm_account',
392+
list: { type: 'grid', searchableFields: ['owner_ref'] },
393+
listViews: { active: { type: 'grid', searchableFields: ['owner_ref'] } },
394+
},
395+
],
396+
});
397+
398+
expect(findings.map((f) => [f.rule, f.path])).toEqual([
399+
[SEARCHABLE_FIELD_UNSEARCHABLE, 'views[0].list.searchableFields[0]'],
400+
[SEARCHABLE_FIELD_UNSEARCHABLE, 'views[0].listViews.active.searchableFields[0]'],
401+
]);
402+
});
403+
404+
it('keeps runtime parity when the object declares system columns searchable', () => {
405+
// The runtime resolves the declared branch against the REGISTRY map, so
406+
// `searchableFields: ['created_at']` is a non-empty declared set there —
407+
// NOT a fall-through to the auto-default. A view entry outside that set
408+
// must be flagged the way the gate refuses it, even though `created_at`
409+
// is invisible to the authored field map.
410+
const findings = validateSearchableFields({
411+
objects: [
412+
{
413+
name: 'audit_log',
414+
fields: { name: { type: 'text' }, detail: { type: 'textarea' } },
415+
searchableFields: ['created_at'],
416+
listViews: { all: { type: 'grid', searchableFields: ['detail'] } },
417+
},
418+
],
419+
});
420+
421+
expect(findings).toHaveLength(1);
422+
expect(findings[0].rule).toBe(SEARCHABLE_FIELD_UNSEARCHABLE);
423+
expect(findings[0].message).toContain('created_at');
424+
});
425+
426+
it('leaves a system column in a view narrowing alone (registry meta invisible)', () => {
427+
// `created_at` in a narrowing would be refused by the runtime, but its
428+
// registry-side metadata is not visible to the linter — a judgment here
429+
// risks the false positive ADR-0072 D1 forbids, so it is a documented
430+
// missed finding instead.
431+
const findings = validateSearchableFields({
432+
objects: [
433+
{
434+
name: 'crm_account',
435+
fields: { name: { type: 'text' } },
436+
listViews: { all: { type: 'grid', searchableFields: ['name', 'created_at'] } },
437+
},
438+
],
439+
});
440+
441+
expect(findings).toEqual([]);
442+
});
443+
444+
it('does not type-check the object\'s own canonical set (runtime honors it)', () => {
445+
const findings = validateSearchableFields({
446+
objects: [
447+
{
448+
name: 'crm_account',
449+
fields: { name: { type: 'text' }, owner_ref: { type: 'lookup', reference: 'sys_user' } },
450+
searchableFields: ['name', 'owner_ref'],
451+
},
452+
],
453+
});
454+
455+
expect(findings).toEqual([]);
456+
});
457+
290458
it('skips a view bound to an object this stack does not define', () => {
291459
// The object may come from another package; a field map we cannot see
292460
// cannot be judged — the same skip the page/flow/widget rules take.

0 commit comments

Comments
 (0)