Skip to content

feat(lint): export the dataset dimension leg group-key classifier groupKeyClassOf - #23520

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23235-export-group-key-classifier
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23235-export-group-key-classifier

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23235

Clause-②: yes

What

@objectstack/lint now exports the dataset dimension leg's group-key classifier from the package barrel:

  • groupKeyClassOf(shape: ValueShapeFieldDef): GroupKeyClass | null. null means 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' (STRUCTURED_JSON_TYPES) or 'multi-value' (isMultiValueField). The two spec predicates are applied in the door's order.
  • GroupKeyClass = 'structured-json' | 'multi-value', the named result type. jsonStoredDimensionFinding now uses it in place of the inline literal union.

dimension-json-stored-field-refused keeps calling the same function on both legs (dataset dimensions at the datasets[i].dimensions[k].field site, cube dimensions at the analyticsCubes[i].dimensions.NAME.sql site). The barrel re-exports the function the module defines. There is no wrapper and no second copy. The rule's findings, paths and texts are unchanged: no existing assertion in the rule's suite was edited.

Why this input shape, and no wrapper

The brief asked whether a consumer must pass ValueShapeFieldDef or whether a wrapper should take a field definition. Measured on main:

  • ValueShapeFieldDef is not private. @objectstack/spec/data exports it (field-value.zod.ts). It is the value contract's structural slice of a field definition: type, multiple, options. Its docblock says it is structural so callers holding their own field-def types can pass theirs as they are.
  • The consumer holds a field definition. A spec Field is assignable to it, so groupKeyClassOf(field) works with no adapter. A new case pins this at compile time: three Field literals (tags, select with multiple: true, a plain select) are passed with no cast. The lint package's test layer is type-checked, and check:test-typecheck is OK for packages/lint/tsconfig.test.json.
  • The analytics door's own classifier (groupedClassOf in service-analytics/src/structured-json-dimension-door.ts) takes the same ValueShapeFieldDef. The export accepts exactly the shape the door judges, so the input is not widened. A wrapper over a field definition would add a second signature for the same verdict.
  • The classifier reads no object graph. Resolving a dotted path to its leaf is still the caller's job, and the JSDoc and the changeset say so.

Pin: the export and the rule agree on the rule's own fixtures

