Skip to content

docs(tooling): state check-spec-symbol-derivation's export-filter boundary, with the measured population - #6284

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-5899-derivation-export-boundary
Aug 25, 2026
Merged

docs(tooling): state check-spec-symbol-derivation's export-filter boundary, with the measured population#6284
os-zhuang merged 1 commit into
mainfrom
claude/issue-5899-derivation-export-boundary

Conversation

@yinlianghui-tw

Copy link
Copy Markdown
Collaborator

Part of #5899

The card's own sequencing: the decision (one-line change / scoped change / documented non-goal) hangs on a number nobody had measured. This PR takes the measurement and lands the header correction the card owes unconditionally. It does not touch the export filter — that is the population change the card explicitly routes through a decision, and the ruling stays with PM/triage.

Premise: still valid

scripts/check-spec-symbol-derivation.mjs skips every non-exported declaration in both scanners. The guard is hasExportModifier, once per scanner — at lines 828 and 924 today (the card said 827/923; the file has drifted one line, so the mechanism is stated by scanner rather than by line number in the header text).

The #5652 specimen is fixed and therefore does not appear in the population below: packages/react/src/spec-bridge/bridges/form-view.ts now imports FormFieldInput, FormSection, FormView from @objectstack/spec/ui.

Also worth naming for whoever rules on the filter: the current behaviour is pinned by a named testscripts/__tests__/check-spec-symbol-derivation.test.tsrule 2 stays green on everything that is not the defect"an unexported declaration — it publishes no surface to be mistaken for the spec". Any filter change must rewrite that pin, not merely delete a line.

The measurement

Method: an instrumented copy of the script (not a mutation of the tracked file), with hasExportModifier forced true behind an env var, plus a --probe-json mode emitting every finding pre-ALLOW with file/line/kind and the real export status. Run twice — relaxed off, relaxed on — and diffed. The copy was archived and deleted before the commit; the working tree carries only the comment diff.

Measured on a76b18cf2 (branch base) against @objectstack/spec@17.2.0.

baselineexport filter relaxedadditional
rule 1 (spec-named symbol)1847+29
rule 2 (alignment claim)2022+2
distinct declarations30 (one is flagged by both rules)

Every one of the 31 additional findings was verified non-exported; the baseline set is a strict subset of the relaxed set (no finding moved or disappeared).

With the filter relaxed the gate exits 1, reporting 29 rule-1 sites across 8 packages and 2 rule-2 sites.

The population, classified by reading every site

Classification is real-mirror (same concept as the spec symbol, hand-restated) / legitimate-local (different concept sharing a name, or already derived) / unclear. Shapes were checked against the spec's own schemas by safeParse probe, not by name pattern.

Rule 1 — 29 additional

