Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/17328-colspan-rule-withdrawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
"@objectstack/lint": minor
---

fix(lint)!: `absolute-colspan-discouraged` is withdrawn — its premise was measured false in a browser, and the alternative it recommended measured worse than the thing it warned about (#17328)

<!-- adr-0087: not-required (no-migration-prescription) nothing an author writes moves: `FormField.colSpan` is untouched in `packages/spec`, still parses, and every stored form view keeps its shape and its rendering. The only surface that moves is one exported TypeScript rule-id constant in `@objectstack/lint` plus the advisory finding it named — neither has a metadata representation, so `objectstack migrate meta` has nothing to reach and the ledger serves nobody affected. -->

**BREAKING** — `@objectstack/lint` no longer exports `FORM_COLSPAN_ABSOLUTE`, and
`validateFormLayout` no longer emits the `absolute-colspan-discouraged` finding. A
TypeScript consumer that imported that constant (to suppress the rule, or to route it)
stops compiling on the import, and the compiler names the site — a more precise channel
than any release note. Authored metadata is untouched: `FormField.colSpan` is unchanged
and still valid.

The rule fired on **every** authored `colSpan`, `colSpan: 1` included, and asserted a
rendering consequence: the form's column count is derived per surface (mobile 1 / modal 2
/ page 3-4), so a fixed span "only aligns at one width". Measured in Chromium on a real
authored 3-column section at all three of the widths that sentence names (390 / 720 /
1700), that misalignment does not happen. The renderer emits one container-query-scoped
span class clamped to the section's declared column count, so the cell starts at a real
column boundary at every width and rendered overflow is 0px in every configuration —
including `colSpan: 4` in a 3-column section, the case that would overflow if the clamp
did not work. The clamp is precisely why the claim was false, and the rule's own file
already recorded the clamp a few lines above the claim.

The hint was the sharper defect. It steered authors to `span: 'full'`, which compiles to
the same class as `colSpan: 4` (`@2xl:col-span-3`) and measures byte-identically: the rule
warned about one spelling and recommended the other, and they are the same thing. At the
modal width `span: 'full'` renders pixel-identical to authoring nothing at all, so an
author who complied was left worse off than one who ignored it.

With no authored `colSpan` shape left that misbehaves there was nothing to re-ground, so
the rule is withdrawn rather than narrowed: `colSpan: 1` emits no class at all, a
`colSpan` within the column count renders exactly as authored, and one above it clamps.
Every test that pinned the rule's wording or its firing set was re-judged in place with
the reason recorded, never deleted, and each re-judged pin is paired with a live finding
on the same fixture so that a walk which stopped reaching the site could not pass as a
withdrawal.
12 changes: 8 additions & 4 deletions packages/lint/src/authoring-rule-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,10 +596,14 @@ describe('authoring-rule registry wiring (#4409)', () => {
},
{},
);
expect(findings.map((f) => f.rule).sort()).toEqual([
'absolute-colspan-discouraged',
'form-field-unknown',
]);
// [#17328] RE-JUDGED, not deleted: the fixture used to earn a second rule
// id, `absolute-colspan-discouraged`, which was withdrawn after browser
// measurement falsified the misalignment it asserted. The `colSpan: 2` is
// left on the fixture on purpose — an entry-object field is the shape this
// adapter has to survive — and `form-field-unknown` still carries the whole
// point of the test: the registry entry's `run` really returns the finding
// a broken stack earns, on all three commands.
expect(findings.map((f) => f.rule).sort()).toEqual(['form-field-unknown']);
});