New describe block [#23235] groupKeyClassOf — the exported classifier and the rule agree on the rule's own fixtures. It imports the classifier through ./index.js, the consumer's door:

  1. The barrel export is the module's function (toBe identity).
  2. Dataset leg sweep. For every FieldType.options entry, with and without multiple: true, the export's answer equals the class named in the rule's finding, or null when the rule is silent, on the rule's own dimensionStack fixture. Floors are exact on the two refusing classes (2 × STRUCTURED_JSON_TYPES.size and 2 × MULTI_OPTION_TYPES.size + MULTI_CAPABLE_TYPES.size) and more than 50 on the served side, so a classifier that always answers the same thing cannot make the agreement vacuous.
  3. Named fixtures. The dimension leg's own shapes (json, text, multiselect, checkboxes, tags, select with multiple: true, select) give the same expected class from both the export and the rule.
  4. Cube leg. Every typed column of the cube leg's own cubeStack fixture object (fx_ledger) gives the same answer as the rule's cube dimension finding. All three answers are exercised.
  5. A declared Field passes verbatim (see above).

The class is read from the finding's message words (a structured-JSON value / a multi-value field) and the column name. Several findings, or words naming no class or both, read as 'unreadable', which no classifier answer equals.

Ablation (each leg's call site replaced by a type-only private copy)

Both runs went through scripts/ablation-replace.mjs under the verify lock. The anchor hit once and moved 1 -> 0. Each run restored: the blob after restore equals the HEAD blob 3fc39652c3ac and git diff HEAD is empty. The test file imports src through relative paths, so no dist is involved.

  • Dataset leg (const cls = groupKeyClassOf(shape); replaced by a STRUCTURED_JSON_TYPES-only check): 4 failed / 61 passed (65). Two of the failures are the new pins: agrees with the dataset leg on every declared FieldType… and agrees with the dimension leg on its named fixtures.
  • Cube leg (const cls = groupKeyClassOf(column.shape); replaced the same way): 3 failed / 62 passed (65). One of the failures is the new pin: agrees with the cube leg on every column of the cube fixture.

The observed direction is red, as expected.

Landing spot

As claimed: packages/lint/src/validate-dataset-measure-aggregates.ts, packages/lint/src/index.ts, the rule's test file, and .changeset/23235-export-group-key-classifier.md (@objectstack/lint: minor, Clause-②: yes, additive). @objectstack/lint has no API-surface snapshot. check:published-readme-exports is green, and the lint README lists no exports.

Verification (final head dfe60e348)

  • pnpm --filter '@objectstack/lint^...' build: exit 0. pnpm --filter @objectstack/lint build: exit 0, check-dts-emitted 6/6. groupKeyClassOf and GroupKeyClass are in dist/index.d.ts, and the CJS (dist/index.cjs) and ESM entries both load and answer multi-value / structured-json / null.
  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2: 139 files / 6731 tests passed.
  • pnpm --filter @objectstack/lint typecheck (tsc --noEmit + check:test-typecheck): exit 0.
  • pnpm --filter @objectstack/spec run test:repo: 60 files / 1029 tests passed (spec rebuilt after merging origin/main).
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 62 commands, plus check:published-readme-exports, check-changeset-fixed.mjs and check:error-code-casing from the roster block. All exit 0 except:
    • check:where-matcher failed on the first draft of the pin's helper. Its structural discovery seated the helper as a WHERE matcher (Object.keys(...) over a same-file map plus an indexed first parameter). The helper was rewritten to destructure and to read a Map. The gate is now green: 0 new matchers, baseline unchanged.
    • check:dual-build-cjs-loads and check:lean-entry-closure printed PREREQUISITE NOT MET (exit 3): they need a whole-workspace dist/. These are NOT MEASURED locally and are declared to CI. The lint package's own CJS/ESM load was probed directly (above).

Acceptance notes

  • The door keeps its own copy. service-analytics' structured-json-dimension-door.ts composes the same two predicates in a private groupedClassOf. That is a second account of the door's order, inside the door itself. The two agree today: same predicates, same order, same input type. They are held equal only by each side's sweep against STRUCTURED_JSON_TYPES / isMultiValueField, not by a shared call. service-analytics does not depend on @objectstack/lint (that direction is wrong), so one copy for both would have to live in @objectstack/spec/data. That is a different card's decision. Carrier: none.
  • Cloud's switch from dataset-derive.ts isDimensionField to this export is cloud's card, per the triage direction.

Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 222293f60a7fff2779269b20bb4e5ce28a34c935 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e85b07e919ed8077531faade3ac75600fcd9d290 — the merge of head dfe60e3486ef23d1c3da427d1964eb9adfd80158 into base 222293f60a7fff2779269b20bb4e5ce28a34c935, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e85b07e919ed8077531faade3ac75600fcd9d290 && git checkout e85b07e919ed8077531faade3ac75600fcd9d290
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 222293f60a7fff2779269b20bb4e5ce28a34c935 dfe60e3486ef23d1c3da427d1964eb9adfd80158 && git checkout -B drift-repro 222293f60a7fff2779269b20bb4e5ce28a34c935 && git merge --no-ff dfe60e3486ef23d1c3da427d1964eb9adfd80158

node scripts/docs-audit/affected-docs.mjs --json 222293f60a7fff2779269b20bb4e5ce28a34c935

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dfe60e3486ef23d1c3da427d1964eb9adfd80158
Local-runs: none

Inputs read: card #23235 (body; comments 6144128621 triage, 6171861649 claim, 6173430039 os-dev-report), PR #23520 (body, 4-file list, net diff against main, +141/-3), and the check-runs on the head. Nothing built, checked out or run.

① Derived judgments

Public-surface changes the diff implies (Clause-②: yes (widening)), item by item:

  1. New value export groupKeyClassOf(shape: ValueShapeFieldDef): GroupKeyClass | null from the @objectstack/lint barrel (packages/lint/src/index.ts, re-exported from validate-dataset-measure-aggregates.ts). RIGHT. It is the module's own function made export — no wrapper, no second copy — and the barrel re-exports that same binding; the new pin is the function the rule calls, re-exported from the package barrel holds toBe identity between ./index.js and the module. The body is unchanged on main: STRUCTURED_JSON_TYPES.has(shape.type) then isMultiValueField(shape), in the door's order. This is exactly the triage direction (6144128621: "export the dimension leg's groupKeyClassOf ... as the one place the analytics door's two predicates are applied in the door's order; the lint rule keeps calling it").
  2. New type export GroupKeyClass = 'structured-json' | 'multi-value'. RIGHT. It names the union the function already returned inline; jsonStoredDimensionFinding's cls parameter is retyped to it with no change in the set of values. Byte-equal to the union the door's own JsonStoredClass carries in packages/services/service-analytics/src/structured-json-dimension-door.ts.
  3. Reachable through the export's signature. ValueShapeFieldDef is already public (@objectstack/spec/data, field-value.zod.ts:361, re-exported through export * from './field-value.zod'), as are STRUCTURED_JSON_TYPES and isMultiValueField. Nothing private becomes reachable. The input type is not widened: the door's own private groupedClassOf takes the same ValueShapeFieldDef, and a spec Field is structurally assignable (type: FieldType into string, optional multiple: boolean, options[].value: string into string | number), which the new compile-time pin takes a declared Field verbatim fixes with three uncast Field literals in the type-checked test layer. RIGHT.
  4. Accept set of dimension-json-stored-field-refused: unchanged. RIGHT, and the brief's required check holds: the diff touches neither leg's call site (dataset datasets[i].dimensions[k].field, cube analyticsCubes[i].dimensions.NAME.sql), nor the finding's where/path/message text; no existing assertion in the rule's suite is edited (the test diff is additive, +99/-0). The new pins hold the export equal to the rule's own verdict on the rule's own fixtures with non-vacuous floors: exactly 2 × STRUCTURED_JSON_TYPES.size structured-json answers and 2 × MULTI_OPTION_TYPES.size + MULTI_CAPABLE_TYPES.size multi-value answers over every FieldType × multiple, more than 50 served, and all three answers over the six typed fx_ledger columns of cubeStack (text, json, tags, select multiple, select, lookup). The oracle (classTheRuleNamed) reads the finding's class words and the column name and answers 'unreadable' on ambiguity, so it cannot agree by accident. RIGHT. The dev's ablation (each call site replaced by a type-only check: 4/65 and 3/65 red, new pins among the failures) is consistent with this reading; it is the dev's run, not re-run here.
  5. MEASURE_AGGREGATE_FIELD_TYPE_REFUSED and the measure leg: untouched. RIGHT.
  6. Triage pin satisfied: "the exported classifier and the rule agree on the rule's own fixture set" — yes, on both the dataset dimensionStack fixture and the cube cubeStack fixture, imported through the consumer's door (./index.js).
  7. No API-surface baseline to update: @objectstack/lint has no api-surface/ or export-origins/ directory on main (the sibling feat(spec): export the controlled_by_parent exactly-one-master predicate; lint reads it #23496 updated packages/spec/api-surface/data.json because its export was on the spec side; this one is lint-side). RIGHT that no baseline file appears in the diff.

Nothing in the diff is judged wrong.

② Semver level

Changeset .changeset/23235-export-group-key-classifier.md: "@objectstack/lint": minor, body carries Clause-②: yes, names both new exports, the input type, the null meaning, the "reads no object graph / pass the leaf" caveat, and states "No export is removed, and no accepted or refused shape changes." The diff publishes two additive exports from a released package and nothing else, so minor is the floor AGENTS.md sets for Clause-②: yes and is the right level (not patch: a new export; not major: nothing removed, renamed or narrowed). @objectstack/lint is in the fixed group, so the bump propagates by the group's rule; Check Changeset is green on the head. skip-changeset does not apply and is not claimed. The Clause-②: line reads yes on the PR body and in the changeset, consistent with the triage lane call Clause-②: yes (widening). Matches.

③ Boundary flags

  • open_questions: [] in the os-dev-report. None to answer.
  • Dev flag, out_of_scope_findings (carrier: none): service-analytics keeps its own private groupedClassOf composing the same two predicates in the same order over the same ValueShapeFieldDef; one shared copy would have to live in @objectstack/spec/data because service-analytics does not depend on @objectstack/lint. Confirmed on main (structured-json-dimension-door.ts:240-243, byte-equal body, JsonStoredClass = the same union as GroupKeyClass). Correctly left outside this card: the card asks for the lint export only, and a spec-side shared classifier is a separate decision. Not escalated; noted for whoever files it.
  • Dev flag, NOT MEASURED locally: check:dual-build-cjs-loads and check:lean-entry-closure (exit 3, whole-workspace dist prerequisite), declared to CI. The CI job family that carries them (Lint · CI, release & engine gates) is in progress at brief time, named below; the dev's direct probe that dist/index.cjs and the ESM entry load and answer is the dev's run, not a gate verdict.
  • Dev flag, check:where-matcher red on the first draft of the pin helper, rewritten (destructured first parameter, Map instead of a same-file object) and green on the head. The helper in the diff reads as described; the gate's CI leg is in the in-progress lint family.
  • Docs-drift check comment on the PR (6173421123): packages/lint/src/index.ts yielded no anchor, so hand-written pages naming it were not covered by that run. The lint README lists no exports (dev's check:published-readme-exports green); the barrel comment and JSDoc are the documentation an export of this kind carries. No escalation.
  • Cloud's switch from dataset-derive.ts isDimensionField to this export is cloud's card, per triage. Not this PR's boundary.

Check-runs on dfe60e3486ef23d1c3da427d1964eb9adfd80158 at brief time (read once): success — Auto Label, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, The card this PR closes must claim this branch, filter. Skipped (path-filtered) — Build Docs, Console Pin Gate, Dogfood Regression Gate (shards), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance. In progress — Build Core, Test Core (1/6 … 6/6), Lint · CI, release & engine gates, Lint · ESLint, Lint · docs & code guards, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace; queued — Lint · repo & tooling gates. No check-run on the head has failed. The verdict below is the contract verdict on the diff; landing still waits on the in-progress families finishing green.

Precedent: PR #23496 (#23272), merged 5547ad451, the spec-side sibling of this lane — same shape (export a module-private predicate, lint keeps calling it, pin agreement on fixtures, minor + Clause-②: yes).

Implemented-by: claude/issue-23235-export-group-key-classifier
Reviewed-by: session_01D6F9axfoPzR7t8G1J7Ytzz

VERDICT: PASS

Adopted by domain:spec seat 3 (#18883), baozhoutao, session session_01D6F9axfoPzR7t8G1J7Ytzz, at 2026-10-14T16:48Z: rendered by an isolated read-only reviewer at CONTRACT_REVIEW_TIER. ③: the private groupedClassOf in service-analytics stays in the acceptance notes, with no card (a spec-side shared classifier is a separate decision). Landing waits on the in-progress check families finishing green on this head.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 17:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 17:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 54af6c0 Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23235-export-group-key-classifier branch October 14, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@objectstack/lint: export the dataset dimension leg's group-key classifier, so a consumer does not recompose the door's predicates

2 participants