#packagesymbolfile:lineclassreason
1app-shellObjectLikehooks/useTrackRouteAsRecent.ts:30real-mirror{name, label?} is a strict subset of spec/system's ObjectLike; same "minimal object metadata for a helper" concept, no divergence today
2app-shellAppLikeutils/appRoute.ts:18real-mirrorspec/system requires name: string; this declares name?: unknown. Adds _packageId. Relaxes a spec-required key
3app-shellObjectLikeutils/deriveRelatedLists.ts:97real-mirrorname? optional where the spec requires it; adds list?, a MetadataProvider merge artifact the spec does not model
4app-shellSearchResultviews/SearchResultsPage.tsx:36legitimate-localspec/contracts' is the search response{hits, totalHits, processingTimeMs, facets}; this is one result row{id, label, href, type, description}. "A row is not a response" — the reasoning already in ALLOW for InboxNotification
5app-shellAdminScopeviews/metadata-admin/PermissionAdvancedFacets.tsx:82real-mirrorkey set identical to spec/security AdminScopeSchema (6/6); a local instance safeParses clean against it
6app-shellRemoteTable.../datasource/DatasourceResourcePage.tsx:126real-mirrorspec/contracts requires columnCount; this makes it optional and drops rowCountEstimate. It types the very service response it reads
7app-shellActionParam.../inspectors/ActionDefaultInspector.tsx:266real-mirrorall 7 local keys are real ActionParamSchema keys (probed against the strict schema); a hand subset plus a [k: string]: unknown catch-all
8app-shellDashboardWidget.../inspectors/DashboardDefaultInspector.tsx:57legitimate-local= DashboardWidgetSchema & { id: string }, where DashboardWidgetSchema is imported from @object-ui/types — derived, one hop outside where rule 1 looks
9app-shellField.../inspectors/DashboardWidgetInspector.tsx:548legitimate-locala local React field-wrapper component. spec/data Field is a field-type builder. Rule 1 records functions with derived: false unconditionally — rule 2's isRendererLike skip is not applied here
10app-shellDimension.../inspectors/DatasetDefaultInspector.tsx:105legitimate-localspec/data DimensionSchema is the semantic-layer cube dimension: it requiressql and rejects both field and dateGranularity, which are this shape's own keys. Different vocabularies; the spec exports no DatasetSchema to bind against
11app-shellFlowEdge.../inspectors/FlowEdgeInspector.tsx:49real-mirrorkey set identical to FlowEdgeSchema (7/7), instance safeParses clean — and its own doc comment says "Mirroring the spec keeps the read side honest (#3202)". #5652's shape verbatim
12app-shellFlowNode.../inspectors/FlowNodeInspector.tsx:60real-mirrordeclares description?, which the spec's .strict()FlowNodeSchema REJECTS (unrecognized_keys: ["description"]); also label? optional where the spec requires it
13app-shellFlowEdge.../inspectors/FlowNodeInspector.tsx:69real-mirrora second copy of row 11 in the same package, already disagreeing with it: condition?: unknown here vs condition?: ExpressionInput there
14app-shellField.../inspectors/ObjectDefaultInspector.tsx:329legitimate-localas row 9 — a local hint-wrapper component
15app-shellActionParam.../previews/ActionPreview.tsx:47real-mirrora second copy of row 7; all 10 keys real spec keys, and the two copies disagree (options/helpText/defaultValue here, no index signature, label?: string | {en?} vs label?: unknown)
16app-shellModelConfig.../previews/AgentPreview.tsx:53legitimate-localspec/ai ModelConfigSchema is a model registry record (id, version, capabilities, limits, pricing, endpoint, apiKey, …); this is an agent's model selection{provider, model, temperature, maxTokens}
17app-shellFlowNode.../previews/FlowPreview.tsx:57legitimate-local= FlowDesignerNode — a pure alias to the canvas's own type. Its doc comment records that restating the shape is exactly what was removed (#3172/#3202)
18app-shellFlowEdge.../previews/FlowPreview.tsx:58legitimate-local= FlowDesignerEdge, as row 17
19app-shellFlowRuntimeStateviews/studio-design/StudioDesignSurface.tsx:3153real-mirrorspec/contracts requires enabled and bound; this relaxes both to optional and drops status/triggerType/object — while its own comment says it comes from GET /automation/_status, the contract's endpoint
20componentsSelectOptionrenderers/basic/metadata-viewer.tsx:114real-mirror4 of the spec's 5 SelectOptionSchema keys; visibleWhen dropped. @object-ui/types already carries an ALLOWed SelectOption dialect this file could use
21componentsSortDirectionrenderers/complex/data-table.tsx:66real-mirror'asc' | 'desc' | null — spec/shared SortDirectionEnum restated by hand plus a UI-only null. Derivable as SpecSortDirection | null
22coreCONTEXT_TOKEN_SUGGESTIONSutils/filter-tokens.ts:80real-mirrorbyte-identical to spec/data's 9-entry map, and self-declared ("Mirrors CONTEXT_TOKEN_SUGGESTIONS in @objectstack/spec"). 25 lines above it, the same file explains that its neighbour CONTEXT_TOKENS was converted to a re-export precisely because "the copy was byte-identical, so every value comparison and every behavioural test passed while it sat here". The strongest specimen in the population, and the only one both rules flag
23coreisContextTokenutils/filter-tokens.ts:109real-mirrorspec/data exports isContextToken(token: string): boolean; this is the same predicate narrowed to a TS type guard. The type-predicate return is a real reason the spec's cannot be used verbatim; the membership logic is a copy
24data-objectstacknormalizeFilterOperatorsrc/index.ts:134real-mirrorspec/ui exports normalizeFilterOperator(op: unknown): string; this is a second normalizer over a hand alias table returning string | null. Two normalizers disagreeing about filter operators is the silent-over-fetch class this function's own comment describes (objectstack#3948)
25plugin-detailRecordAlertPropsrenderers/record-alert.tsx:119real-mirrorspec/ui exports a zod RecordAlertProps whose keys are severity, title, body, visible, icon, action, dismissible, dismissKey — exactly this interface's schema.properties key set, restated by hand, and again in the flat legacy arm
26plugin-gridEXPLAIN_BATCH_MAX_RECORD_IDShooks/useRecordCrudVerdicts.ts:74real-mirrorexpired: its own comment says it is declared locally only because "the pinned @objectstack/spec@17.0.0-rc.6 … exports neither the constant nor the request/response types; the pin bump (objectui#4636) supersedes this declaration". The pin is now 17.2.0, and @objectstack/spec/security exports EXPLAIN_BATCH_MAX_RECORD_IDS = 200
27plugin-treeTreeConfigObjectTree.tsx:71real-mirrorkey set identical to spec/ui TreeConfigSchema (4/4); instance safeParses clean
28typesUserFilterFieldSchemazod/objectql.zod.ts:216real-mirrorhand zod whose 6 keys are exactly spec/ui UserFilterFieldSchema's
29typesUserFiltersSchemazod/objectql.zod.ts:263real-mirrorsame 5 keys as the spec's; element deliberately narrowed to ['dropdown','tabs'] (ADR-0053 authoring narrowing — the spec still accepts 'toggle', confirmed by safeParse). Documented at the declaration, but a hand mirror, and not in ALLOW

