Skip to content

Commit 2f14347

Browse files
authored
Merge pull request #20 from DrSkillIssue/fix/mutually-exclusive-attribute-delta
fix: suppress false positives for css-layout-conditional-white-space-wrap-shift on mutually exclusive attribute dispatch
2 parents b622867 + 488f08c commit 2f14347

8 files changed

Lines changed: 115 additions & 7 deletions

File tree

packages/ganko/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@drskillissue/ganko",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "Static analysis SDK for Solid.js — graphs, rules, ESLint adapter",
55
"license": "MIT",
66
"type": "module",

packages/ganko/src/cross-file/layout/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ export function buildLayoutGraph(solids: readonly SolidGraph[], css: CSSGraph, l
330330
elements,
331331
appliesByNode,
332332
monitoredDeclarationsBySelectorId,
333+
selectorsById,
333334
)
334335
const elementsWithConditionalOverflowDelta = buildConditionalDeltaSignalGroupElements(
335336
conditionalDeltaIndex.elementsWithConditionalDeltaBySignal,

packages/ganko/src/cross-file/layout/cascade-builder.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ export function buildConditionalDeltaIndex(
400400
elements: readonly LayoutElementNode[],
401401
appliesByNode: ReadonlyMap<LayoutElementNode, readonly LayoutMatchEdge[]>,
402402
monitoredDeclarationsBySelectorId: ReadonlyMap<number, readonly MonitoredDeclaration[]>,
403+
selectorsById: ReadonlyMap<number, SelectorEntity>,
403404
): ConditionalDeltaIndex {
404405
const conditionalSignalDeltaFactsByNode = new Map<LayoutElementNode, ReadonlyMap<LayoutSignalName, LayoutConditionalSignalDeltaFact>>()
405406
const elementsWithConditionalDeltaBySignal = new Map<LayoutSignalName, LayoutElementNode[]>()
@@ -414,13 +415,25 @@ export function buildConditionalDeltaIndex(
414415

415416
if (edges !== undefined && edges.length > 0) {
416417
const byProperty = new Map<LayoutSignalName, { conditional: Set<string>; unconditional: Set<string> }>()
418+
// Lazily allocated. Tracks which attribute dispatch group each conditional (property, value)
419+
// belongs to. Used to detect mutually exclusive attribute value selectors (e.g.
420+
// [data-sizing="intrinsic"] vs [data-sizing="flex"]) where only one can match at a time.
421+
let conditionalAttributeDispatch: Map<LayoutSignalName, Map<string, string>> | null = null
417422

418423
for (let j = 0; j < edges.length; j++) {
419424
const currentEdge = edges[j]
420425
if (!currentEdge) continue
421426
const declarations = monitoredDeclarationsBySelectorId.get(currentEdge.selectorId)
422427
if (!declarations) continue
423428

429+
// Identify the dynamic attribute causing conditionality for this edge.
430+
// A conditional match from a selector like [data-sizing="intrinsic"] on an element
431+
// with data-sizing=null (dynamic) means the conditionality comes from data-sizing.
432+
let conditionalAttributeName: string | null = null
433+
if (currentEdge.conditionalMatch) {
434+
conditionalAttributeName = identifyConditionalAttribute(currentEdge.selectorId, node, selectorsById)
435+
}
436+
424437
for (let k = 0; k < declarations.length; k++) {
425438
const declaration = declarations[k]
426439
if (!declaration) continue
@@ -440,6 +453,16 @@ export function buildConditionalDeltaIndex(
440453

441454
if (declaration.guardProvenance.kind === LayoutSignalGuard.Conditional || currentEdge.conditionalMatch) {
442455
bucket.conditional.add(expandedEntry.value)
456+
// Track the attribute dispatch source for this conditional value
457+
if (conditionalAttributeName !== null && declaration.guardProvenance.kind !== LayoutSignalGuard.Conditional) {
458+
if (conditionalAttributeDispatch === null) conditionalAttributeDispatch = new Map()
459+
let dispatchMap = conditionalAttributeDispatch.get(property)
460+
if (!dispatchMap) {
461+
dispatchMap = new Map()
462+
conditionalAttributeDispatch.set(property, dispatchMap)
463+
}
464+
dispatchMap.set(expandedEntry.value, conditionalAttributeName)
465+
}
443466
continue
444467
}
445468
bucket.unconditional.add(expandedEntry.value)
@@ -467,6 +490,29 @@ export function buildConditionalDeltaIndex(
467490
}
468491
}
469492

493+
// Suppress delta when all conditional values come from mutually exclusive
494+
// attribute value selectors on the same attribute. E.g., [data-sizing="intrinsic"]
495+
// sets white-space:nowrap and [data-sizing="flex"] sets white-space:normal — these
496+
// are mutually exclusive on the same element, so the property never actually shifts.
497+
if (hasDelta && conditionalAttributeDispatch !== null) {
498+
const dispatchMap = conditionalAttributeDispatch.get(property)
499+
if (dispatchMap !== undefined && dispatchMap.size === conditionalValues.length) {
500+
let singleAttribute: string | null = null
501+
let allSameAttribute = true
502+
for (const attrName of dispatchMap.values()) {
503+
if (singleAttribute === null) {
504+
singleAttribute = attrName
505+
} else if (singleAttribute !== attrName) {
506+
allSameAttribute = false
507+
break
508+
}
509+
}
510+
if (allSameAttribute && singleAttribute !== null) {
511+
hasDelta = false
512+
}
513+
}
514+
}
515+
470516
const scrollProfile = buildScrollValueProfile(property, conditionalValues, unconditionalValues)
471517

472518
facts.set(property, {
@@ -571,6 +617,45 @@ export function buildConditionalDeltaSignalGroupElements(
571617
return out
572618
}
573619

620+
/**
621+
* Identify the single dynamic attribute on the element that caused a conditional
622+
* selector match. Returns the attribute name if exactly one attribute constraint
623+
* in the selector's subject compound targets a dynamic attribute (value=null) on
624+
* the element with an `equals` operator. Returns null if the conditionality comes
625+
* from multiple attributes, non-equals operators, or non-attribute sources.
626+
*/
627+
function identifyConditionalAttribute(
628+
selectorId: number,
629+
node: LayoutElementNode,
630+
selectorsById: ReadonlyMap<number, SelectorEntity>,
631+
): string | null {
632+
const selector = selectorsById.get(selectorId)
633+
if (!selector) return null
634+
635+
const constraints = selector.anchor.attributes
636+
let dynamicAttributeName: string | null = null
637+
638+
for (let i = 0; i < constraints.length; i++) {
639+
const constraint = constraints[i]
640+
if (!constraint) continue
641+
if (constraint.operator !== "equals") continue
642+
if (constraint.value === null) continue
643+
644+
// Check if this attribute is dynamic on the element.
645+
// attributes.get returns undefined (absent), string (known), or null (dynamic).
646+
// Only null (dynamic value) is the conditionality source.
647+
const elementValue = node.attributes.get(constraint.name)
648+
if (elementValue !== null) continue
649+
if (dynamicAttributeName !== null && dynamicAttributeName !== constraint.name) {
650+
// Multiple different dynamic attributes — can't determine single dispatch source
651+
return null
652+
}
653+
dynamicAttributeName = constraint.name
654+
}
655+
656+
return dynamicAttributeName
657+
}
658+
574659
function buildScrollValueProfile(
575660
property: LayoutSignalName,
576661
conditionalValues: readonly string[],

packages/ganko/test/cross-file/layout-cls-rules.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from "vitest"
22
import type { Diagnostic } from "../../src/diagnostic"
33
import { analyzeCrossFileInput } from "../../src/cross-file"
4-
import { parseCode, lazyParseBatch } from "../solid/test-utils"
4+
import { parseCode } from "../solid/test-utils"
55

66
interface CssFixture {
77
readonly path: string
@@ -14,7 +14,6 @@ interface CssFixture {
1414
* The SolidInput is created lazily on first access and cached for reuse.
1515
*/
1616
const tsxToSolidInput = new Map<string, ReturnType<typeof parseCode>>()
17-
let batchProgram: import("typescript").Program | null = null
1817
let batchFileCounter = 0
1918

2019
function getOrCreateSolidInput(tsx: string): ReturnType<typeof parseCode> {
@@ -1627,6 +1626,29 @@ describe("CLS rule suite", () => {
16271626
expect(diagnostics).toHaveLength(1)
16281627
})
16291628

1629+
it("does not flag white-space wrap shifts from mutually exclusive attribute value selectors", () => {
1630+
const diagnostics = runRule(
1631+
"css-layout-conditional-white-space-wrap-shift",
1632+
`
1633+
import "./layout.css"
1634+
export function App() {
1635+
return (
1636+
<section>
1637+
<p class="cell" data-sizing={props.sizing}>content</p>
1638+
<p>peer</p>
1639+
</section>
1640+
)
1641+
}
1642+
`,
1643+
`
1644+
.cell[data-sizing="intrinsic"] { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1645+
.cell[data-sizing="flex"] { white-space: normal; word-break: break-word; }
1646+
`,
1647+
)
1648+
1649+
expect(diagnostics).toHaveLength(0)
1650+
})
1651+
16301652
it("flags conditional inset-block shorthand offsets", () => {
16311653
const diagnostics = runRule(
16321654
"css-layout-conditional-offset-shift",

packages/ganko/test/solid/rules/avoid-object-spread-edge-cases.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect, beforeEach } from "vitest"
1+
import { describe, it, expect } from "vitest"
22
import { lazyRuleBatch, applyAllFixes, at } from "../test-utils"
33
import { avoidObjectSpread } from "../../../src/solid/rules/correctness"
44

packages/lsp/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@drskillissue/ganko-lsp",
3-
"version": "0.2.5",
3+
"version": "0.2.6",
44
"description": "Language server and CLI linter for Solid.js projects",
55
"license": "MIT",
66
"type": "commonjs",

packages/shared/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@drskillissue/ganko-shared",
33
"private": true,
4-
"version": "0.2.5",
4+
"version": "0.2.6",
55
"description": "Shared protocol types for the ganko toolchain",
66
"license": "MIT",
77
"type": "commonjs",

packages/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"private": true,
44
"displayName": "Ganko — Solid.js & CSS Linter",
55
"description": "Static analysis for Solid.js and CSS",
6-
"version": "0.2.5",
6+
"version": "0.2.6",
77
"license": "MIT",
88
"publisher": "ganko",
99
"icon": "icon.png",

0 commit comments

Comments
 (0)