Skip to content

fix(lint)!: withdraw absolute-colspan-discouraged — its premise was measured false and its recommendation measured worse than the thing it warned about - #17671

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-17328-colspan-rule-reground
Sep 11, 2026
Merged

claude[bot] merged 2 commits into
mainfrom
claude/issue-17328-colspan-rule-reground

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Clause-②: no

Closes #17328

The decision: retire, not re-ground — and the evidence that decided it

Triage's ruling was "withdraw or re-ground the rule, and fix the hint", with the recommendation named as the harmful half. The choice between the two branches is settled by asking what a narrowed rule would still be warning about. Every candidate answer is empty:

Authored shape What #17328 measured Is there anything to warn about?
colSpan: 1 emits zero classes; byte-identical to the control at all three widths No — wholly inert, yet it warned
colSpan: 2 in a 2- or 3-column section renders exactly 2 of 2 / 2 of 3; 0px overflow at every width No — and it is the only spelling that expresses "two columns wide"; neither span: 'full' nor omitting it can
colSpan: 4 in a 3-column section clamps to @2xl:col-span-3; 0px overflow, the case that would overflow if the clamp did not work No
span: 'full' (the recommended alternative) compiles to the identical class as colSpan: 4 and measures byte-identically; at the modal width it is pixel-identical to authoring nothing It is the one that misrenders — see the last section

Overflow is 0px in every configuration at every width in the card's table. With no authored shape that misbehaves, there is nothing left to re-ground, and a narrowed rule could not be given a positive control — the brief's own test for keeping it. A rule that reports 0 and a rule correctly narrowed look identical on a clean tree, so the honest branch is withdrawal.

The corollary matters for anyone tempted to soften the message instead: the rule warned about colSpan: 4 and recommended span: 'full', and they are the same emitted class. There is no wording that makes that coherent.

Re-verification of the ruling's premise, on this branch's base (5ddd5d3)

Re-read first-hand rather than taken from the dispatch. All three hold, plus a fourth the card and triage did not name:

Premise Reading
fires on every authored colSpan validate-form-layout.ts:203if (colSpan != null) {, no further discrimination; colSpan: 1 included
the message asserts a rendering consequence :210-213…so a fixed span only aligns at one width
the hint recommends span: 'full' :214-215Prefer span: 'full' (whole row at any column count). The renderer clamps colSpan to the current column count.
the file contradicts itself :13 — "an over-wide colSpan is clamped", one paragraph above the claim the clamp falsifies
a third carrier, more authoritative than either :18-21 — the module docblock repeats the same false premise and the same harmful recommendation

Reverse-read sweep — which existing sentences does this change make false?

Swept tree-wide with git grep. Reported in full, including the zeros.

only aligns at one width (exact): 1 hit — validate-form-layout.ts:212, the retired message itself. Zero elsewhere in the tree.

lines up at the one width: 3 hits — validate-form-layout.ts:20 (the docblock carrier, rewritten here) and two CHANGELOG.md files. Changelogs are an immutable record of what a past release shipped and are not edited.

one width (broad, 14 hits): everything else is an unrelated sense — identifier column widths, SQL storage ceilings, string-family widths. One real hit: skills/objectstack-ui/rules/navigation.md:139. See "carriers left standing" below.

span: 'full' (13 hits): the two carriers in this file (both fixed); two .changeset/CHANGELOG history rows; content/docs/ui/views.mdx ×4; packages/spec/src/ui/view.zod.ts:2494; skills/objectstack-ui/rules/navigation.md:136; and three pure fixtures (examples/app-showcase/.../task.view.ts:368, packages/lint/src/showcase-shape.fixtures.ts:298, the test file) which assert nothing about width and are unaffected.

colSpan tree-wide: ~100 hits, almost all *.form.ts builtin form definitions authoring a colSpan — untouched and still valid, which is the point. The hits that say something about it are the ones tabled above.

FORM_COLSPAN_ABSOLUTE: after this change, zero code references — only tombstone comments in validate-form-layout.ts, index.ts, authoring-rules.ts and the re-judged tests.

Two zeros worth stating as readings rather than silences:

  • FORM_COLSPAN_ABSOLUTE in any .md / .mdx outside CHANGELOG.md: zero hits — no README or doc page advertises the removed export, so nothing else had to move with it.
  • Any importer of the constant outside packages/lint/src: zero hits — the only consumers were this package's own index and tests.

Carriers left standing, deliberately — filed as #17670

The sweep found the same two false sentences in five more places outside this PR's fence: the .describe() on FormField.colSpan and on FormField.span in packages/spec/src/ui/view.zod.ts (which generate content/docs/references/ui/view.mdx), two hand-written docs pages, and skills/objectstack-ui/rules/navigation.md — a governed surface, and the prescriptive one an AI author reads first. Each is outside this card's land point and pulls in a different gate family (spec generated artifacts) or a human-merge surface. Filed unassigned as #17670 rather than fixed here or left unsaid.

content/docs/releases/v12.mdx:73-76 and :82 also name the rule; release notes are release-owned history and are correct as history. Not edited, not filed.

What changed

  • packages/lint/src/validate-form-layout.ts — the (b) emission block and the FORM_COLSPAN_ABSOLUTE constant are gone. The module docblock's colSpan bullet is replaced by a tombstone carrying the measurement, the reason the claim was false, the reason the recommendation was worse, and a standing instruction that reintroducing a colSpan warning takes a browser measurement plus a positive control — not source reading.
  • packages/lint/src/index.tsFORM_COLSPAN_ABSOLUTE is off the public surface, with the reason recorded at the export site: a rule id on the public surface reads as a check the platform performs (Prime Directive chore: version packages #10), and the compiler is the precise channel for telling a consumer that suppressed it.
  • packages/lint/src/authoring-rules.ts — the registry comment described validateFormLayout as covering "an absolute colSpan under a per-surface derived column count". That becomes false with this change, so it is corrected in place (it now names the section.group rule it always ran and never mentioned).
  • Tests — seven pins re-judged in place. None deleted.

FormField.colSpan in packages/spec is untouched and still parses. Only the diagnostic is gone.

The pins: re-judged in place, never deleted

Each carries a comment saying what it used to assert, what changed and why. Each re-judged pin that would otherwise assert an empty result is paired with a live finding on the same fixture, because expect([]).toEqual([]) would keep passing if the walk stopped reaching the site at all.

Pin Was Is now
validate-form-layout.test.ts "discourages absolute colSpan and steers to span" the wording pin: hint contains span: 'full' asserts the withdrawal, paired with a form-field-unknown on the identical sections[0].fields[1]
"reports both rules for the same field independently" both rules on one entry a colSpan on the entry does not suppress, duplicate or relocate the reference finding it earns
"skips reference-checking when the bound object cannot be resolved" [FORM_COLSPAN_ABSOLUTE] — the colSpan rule was the only thing that could speak on an unresolvable binding [], paired with the identical sections under a resolvable binding reporting both dangling names
"reads the legacy groups bucket too" 2 rows 1 row; the colSpan: 3 stays on the fixture — it is what proves the entry-object shape is read here
"reports every planted defect on that stack" (#6251) 3 rows 2 rows, one per container rung — the traversal assertion is undiminished
"the colSpan rule is unconditional on the object binding" (#16168) the whole subject was the colSpan rule the other half of the same sentence: a well-formed field with a colSpan on a list-bound container is silent, a dangling one is still reported — #16168's fix still holding, now with nothing riding alongside it
authoring-rule-wiring.test.ts "the form-layout rule really runs, and really finds something" ['absolute-colspan-discouraged', 'form-field-unknown'] ['form-field-unknown']; the colSpan: 2 stays on the fixture and the test's whole point — the registry entry's run returns a real finding on all three commands — is carried intact

Verification

  • pnpm --filter '@objectstack/lint^...' build — exit 0.
  • pnpm --filter @objectstack/lint test101 files / 3724 passed, 5 skipped, exit 0.
  • pnpm --filter @objectstack/lint typecheck — exit 0 (tsc --noEmit + check:test-typecheck).
  • pnpm lint (eslint . --no-inline-config, the whole repo, not narrowed) — exit 0.
  • Gate families derived with scripts/pm/dispatch-gates.mjs against the real change set and reconciled with --ran: 59 derived, 56 run green, 0 unrun, 3 NOT MEASURED at exit 3 / PREREQUISITE NOT MET (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt — each needs the whole-repo build closure; declared to CI). check:docs-transcript-drift first exited 3 for the same reason and passes after pnpm --filter '@objectstack/lint...' build.
  • Changeset gates green on the committed changeset: check-adr-0087-registration (accepted the no-migration-prescription disposition), check-changeset-no-major, check-empty-changeset, check-changeset-fixed.
  • All readings above taken at 70e35f4beb.

Reverse verification (one-time ablation, not left on the tree)

Prediction before running: red. Re-emitting the withdrawn finding was injected into the committed source; the mutation was proved on disk by marker count and by git hash-object differing from the HEAD blob (8ddaf813… against 0c354a4d…), and the two test files were run against the mutated source.

Result: 7 failed / 48 passed — every one of the seven re-judged pins goes red, and nothing else does. So the pins assert the withdrawal, not nothing.

Restore leg: git checkout HEAD -- packages/lint/src/validate-form-layout.ts under an EXIT INT TERM trap using absolute paths, verified by blob hash equal to HEAD (0c354a4d…) and an empty git diff HEAD — not by an exit code.

AGENTS.md Post-Task Checklist item 4 — does this removal break the pinned sibling checkout?

This PR removes a published export (FORM_COLSPAN_ABSOLUTE from @objectstack/lint), which is exactly the class item 4 names. The Console Pin Gate job does not answer it on this PR, and that is not a pass:

  • The job is gated if: ${{ !cancelled() && needs.filter.outputs.console != 'false' }} (.github/workflows/ci.yml, the console-pin job). The console paths-filter lists exactly seven paths — .objectui-sha, scripts/build-console.sh, scripts/check-console-sha.mjs, scripts/check-console-injection.mjs, scripts/console-spec-probes.mjs, scripts/assert-console-spec-injection.mjs, .github/workflows/ci.yml. No path under packages/ is among them, so a packages/lint diff never triggers it, and a skipped check reads as success in branch protection. On this question CI is NOT MEASURED.
  • ⛔ The filter was deliberately not touched. Manufacturing a trigger is not a measurement.

So it was measured directly instead, on the pinned tree.

The tree that was read

.objectui-sha at this PR's head is 53ded82bf7a494f54e344e19099dbf00854b8694. Fetched exactly as scripts/build-console.sh mode 3 does (git init + fetch --depth 1 origin at that SHA from https://github.com/objectstack-ai/objectui.git), checked out detached. git rev-parse HEAD equals the pin; 6409 tracked files.

Reading 1 — what the pinned sibling imports, zeros included

Identical probe (git grep -F on the symbol), same tree, absent symbols beside their positive controls:

Symbol Hit lines Files
FORM_COLSPAN_ABSOLUTE 0 0
absolute-colspan-discouraged 0 0
validateFormLayout 0 0
FORM_FIELD_UNKNOWN 0 0
FORM_SECTION_GROUP_UNKNOWN 0 0
PAGE_SOURCE_CLASSNAME — positive control 3 1
validatePageSourceStyling — positive control 4 2
validateCapabilityReferences — positive control 6 4
validateSecurityPosture — positive control 5 2

The controls are not arbitrary: the pinned sibling really does depend on @objectstack/lintapps/console/package.json:94 and packages/app-shell/package.json:85 both declare "@objectstack/lint": "^17.0.0" — so the zeros above are a reading taken on a tree where this exact package is demonstrably present and imported, not on an empty clone.

Every site that reaches into the package, exhaustively — five, and only four symbols between them:

  • apps/console/src/__tests__/sdui-preview-page-source-styling.test.ts:28import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from '@objectstack/lint';
  • packages/app-shell/src/preview/capabilityLint.ts:50await import('@objectstack/lint'), feature-detecting validateCapabilityReferences and nothing else
  • packages/app-shell/src/preview/securityPostureLint.ts:94await import('@objectstack/lint'), feature-detecting validateSecurityPosture and nothing else
  • two comment references in apps/console/vite.config.ts

Namespace / wildcard imports of @objectstack/lint: 0 hits — so there is no import * as lint whose dynamic member read a grep for the symbol could miss. The two dynamic sites read two named functions, by name, and both are still exported here (checked against the export list in packages/lint/src/index.ts, not against a comment that merely mentions the name).

Reading 2 — the gate could not see this change even if the symbol were there

scripts/build-console.sh injects exactly two framework packages into the pinned build. Those two export lines are the only ones in the whole script:

  • :171 export OBJECTSTACK_CLIENT_DIST="$CLIENT_PKG"
  • :215 export OBJECTSTACK_SPEC_DIST="$SPEC_PKG"

@objectstack/lint is not injected. The pinned tree is installed with pnpm install --frozen-lockfile --prefer-offline --prod=false against registry.npmjs.org, and objectui's own lockfile resolves '@objectstack/lint@17.2.0' — a published, immutable version. No change to this repo's packages/lint working tree can move what the Console Pin Gate builds against, at PR time or at merge time.

Verdict

Item 4 is satisfied without a sibling fix and without a pin bump: the pinned sibling does not import the removed symbol, and the gate resolves the package from npm rather than from this tree. This ships alone. .objectui-sha is untouched — that pin belongs to #17429, not to this PR.

Declared NOT MEASURED: the Console Pin Gate build itself was not executed locally — it installs objectui's full dependency tree and CI allows it 45 minutes, beyond this session's foreground budget. What is reported above is the two readings that decide item 4's question, not a substitute build. If a reviewer wants the build run anyway, its trigger is a .objectui-sha touch, which is #17429's to make.

Changeset — measured, not defaulted

@objectstack/lint ships files: ["dist", "README.md", "CHANGELOG.md"], and this change moves what dist contains: one export is gone and one class of finding is no longer emitted. A changeset is genuinely owed; skip-changeset is not applicable and no such label is applied. Graded minor with a **BREAKING** banner, per this repo's launch-window convention (major is refused by check-changeset-no-major; breaking-ness is carried by the banner plus the ADR-0087 disposition, not by the level). Disposition: not-required (no-migration-prescription) — nothing an author writes moves, and the one surface that does move has no metadata representation for objectstack migrate meta to reach.

Clause-②

Clause-②: no, as the claiming seat judged on the card. Nothing here widens a contract: a lint warning is withdrawn, packages/spec/src/** is untouched, and the rule's severity was warning throughout. One thing the seat asked to be told: this implementation removes a public export (FORM_COLSPAN_ABSOLUTE from @objectstack/lint). It adds no new export and no new rule constant — the movement is strictly subtractive — but the line is the seat's to re-judge, not mine.

New evidence for the PM: the span: 'full' half

Not filed here, and no objectui change attempted — this session's repository access is objectstack-ai/objectstack only. Two readings from this branch worth having when that half is triaged:

  1. The misrender is recommended by the spec itself, not only by the retired lint hint: packages/spec/src/ui/view.zod.ts:2494 describes span: 'full' as "whole row at any column count" and tells authors to "prefer this over the absolute colSpan". That .describe() is the source the public reference docs are generated from, so the recommendation outlives this PR by a wide margin (carried in [finding] The colSpan / span: 'full' claim #17328 measured false is still on the tree in five more carriers — including the spec .describe() that generates the public reference docs #17670).
  2. The shape of the defect is visible in the emitted class without reading objectui's source: span: 'full' and colSpan: 4 both compile to @2xl:col-span-3 — a hard-coded span of the declared maximum gated at the top breakpoint only. So span: 'full' is not "relative" in the emitted CSS at all; it is an absolute span of 3 with a single container-query gate, which is exactly why it collapses to one column of two at the modal width. Whatever the fix is, "full" needs a class at each breakpoint, not one at the last.

验收备注

  • packages/cli/test/build-warning-truncation-notice.test.ts:65,131 uses the string absolute-colspan-discouraged as synthetic fixture data for a warning-truncation test; it constructs the findings by hand and asserts nothing about this rule. Nothing there becomes false, so it is left alone rather than pulling packages/cli into this diff. Noted, not filed.
  • The (a) / (c) comment labels inside validateFormLayout now have a gap where (b) was. The labels are referenced from test comments, so they are left stable rather than renumbered. Noted, not filed.
  • packages/lint's own module docblock said "the two that matter" while three rule ids existed (form-section-group-unknown was added later and documented only on its constant). Corrected as a side effect of the rewrite, since the sentence had to be rewritten anyway.

🤖 Generated with Claude Code

https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU

…asured false and its recommendation measured worse (#17328)

The rule fired on every authored `colSpan`, `colSpan: 1` included, and asserted
that a fixed span "only aligns at one width". Browser measurement at all three
surface widths the message names shows the renderer clamps the span to the
section's column count: overflow is 0px in every configuration, including
`colSpan: 4` in a 3-column section. The hint's recommended alternative,
`span: 'full'`, compiles to the same class as `colSpan: 4` and renders
pixel-identical to authoring nothing at the modal width.

With no authored colSpan shape left that misbehaves there was nothing to
re-ground, so the rule is withdrawn rather than narrowed. `colSpan` itself is
untouched and still parses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 3 documentable anchor(s). ⚠️ 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.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via validateFormLayout (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

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 7880c184427baec39e5cdd1166f497bf9870a652packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2c3789e443e5768cf0e06d4bfc6f6e2f162f2136 — the merge of head 70e35f4beb63f073a950cf201d2222af1f2819cc into base 7880c184427baec39e5cdd1166f497bf9870a652, 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 2c3789e443e5768cf0e06d4bfc6f6e2f162f2136 && git checkout 2c3789e443e5768cf0e06d4bfc6f6e2f162f2136
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7880c184427baec39e5cdd1166f497bf9870a652 70e35f4beb63f073a950cf201d2222af1f2819cc && git checkout -B drift-repro 7880c184427baec39e5cdd1166f497bf9870a652 && git merge --no-ff 70e35f4beb63f073a950cf201d2222af1f2819cc

node scripts/docs-audit/affected-docs.mjs --json 7880c184427baec39e5cdd1166f497bf9870a652

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7880c184427baec39e5cdd1166f497bf9870a652 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

2 participants