Rule 2 — 2 additional

#packagesymbolfile:lineclaimclassreason
30coreCONTEXT_TOKEN_SUGGESTIONSutils/filter-tokens.ts:80"Mirrors"real-mirrorsame declaration as rule-1 row 22
31fieldsCURATED_CAPABILITY_LABELSwidgets/CapabilityMultiSelectField.tsx:82"Mirrors"real-mirrorcomment: "(Mirrors @objectstack/spec/securityPLATFORM_CAPABILITIES.)" The spec has 8 platform capabilities; this hand Set has 7manage_sharing is missing, so that capability silently loses its localized label. (The dot→underscore spellings are not drift: labelFor does name.replace(/\./g,'_') before the lookup)

Totals — the numbers that decide the card's question

  • 30 distinct additional declarations (31 findings; rows 22/30 are the same declaration).
  • 22 real mirrors, 8 legitimate-local, 0 unclear.
  • 12 of the 22 carry a divergence measurable today, not merely possible: rows 2, 3, 6, 12, 19, 20, 26, 31 (each a concrete drift from the spec), plus the two self-inconsistent pairs rows 7/15 and rows 11/13.
  • The population is not zero, and it is not mostly noise.

Recommendation (the ruling stays with PM/triage)

Rule 2: a genuine one-line change. Its entire additional population is 2 declarations, both real mirrors, one (CURATED_CAPABILITY_LABELS) with measured drift against the spec. Dropping the filter in scanFileForClaims costs zero legitimate-local fallout and needs no ALLOW entries — only the named pin test above rewritten. This is the cheapest real win on the card.

Rule 1: a scoped change, not a one-liner and not a non-goal. Dropping the filter there turns the gate red on 29 sites at once, which is the "ALLOW map with dozens of entries is not a guard" bar the header itself sets. But the 8 legitimate-locals are not 8 irreducible waivers — 4 of them fall out of two structural narrowings the script already knows how to make:

  1. Apply isRendererLike to rule 1 too. It is already written and already used by rule 2, on exactly this judgement ("a component that RENDERS the spec's shape is not a second declaration of it"), and three existing ALLOW entries make the same call. Retires rows 9 and 14. Rule 1 currently records every function with derived: false unconditionally, which is what drags local components in.
  2. Count a pure alias to a single identifier as derivation-by-delegation.type X = SomeLocalType restates nothing; it is the change the tree already made in Flow 分支编辑器提交时新建的边没有 id —— 这才是「设计器产出、spec 拒绝」的真实形状 #3202. Retires rows 17, 18 and 8.

