From 78cd0e42cedd2c7af09d898d29c7708e366eb270 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:28:32 +0000 Subject: [PATCH 1/3] feat(lint): export the dimension leg's group-key classifier groupKeyClassOf Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/lint/src/index.ts | 6 +- ...alidate-dataset-measure-aggregates.test.ts | 92 +++++++++++++++++++ .../validate-dataset-measure-aggregates.ts | 23 ++++- 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index ebfdb83cc90..a384b3eec31 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -60,8 +60,12 @@ export { validateDatasetMeasureAggregates, MEASURE_AGGREGATE_FIELD_TYPE_REFUSED, DIMENSION_JSON_STORED_FIELD_REFUSED, + // [#23235] The dimension leg's group-key verdict on its own, so a consumer + // deriving dataset dimensions (cloud's AI build) asks the door's two + // predicates in the door's order here rather than composing them itself. + groupKeyClassOf, } from './validate-dataset-measure-aggregates.js'; -export type { DatasetMeasureAggregateFinding } from './validate-dataset-measure-aggregates.js'; +export type { DatasetMeasureAggregateFinding, GroupKeyClass } from './validate-dataset-measure-aggregates.js'; export { validateStackExpressions, fieldRuleRootIssue, FIELD_RULE_BOUND_ROOTS } from './validate-expressions.js'; export type { ExprIssue } from './validate-expressions.js'; diff --git a/packages/lint/src/validate-dataset-measure-aggregates.test.ts b/packages/lint/src/validate-dataset-measure-aggregates.test.ts index facfc760755..e19be377f56 100644 --- a/packages/lint/src/validate-dataset-measure-aggregates.test.ts +++ b/packages/lint/src/validate-dataset-measure-aggregates.test.ts @@ -24,6 +24,7 @@ import { STRUCTURED_JSON_TYPES, isAggregateCompatibleWithFieldType, isMultiValueField, + type Field, } from '@objectstack/spec/data'; import { runAuthoringRules } from './authoring-rules.js'; @@ -31,8 +32,11 @@ import { explainRule } from './rule-explanations.js'; import { DIMENSION_JSON_STORED_FIELD_REFUSED, MEASURE_AGGREGATE_FIELD_TYPE_REFUSED, + groupKeyClassOf as moduleGroupKeyClassOf, validateDatasetMeasureAggregates as validateDatasetMeasureAggregatesUnrecorded, } from './validate-dataset-measure-aggregates.js'; +// [#23235] The classifier as a consumer reaches it: the package barrel. +import { groupKeyClassOf, type GroupKeyClass } from './index.js'; // [#22161] Each finding of the two ids is one verdict sentence; the reasoning // it used to carry is the id's `os explain` entry. Every call below records @@ -1089,6 +1093,94 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel }); }); +// ─────────────────────────────────────────────────────────────────────────── +// [#23235] `groupKeyClassOf` — the dimension leg's verdict, exported. A +// consumer deriving dataset dimensions asks it instead of composing the +// analytics door's two predicates; these cases hold the export equal to what +// the rule itself decides, on the rule's own fixtures (the dataset leg's +// `dimensionStack` and the cube leg's `cubeStack`). +// ─────────────────────────────────────────────────────────────────────────── + +/** The words a `dimension-json-stored-field-refused` message names each class with. */ +const CLASS_WORDS: Record = { + 'structured-json': 'a structured-JSON value', + 'multi-value': 'a multi-value field', +}; + +/** The class the rule's one finding names, or `null` when the rule is silent. */ +const classTheRuleNamed = (found: ReadonlyArray<{ message: string }>): GroupKeyClass | null => { + expect(found.length).toBeLessThanOrEqual(1); + if (found.length === 0) return null; + const named = (Object.keys(CLASS_WORDS) as GroupKeyClass[]).filter((cls) => found[0].message.includes(CLASS_WORDS[cls])); + expect(named).toHaveLength(1); + return named[0]; +}; + +describe('[#23235] groupKeyClassOf — the exported classifier and the rule agree on the rule\'s own fixtures', () => { + it('is the function the rule calls, re-exported from the package barrel', () => { + expect(groupKeyClassOf).toBe(moduleGroupKeyClassOf); + }); + + it('agrees with the dataset leg on every declared FieldType, flagged and not — class included', () => { + const tally: Record = { 'structured-json': 0, 'multi-value': 0, served: 0 }; + for (const fieldType of FieldType.options) { + for (const multiple of [false, true]) { + const def = multiple ? { type: fieldType, multiple: true } : { type: fieldType }; + const label = `${fieldType}${multiple ? ', multiple' : ''}`; + const exported = groupKeyClassOf(def); + expect(exported, label).toBe(classTheRuleNamed(dimensionFindings(dimensionStack(def)))); + tally[exported ?? 'served']++; + } + } + // Floors on every side, so neither an always-null nor an always-refusing + // classifier can make the agreement vacuous. + expect(tally['structured-json']).toBe(2 * STRUCTURED_JSON_TYPES.size); + expect(tally['multi-value']).toBe(2 * MULTI_OPTION_TYPES.size + MULTI_CAPABLE_TYPES.size); + expect(tally.served).toBeGreaterThan(50); + }); + + it('agrees with the dimension leg on its named fixtures', () => { + const fixtures: Array<[Record, GroupKeyClass | null]> = [ + [{ type: 'json' }, 'structured-json'], + [{ type: 'text' }, null], + [{ type: 'multiselect' }, 'multi-value'], + [{ type: 'checkboxes' }, 'multi-value'], + [{ type: 'tags' }, 'multi-value'], + [{ type: 'select', multiple: true }, 'multi-value'], + [{ type: 'select' }, null], + ]; + for (const [def, expected] of fixtures) { + const label = JSON.stringify(def); + expect(groupKeyClassOf(def as { type: string; multiple?: boolean }), label).toBe(expected); + expect(classTheRuleNamed(dimensionFindings(dimensionStack(def))), label).toBe(expected); + } + }); + + it('agrees with the cube leg on every column of the cube fixture', () => { + const stack = cubeStack({}); + const ledger = (stack.objects as Array<{ name: string; fields: Record }>)[0]; + const columns = Object.entries(ledger.fields).filter(([, def]) => typeof def.type === 'string'); + expect(columns.length).toBeGreaterThanOrEqual(6); + const classes = new Set(); + for (const [column, def] of columns) { + const exported = groupKeyClassOf(def); + classes.add(exported); + expect(exported, column).toBe(classTheRuleNamed(cubeDimensionFindings(cubeStack({ dimensions: { probe: dim(column) } })))); + } + // The fixture exercises all three answers. + expect([...classes].sort()).toEqual([null, 'multi-value', 'structured-json'].sort()); + }); + + it('takes a declared Field verbatim — the shape a consumer holds', () => { + const tags: Field = { name: 'labels', type: 'tags' }; + const stages: Field = { name: 'stages', type: 'select', multiple: true, options: [{ label: 'Open', value: 'open' }] }; + const stage: Field = { name: 'stage', type: 'select', options: [{ label: 'Open', value: 'open' }] }; + expect(groupKeyClassOf(tags)).toBe('multi-value'); + expect(groupKeyClassOf(stages)).toBe('multi-value'); + expect(groupKeyClassOf(stage)).toBeNull(); + }); +}); + describe('[#22161] one-line verdicts — the two ids', () => { const IDS = [MEASURE_AGGREGATE_FIELD_TYPE_REFUSED, DIMENSION_JSON_STORED_FIELD_REFUSED]; diff --git a/packages/lint/src/validate-dataset-measure-aggregates.ts b/packages/lint/src/validate-dataset-measure-aggregates.ts index ec169612b5d..3fc39652c3a 100644 --- a/packages/lint/src/validate-dataset-measure-aggregates.ts +++ b/packages/lint/src/validate-dataset-measure-aggregates.ts @@ -325,6 +325,13 @@ function declarerOf(verdict: Extract, baseObje return verdict.object === baseObject ? `object "${baseObject}"` : `joined object "${verdict.object}"`; } +/** + * The JSON-stored class of a column the analytics door refuses to group by: + * `'structured-json'` (the {@link STRUCTURED_JSON_TYPES} class) or + * `'multi-value'` (a list stored as JSON, {@link isMultiValueField}). + */ +export type GroupKeyClass = 'structured-json' | 'multi-value'; + /** * [#20890] The class of a column a dimension GROUPS by, or `null` when it * stores one scalar value — the analytics door's two predicates, in its @@ -332,8 +339,20 @@ function declarerOf(verdict: Extract, baseObje * declaration, `multiple` included). ⛔ Never the type alone: a type-only * reading would refuse `tags` and pass a `select` with `multiple: true`, which * is the same JSON column. + * + * [#23235] Exported from the package barrel as the one place the door's two + * predicates are applied in the door's order, so a consumer that derives + * group keys outside a config file (cloud's AI build deriving dataset + * dimensions) asks this verdict instead of composing the two predicates + * itself — `null` is a group key the door serves. Both legs of + * `dimension-json-stored-field-refused` (dataset and cube) keep calling it, + * and this file's test holds the export equal to the rule's verdict on the + * rule's own fixtures. The input is the spec's own value-contract slice of a + * field definition (`ValueShapeFieldDef`: `type` and `multiple`, the two keys + * the predicates read), so a declared `Field` passes verbatim; it reads no + * object graph, so resolving a dotted path to its leaf stays the caller's. */ -function groupKeyClassOf(shape: ValueShapeFieldDef): 'structured-json' | 'multi-value' | null { +export function groupKeyClassOf(shape: ValueShapeFieldDef): GroupKeyClass | null { if (STRUCTURED_JSON_TYPES.has(shape.type)) return 'structured-json'; return isMultiValueField(shape) ? 'multi-value' : null; } @@ -362,7 +381,7 @@ function jsonStoredDimensionFinding( site: MemberSite, leaf: Extract, shape: ValueShapeFieldDef, - cls: 'structured-json' | 'multi-value', + cls: GroupKeyClass, ): DatasetMeasureAggregateFinding { return { severity: 'error', From 11b6c52324c49c422bf24babce701bf88dac8da5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:32:33 +0000 Subject: [PATCH 2/3] chore(changeset): lint minor for the exported group-key classifier Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/23235-export-group-key-classifier.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/23235-export-group-key-classifier.md diff --git a/.changeset/23235-export-group-key-classifier.md b/.changeset/23235-export-group-key-classifier.md new file mode 100644 index 00000000000..a7a077d6a40 --- /dev/null +++ b/.changeset/23235-export-group-key-classifier.md @@ -0,0 +1,16 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): export the dataset dimension leg's group-key classifier, so a consumer asks the lint instead of composing the analytics door's two predicates itself (#23235) + +Clause-②: yes + +`@objectstack/lint` now exports `groupKeyClassOf(shape)` and its result type `GroupKeyClass`: + +- `groupKeyClassOf(shape)` answers "may a dataset or cube dimension group by a column declared this way?". `null` means yes: the column stores one scalar value, and the analytics door serves it as a group key. Otherwise it names the JSON-stored class the door refuses with `400 INVALID_FIELD`: `'structured-json'` (a type in `STRUCTURED_JSON_TYPES`) or `'multi-value'` (`isMultiValueField`: `multiselect`, `checkboxes`, `tags`, or a multi-capable type such as `select` declared `multiple: true`). The two spec predicates are applied in the door's order. +- `shape` is `ValueShapeFieldDef` from `@objectstack/spec/data`, the slice of a field definition the predicates read (`type`, `multiple`). A declared `Field` passes as it is. The classifier reads no object graph: for a dotted path, pass the leaf field's definition. + +A consumer that derives dataset dimensions keeps a field as a group key when `groupKeyClassOf(field) === null`, and gets the lint's own verdict. The `dimension-json-stored-field-refused` rule (dataset dimensions and cube dimensions) keeps calling the same function; on the rule's own fixtures, every declared field type with and without `multiple: true` and every column of the cube fixture, the export and the rule agree on the verdict and the class. The rule's findings, paths and texts are unchanged. + +No export is removed, and no accepted or refused shape changes. From dfe60e3486ef23d1c3da427d1964eb9adfd80158 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:10:21 +0000 Subject: [PATCH 3/3] test(lint): keep the classifier pin's helper out of the where-matcher discovery shape Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- ...alidate-dataset-measure-aggregates.test.ts | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/packages/lint/src/validate-dataset-measure-aggregates.test.ts b/packages/lint/src/validate-dataset-measure-aggregates.test.ts index e19be377f56..9b28cd521fc 100644 --- a/packages/lint/src/validate-dataset-measure-aggregates.test.ts +++ b/packages/lint/src/validate-dataset-measure-aggregates.test.ts @@ -1102,19 +1102,24 @@ describe('the cube leg — every cube measure is judged by the aggregate × fiel // ─────────────────────────────────────────────────────────────────────────── /** The words a `dimension-json-stored-field-refused` message names each class with. */ -const CLASS_WORDS: Record = { - 'structured-json': 'a structured-JSON value', - 'multi-value': 'a multi-value field', -}; +const GROUP_KEY_CLASSES: readonly GroupKeyClass[] = ['structured-json', 'multi-value']; +const CLASS_WORDS: ReadonlyMap = new Map([ + ['structured-json', 'a structured-JSON value'], + ['multi-value', 'a multi-value field'], +]); -/** The class the rule's one finding names, or `null` when the rule is silent. */ -const classTheRuleNamed = (found: ReadonlyArray<{ message: string }>): GroupKeyClass | null => { - expect(found.length).toBeLessThanOrEqual(1); - if (found.length === 0) return null; - const named = (Object.keys(CLASS_WORDS) as GroupKeyClass[]).filter((cls) => found[0].message.includes(CLASS_WORDS[cls])); - expect(named).toHaveLength(1); - return named[0]; -}; +/** + * The class the rule's one finding names, or `null` when the rule is silent. + * Anything else — several findings, or a message naming no class or both — + * is `'unreadable'`, which no classifier answer equals. + */ +function classTheRuleNamed(found: ReadonlyArray<{ message: string }>, column: string): GroupKeyClass | null | 'unreadable' { + const [only, ...more] = found; + if (!only) return null; + if (more.length > 0 || !only.message.includes(`"${column}"`)) return 'unreadable'; + const named = GROUP_KEY_CLASSES.filter((cls) => only.message.includes(CLASS_WORDS.get(cls) ?? cls)); + return named.length === 1 ? named[0] : 'unreadable'; +} describe('[#23235] groupKeyClassOf — the exported classifier and the rule agree on the rule\'s own fixtures', () => { it('is the function the rule calls, re-exported from the package barrel', () => { @@ -1128,7 +1133,7 @@ describe('[#23235] groupKeyClassOf — the exported classifier and the rule agre const def = multiple ? { type: fieldType, multiple: true } : { type: fieldType }; const label = `${fieldType}${multiple ? ', multiple' : ''}`; const exported = groupKeyClassOf(def); - expect(exported, label).toBe(classTheRuleNamed(dimensionFindings(dimensionStack(def)))); + expect(exported, label).toBe(classTheRuleNamed(dimensionFindings(dimensionStack(def)), 'grouped')); tally[exported ?? 'served']++; } } @@ -1152,7 +1157,7 @@ describe('[#23235] groupKeyClassOf — the exported classifier and the rule agre for (const [def, expected] of fixtures) { const label = JSON.stringify(def); expect(groupKeyClassOf(def as { type: string; multiple?: boolean }), label).toBe(expected); - expect(classTheRuleNamed(dimensionFindings(dimensionStack(def))), label).toBe(expected); + expect(classTheRuleNamed(dimensionFindings(dimensionStack(def)), 'grouped'), label).toBe(expected); } }); @@ -1165,7 +1170,9 @@ describe('[#23235] groupKeyClassOf — the exported classifier and the rule agre for (const [column, def] of columns) { const exported = groupKeyClassOf(def); classes.add(exported); - expect(exported, column).toBe(classTheRuleNamed(cubeDimensionFindings(cubeStack({ dimensions: { probe: dim(column) } })))); + expect(exported, column).toBe( + classTheRuleNamed(cubeDimensionFindings(cubeStack({ dimensions: { probe: dim(column) } })), column), + ); } // The fixture exercises all three answers. expect([...classes].sort()).toEqual([null, 'multi-value', 'structured-json'].sort());