it('every ledger entry is still an exported rule', () => {
Expand Down
10 changes: 6 additions & 4 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,10 +1026,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
run: (stack) => validateSemanticRoles(stack),
},
// #2578 / #4449 — a form section's field reference that resolves to nothing
// (silently not rendered) and an absolute `colSpan` under a per-surface
// derived column count. Advisory: the renderer skips the unknown field and
// clamps the span, so nothing is broken — but each is almost certainly an
// authoring mistake, and until #4449 this rule ran on no command at all.
// (silently not rendered), and a `section.group` naming a field group the
// object does not declare. Advisory: the renderer skips the unknown field, so
// nothing is broken — but each is almost certainly an authoring mistake, and
// until #4449 this rule ran on no command at all. [#17328] It used to carry a
// third finding, `absolute-colspan-discouraged`; that one was withdrawn —
// `validate-form-layout.ts`'s module note carries the measurement.
// Pure structured-metadata walk (no lazy dependency), so wiring it to all
// three costs nothing measurable.
{
Expand Down
9 changes: 8 additions & 1 deletion packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,17 @@ export {
} from './validate-semantic-roles.js';
export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js';

// [#17328] `FORM_COLSPAN_ABSOLUTE` / `absolute-colspan-discouraged` was
// WITHDRAWN, not renamed: browser measurement at all three surface widths
// falsified the misalignment it asserted, and its recommended alternative
// measured worse than the thing it warned about. The argument is in
// `validate-form-layout.ts`'s module note. Removing the constant rather than
// leaving it exported is deliberate: a rule id on the public surface reads as a
// check the platform performs (Prime Directive #10), and the compiler is the
// precise channel for telling a consumer that suppresses it.
export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';
Expand Down
121 changes: 88 additions & 33 deletions packages/lint/src/validate-form-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { defineStack, normalizeStackInput } from '@objectstack/spec';
import {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';

Expand Down Expand Up @@ -38,7 +37,7 @@ const groupedObjects = [
];

describe('validateFormLayout (#2578)', () => {
it('is clean for a well-formed multi-column form (known fields, no colSpan)', () => {
it('is clean for a well-formed multi-column form (known fields)', () => {
const stack = {
objects,
views: [
Expand Down Expand Up @@ -74,7 +73,19 @@ describe('validateFormLayout (#2578)', () => {
expect(findings[0].path).toBe('views[0].sections[0].fields[1]');
});

it('discourages absolute colSpan and steers to span', () => {
// [#17328] RE-JUDGED, not deleted. This test used to be the pin on the
// `absolute-colspan-discouraged` wording — it asserted the finding fired on
// `colSpan: 2` and that the hint said `span: 'full'`. Both halves were
// measured wrong in Chromium at the three surface widths the message named:
// the renderer clamps the span to the section's column count, so an absolute
// `colSpan` is grid-aligned at EVERY width (0px overflow in every
// configuration), and `span: 'full'` — the recommended alternative — compiles
// to the same class as `colSpan: 4` and renders pixel-identical to authoring
// nothing at the modal width. The rule was withdrawn. What the pin asserts
// now is that withdrawal, and it is deliberately PAIRED with a live finding
// on the same section: a bare `expect([])` here would keep passing if the
// walk stopped reaching this site at all.
it('[#17328] an absolute colSpan is no longer a finding — the rule was withdrawn', () => {
const stack = {
objects,
views: [
Expand All @@ -85,42 +96,70 @@ describe('validateFormLayout (#2578)', () => {
},
],
};
const findings = validateFormLayout(stack);
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FORM_COLSPAN_ABSOLUTE);
expect(findings[0].hint).toContain("span: 'full'");
expect(findings[0].path).toBe('views[0].sections[0].fields[1].colSpan');
});
expect(validateFormLayout(stack)).toEqual([]);

it('reports both rules for the same field independently', () => {
const stack = {
// POSITIVE CONTROL on the identical shape: the walk does reach
// `sections[0].fields[1]`, so the empty result above is a verdict about
// `colSpan` and not about an unread site.
const reached = validateFormLayout({
objects,
views: [
{
name: 'contract_form',
data: { provider: 'object', object: 'contract' },
sections: [{ columns: 2, fields: [{ field: 'ghost', colSpan: 3 }] }],
sections: [{ columns: 2, fields: ['name', { field: 'ghost_amount', colSpan: 2 }] }],
},
],
};
const rules = validateFormLayout(stack).map(f => f.rule).sort();
expect(rules).toEqual([FORM_COLSPAN_ABSOLUTE, FORM_FIELD_UNKNOWN].sort());
});
expect(reached.map((f) => `${f.rule}@${f.path}`)).toEqual([
`${FORM_FIELD_UNKNOWN}@views[0].sections[0].fields[1]`,
]);
});

it('skips reference-checking when the bound object cannot be resolved', () => {
// [#17328] RE-JUDGED, not deleted. The same fixture used to earn TWO findings
// (`form-field-unknown` + `absolute-colspan-discouraged`). With the colSpan
// rule withdrawn the surviving assertion is the one that still carries
// information: a `colSpan` on an entry does not suppress, duplicate or
// relocate the reference finding the entry independently earns.
it('a colSpan on the entry does not disturb the reference finding it earns', () => {
const stack = {
objects,
views: [
{
name: 'orphan_form',
data: { provider: 'object', object: 'does_not_exist' },
sections: [{ columns: 2, fields: ['whatever', { field: 'x', colSpan: 2 }] }],
name: 'contract_form',
data: { provider: 'object', object: 'contract' },
sections: [{ columns: 2, fields: [{ field: 'ghost', colSpan: 3 }] }],
},
],
};
const findings = validateFormLayout(stack);
// No form-field-unknown (object unresolved), but colSpan is still flagged.
expect(findings.map(f => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]);
expect(validateFormLayout(stack).map((f) => `${f.rule}@${f.path}`)).toEqual([
`${FORM_FIELD_UNKNOWN}@views[0].sections[0].fields[0]`,
]);
});

// [#17328] RE-JUDGED, not deleted. The old assertion was
// `[FORM_COLSPAN_ABSOLUTE]` — the colSpan rule was the only thing that could
// still speak on an unresolvable binding, which is what made this test
// non-empty. With that rule withdrawn the verdict is `[]`, so the test is
// PAIRED with the same stack under a resolvable binding; otherwise a walk
// that stopped visiting orphan views entirely would look identical.
it('skips reference-checking when the bound object cannot be resolved', () => {
const sections = [{ columns: 2, fields: ['whatever', { field: 'x', colSpan: 2 }] }];
expect(validateFormLayout({
objects,
views: [{ name: 'orphan_form', data: { provider: 'object', object: 'does_not_exist' }, sections }],
})).toEqual([]);

// POSITIVE CONTROL: the identical sections, bound to an object that DOES
// resolve, report both dangling names — so the silence above is the
// unresolved binding, not an unvisited site.
expect(validateFormLayout({
objects,
views: [{ name: 'bound_form', data: { provider: 'object', object: 'contract' }, sections }],
}).map((f) => `${f.rule}@${f.path}`)).toEqual([
`${FORM_FIELD_UNKNOWN}@views[0].sections[0].fields[0]`,
`${FORM_FIELD_UNKNOWN}@views[0].sections[0].fields[1]`,
]);
});

it('ignores non-form views (no sections array)', () => {
Expand Down Expand Up @@ -295,9 +334,12 @@ describe('#6251 — the view CONTAINER ladder', () => {
form: { data: { object: 'contract' }, groups: [{ fields: ['name', { field: 'ghost_g', colSpan: 3 }] }] },
}],
});
// [#17328] RE-JUDGED: the second row was the withdrawn
// `absolute-colspan-discouraged` finding on the same entry. The `colSpan: 3`
// is deliberately LEFT on the fixture — it is what proves the entry object
// shape is read here at all — but it no longer earns a row.
expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([
`${FORM_FIELD_UNKNOWN}@views[0].form.groups[0].fields[1]`,
`${FORM_COLSPAN_ABSOLUTE}@views[0].form.groups[0].fields[1].colSpan`,
]);
});

Expand Down Expand Up @@ -408,10 +450,13 @@ describe('#6251 — reachable on a REAL parsed app stack', () => {

it('reports every planted defect on that stack — this is the assertion #6251 exists for', () => {
const { value } = quietly(() => cliTierFor(structuredClone(appShape)));
// [#17328] RE-JUDGED: the third row was the withdrawn colSpan finding on
// the `ghost_named` entry. The other two rows are the ones #6251 exists for
// — one per container rung — and both still fire, so the traversal
// assertion this test carries is undiminished.
expect(validateFormLayout(value!).map((f) => `${f.rule}@${f.path}`)).toEqual([
`${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[2]`,
`${FORM_FIELD_UNKNOWN}@views[0].formViews.create.sections[0].fields[1]`,
`${FORM_COLSPAN_ABSOLUTE}@views[0].formViews.create.sections[0].fields[1].colSpan`,
]);
});

Expand Down Expand Up @@ -520,16 +565,26 @@ describe('#16168 — falls back to the container list binding', () => {
})).toEqual([]);
});

it('the colSpan rule is unconditional on the object binding — it already fired on a list-bound container before this fix', () => {
// Measured for the changeset: `absolute-colspan-discouraged` sits OUTSIDE
// the `known`-gated block (validate-form-layout.ts, the `(b)` comment) —
// it needs only `entry.colSpan != null`, never `objName` / `known`. So it
// was never actually dead on HotCRM's list-bound views; #16168 only fixes
// `form-field-unknown` and `form-section-group-unknown`.
const findings = validateFormLayout({
// [#17328] RE-JUDGED, not deleted. This was #16168's measurement that
// `absolute-colspan-discouraged` sat OUTSIDE the `known`-gated block — it
// needed only `entry.colSpan != null`, so it was never dead on HotCRM's
// list-bound views and #16168 only ever fixed the two reference rules. That
// rule is now withdrawn, so what the same fixture measures is the OTHER half
// of the same sentence: on a list-bound container a well-formed field with a
// `colSpan` is silent, while a dangling one is not — which is exactly the
// #16168 fix still holding, now with nothing riding alongside it.
it('[#17328] a list-bound container reports only its reference findings — the colSpan rule is gone', () => {
expect(validateFormLayout({
objects,
views: [listBoundContainer({ sections: [{ columns: 2, fields: [{ field: 'name', colSpan: 2 }] }] })],
});
expect(findings.map((f) => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]);
})).toEqual([]);

// POSITIVE CONTROL — the #16168 fix itself: the same list-bound rung, a
// dangling reference, still reported. Without this the line above would
// pass on a container the walk never descends into.
expect(validateFormLayout({
objects,
views: [listBoundContainer({ sections: [{ columns: 2, fields: [{ field: 'ghost_lb', colSpan: 2 }] }] })],
}).map((f) => f.rule)).toEqual([FORM_FIELD_UNKNOWN]);
});
});
Loading
Loading