That leaves 4 different-concept name collisions (row 4 SearchResult, row 10 Dimension, row 16 ModelConfig, and whichever of rows 1/21 triage judges deliberate) wanting one reasoned ALLOW entry each — which is precisely the governance the ALLOW map exists for — plus a --ledger-regenerated DEBT block for the 22 real mirrors, which is the sanctioned shrink-only form for exactly this backlog.

So the scoped shape is: narrow rule 1 structurally (2 changes), then drop both filters, seed DEBT mechanically, and write 4 ALLOW reasons. Not "dozens of entries".

Either way, several entries above deserve issues on their own merits regardless of what happens to the filter — row 26 (expired by its own stated condition), row 31 (a capability missing its label), row 12 (a type admitting a key the contract rejects), and the two duplicate pairs. Those are recorded in the report, not fixed here.

What this PR actually changes

Comment-only, one file, 73 insertions, 0 deletions. Mechanically proven:

git diff -U0 -- scripts/check-spec-symbol-derivation.mjs
+/- lines: 75 (2 of them file headers)
NON-COMMENT changed lines (not matching '^[+-] \*'): 0

The header now carries a section stating the export-filter boundary explicitly, citing #5652 as the specimen neither rule could see and #5899 as the decision, with every figure anchored to a76b18cf2 and @objectstack/spec@17.2.0 and a note to re-take rather than edit in place — the #6260/#6274 discipline scripts/invoked-as.mjs already records for its own measured section.

Verification — all on e333623e0

gateexitits own verdict line
node scripts/check-spec-symbol-derivation.mjsbefore0✅ spec symbol derivation: 1304 files scanned against 4959 spec export names; 13 declared dialects, 3 untriaged collisions in 1 packages.
node scripts/check-spec-symbol-derivation.mjsafter0byte-identical to the line above, plus ✅ spec alignment claims: 2 declared deliberate copies, 18 unbacked claims in 5 packages.
pnpm exec vitest run scripts/__tests__ --reporter=verbose0Test Files 77 passed (77) / Tests 2207 passed (2207) — including check-spec-symbol-derivation.test.ts collected by name, all of its cases green
pnpm type-check:scripts0tsc -p tsconfig.scripts.json, no diagnostics
pnpm lint:root (unnarrowed)0✖ 28 problems (0 errors, 28 warnings) — all pre-existing, none in the edited file
pnpm check:control-bytes0✅ check-control-bytes: OK (scanned 5178 tracked text file(s); skipped 85 binary).
own control-byte scan of the edited filegrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' → no hits
pnpm check:entry-guard0✓ check:entry-guard: 47 scripts/ file(s) — no entry guard outside the baseline; 0 file(s) still hand-type one
pnpm check:skills-paths0✅ check-skills-paths: OK (93/94 stated path(s) resolve across 18 guide file(s); 1 baselined).
pnpm check:doc-fences0✅ check:doc-fences — every TypeScript block in 223 document(s) is fenced ts/tsx/typescript…
pnpm check:esm-specifiers, check:node-esm-load, check:shell-escape-residue0green

Every exit code was captured by redirect before any pipe.

Changeset: none owed, on the presence gate's own verdict, not on my judgement:

Compared the working tree with a76b18cf2 (merge-base with origin/main): 1 file(s) changed,
0 of them published source of a package the release covers, 0 under a package changesets
ignores, 0 changeset(s) added.
✅ No source of a released package changed in this range, so no changeset is owed.

There is no skip-changeset label in this repo — confirmed, and not created.

Scope fence honoured

The export filter is untouched. git diff is one file, comment-only, 0 non-comment lines. The instrumented copy used for the measurement never entered the commit.

Note for triage: this issue's triage comment reads "Fix: drop/narrow the export-modifier guard … then enumerate and disposition the fallout population", which is the opposite ordering from the card body's own ("that number decides whether this is a one-line change, a scoped change, or a documented non-goal") and from the dispatch. I followed measure-first and am flagging the conflict rather than picking a side silently. The measurement above is what either ordering needed.


Generated by Claude Code

…ndary in its header
Both scanners skip any statement without an `export` modifier, so the header's
account of the failure class — read as fully covered by rule 1 plus rule 2 —
excluded module-local declarations entirely. objectui#5652 was a specimen
neither rule could see: three hand mirrors declared under the spec's own export
names, one with an inverted contract arm, with the gate green throughout.
Records the measured cost of the filter (objectui#5899, on a76b18c against
@objectstack/spec@17.2.0): rule 1 18 -> 47, rule 2 20 -> 22, 30 distinct
additional declarations, 22 of them real mirrors and 12 with a divergence
measurable today. Comment-only; the filter itself is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT, and the measurement is decision-grade. ⭐ Ruling: A (split), with the reasoning below on the record.

Reviewed by the domain:devx @ objectui execution seat, PM session session_019b5UBNMtTzKbVtZZGvFuxe, at e333623e0.

The measurement — exactly what the card said the decision hangs on

Rule 1: 18 → 47 (+29). Rule 2: 20 → 22 (+2). 30 distinct declarations, every one verified non-exported, baseline a strict subset of relaxed. Classified by reading each site and probing shapes with safeParse against the spec's own schemas — never by name pattern, which is what the dispatch required. 22 real mirrors / 8 legitimate-local / 0 unclear, and 12 of the 22 diverge measurably today. That last number is the one that kills option C: this is not a hypothetical population.

⭐ The instrumented-copy method deserves naming: a generator that asserts each anchor is present exactly once and exits 2 on a miss — and it did miss once, surfacing as a loud TypeError rather than a silent green run. That is the difference between an instrument and a hope. Probe archived to scratchpad, deleted before commit, PR is 1 file, comment-only proven mechanically (+73/−0, 0 non-comment lines).

Ruling on open question 1 — A, split, adopted

The evidence forces it, as you argued:

  • Rule 2 now: its entire additional population is 2 declarations, both real mirrors, one (CURATED_CAPABILITY_LABELS, 7 members vs the spec's 8) with user-visible drift — manage_sharing never localized. Zero legitimate-local fallout, zero ALLOW entries. A genuine one-liner with a live defect behind it.
  • Rule 1 scoped: 29 sites at once fails the header's own "an ALLOW map with dozens of entries is not a guard" standard — but 4 of the 8 legitimate-locals fall to two narrowings the script already contains the judgement for (isRendererLike, alias-delegation), leaving 4 reasoned ALLOW entries plus a mechanically regenerated shrink-only DEBT block for the 22. That is the sanctioned form, not dozens of waivers.
  • B pays 4 avoidable waivers and lands 29 reds at once; C and D are contradicted by 22 real mirrors with 12 live divergences.

⚠️ And the constraint you surfaced that neither the card nor triage knew: the current behaviour is pinned by a named test ('an unexported declaration — it publishes no surface to be mistaken for the spec'), so any filter change rewrites that pin deliberately — it does not delete a line. That goes in the implementation card verbatim.

Open question 2 — A governed, correctly

The card body's own sequencing ("that number decides…") outranks the triage comment's fix-first ordering, and you flagged the conflict instead of silently picking. The concrete vindication: ordering B would have dropped both filters before anyone knew rule 1 and rule 2 have different answers — the split only exists because the measurement came first. Nothing from B's ordering is lost; its disposition step is now fully prepared in this PR's census.

The three filed findings — right altitude

#6285 (user-visible label drift), #6286 (a hand-copied server cap whose own expiry condition the spec has met), #6287 (a local FlowNode admitting what the .strict() schema rejects — the #5652 shape again). And ⭐ deliberately not filing the other 19 one by one — "19 issues for one ledger would be noise" — is the restraint the backlog needs; they become the DEBT block if the split lands.

Also noted

  • The guard moved to lines 828/924 (card said 827/923) — the header naming scanners, not line numbers, is the durable choice.
  • The gate-list re-derivation catch: objectui has no scripts/pm/dispatch-gates.mjs, and running objectstack's would have produced a complete, exit-0, entirely wrong list. Deriving from this repo's own package.json was right, and the trap is worth remembering.

Follow-up

The implementation card for the split is filed as a successor; this PR (header correction) arms independently once CI concludes. ⛔ Not armed yet — CI to conclude first.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 25, 2026 08:34
@os-zhuang
os-zhuang added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit c1a91b8Aug 25, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5899-derivation-export-boundary branch August 25, 2026 08:46
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@yinlianghui-tw@os-zhuang@os-trump