diff --git a/BUILD-PLAN-issue-21.md b/BUILD-PLAN-issue-21.md new file mode 100644 index 0000000..81d9ef2 --- /dev/null +++ b/BUILD-PLAN-issue-21.md @@ -0,0 +1,12 @@ +# BUILD-PLAN-issue-21.md — CSS Cascade Layers (@layer) audit + +**Card:** https://github.com/nujovich/mint-radar/issues/21 + +**Decision:** PLAN defined 4 milestones (static detection, no LLM step needed). + +## Milestones + +- [ ] Milestone 1 — Add `CascadeLayerAudit` type system with detection of layer names, declaration order, and rules outside any layer +- [ ] Milestone 2 — Implement @layer parser that groups rules by layer and detects anti-patterns (post-layer specificity, !important inside layers) +- [ ] Milestone 3 — Add layer hierarchy visualization to the CLI output, with warnings for implicit vs explicit order +- [ ] Milestone 4 — Add test fixture with real-world multi-layer projects and tests diff --git a/bin/mint-ds.mjs b/bin/mint-ds.mjs index 4f96653..5c27304 100755 --- a/bin/mint-ds.mjs +++ b/bin/mint-ds.mjs @@ -527,7 +527,7 @@ async function cmdLint(argv) { ) const result = lintCss(css) - const { findings } = result + const { findings, cascadeLayers } = result if (findings.length === 0) { log(styles.green('✓') + ' No lint issues found.') @@ -547,6 +547,44 @@ async function cmdLint(argv) { } } + // Cascade Layers: hierarchy visualization + implicit/explicit order warnings. + if (cascadeLayers && cascadeLayers.layerCount > 0) { + log('') + log(styles.bold('Cascade Layers')) + log('') + if (cascadeLayers.orderExplicit) { + log(styles.dim(' Order: explicit (@layer order statement)')) + } else { + log(styles.dim(' Order: implicit (first-appearance order)')) + log( + ' ' + + styles.yellow('WARN') + + ' Layer order is implicit. Add an explicit order statement (e.g. @layer reset, base, theme;) to pin cascade precedence.' + ) + } + log('') + log(styles.dim(' Layer hierarchy (lowest priority first):')) + for (const entry of cascadeLayers.hierarchy) { + const tag = entry.order === 'explicit' ? 'explicit' : 'implicit' + log( + ' ' + + (entry.rank + 1) + + '. ' + + entry.name + + ' ' + + styles.dim('(' + tag + ', ' + entry.rulesCount + ' rule(s))') + ) + } + log( + styles.dim( + ' - unlayered styles (highest priority; override all layers, ' + + cascadeLayers.rulesOutsideLayers + + ' rule(s))' + ) + ) + log('') + } + // Modern CSS Opportunities: adoption report for gap decorations. const { adoption } = lintGapDecorationAdoption(css, { stylesheetCount: files.length, diff --git a/lib/__fixtures__/cascade-layers.css b/lib/__fixtures__/cascade-layers.css new file mode 100644 index 0000000..e5a2aeb --- /dev/null +++ b/lib/__fixtures__/cascade-layers.css @@ -0,0 +1,77 @@ +/* Fixture for the Cascade Layers audit — a real-world multi-layer project. + Mirrors a design-system stylesheet: an explicit layer order, four named + @layer blocks (reset, base, components, utilities), legacy unlayered + overrides, a high-specificity unlayered rule declared after the layer + order, and an !important inside the reset layer. */ + +@layer reset, base, components, utilities; + +@layer reset { + * { + margin: 0 !important; + box-sizing: border-box; + } + img { + display: block; + max-width: 100%; + } +} + +@layer base { + :root { + --color-text: #111111; + --color-bg: #ffffff; + } + body { + font-family: system-ui, sans-serif; + color: var(--color-text); + background: var(--color-bg); + } + h1, + h2, + h3 { + line-height: 1.2; + } +} + +@layer components { + .card { + display: grid; + gap: 1rem; + padding: 1.5rem; + border-radius: 8px; + } + .button { + border: none; + padding: 0.5rem 1rem; + } + .modal { + position: fixed; + inset: 0; + } +} + +@layer utilities { + .mt-0 { + margin-top: 0; + } + .text-center { + text-align: center; + } +} + +/* Legacy overrides declared outside any layer — they outrank every layer. */ +.legacy-clearfix::after { + content: ''; + display: table; + clear: both; +} + +.footer { + margin-top: 2rem; +} + +/* High-specificity unlayered rule declared after the layer order. */ +#hero { + font-size: 3rem !important; +} diff --git a/lib/__tests__/css-lint-rules.test.mjs b/lib/__tests__/css-lint-rules.test.mjs index 3aaffdd..1067d13 100644 --- a/lib/__tests__/css-lint-rules.test.mjs +++ b/lib/__tests__/css-lint-rules.test.mjs @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' import { parseCssRules, parseDeclarations, @@ -6,6 +7,7 @@ import { lintGapDecorationsCompat, lintGapDecorationAdoption, lintCss, + lintCascadeLayers, } from '../css-lint-rules.mjs' describe('parseCssRules', () => { @@ -360,3 +362,273 @@ describe('lintGapDecorationAdoption', () => { expect(adoption.stylesheetsWithHacks).toBe(1) }) }) + +describe('lintCascadeLayers', () => { + it('returns empty layers and zero unlayered rules for CSS without @layer', () => { + const css = '.text { color: red; }\n.card { padding: 16px; }' + const result = lintCascadeLayers(css) + expect(result.layers).toEqual([]) + expect(result.layerCount).toBe(0) + expect(result.rulesOutsideLayers).toBe(2) + expect(result.issues).toHaveLength(2) + expect(result.issues[0].selector).toBe('.text') + expect(result.issues[1].selector).toBe('.card') + }) + + it('detects @layer statement and tracks layer order', () => { + const css = `@layer reset, base, components; +.text { color: red; }` + const result = lintCascadeLayers(css) + expect(result.layers).toEqual(['reset', 'base', 'components']) + expect(result.layerCount).toBe(3) + }) + + it('detects @layer blocks and extracts names', () => { + const css = `@layer base { .reset { margin: 0; } } +@layer components { .button { border: none; } }` + const result = lintCascadeLayers(css) + expect(result.layers).toEqual(['base', 'components']) + expect(result.layerCount).toBe(2) + expect(result.rulesOutsideLayers).toBe(0) + expect(result.issues).toHaveLength(0) + }) + + it('distinguishes rules inside @layer blocks from unlayered rules', () => { + const css = `@layer base { h1 { font-size: 2rem; } } +@layer theme { .card { border-radius: 8px; } } +.legacy { float: left; } +.footer { margin-top: 0; }` + const result = lintCascadeLayers(css) + expect(result.layers).toEqual(['base', 'theme']) + expect(result.rulesOutsideLayers).toBe(2) + expect(result.issues.map((i) => i.selector)).toEqual(['.legacy', '.footer']) + }) + + it('combines statement order with block declarations', () => { + const css = `@layer reset, base; +@layer base { .reset { margin: 0; } } +@layer components { .card { display: grid; } }` + const result = lintCascadeLayers(css) + expect(result.layers).toEqual(['reset', 'base', 'components']) + expect(result.layerCount).toBe(3) + expect(result.rulesOutsideLayers).toBe(0) + }) + + it('ignores anonymous @layer blocks (no name)', () => { + const css = `@layer { .anonymous { color: blue; } }` + const result = lintCascadeLayers(css) + expect(result.layers).toEqual([]) + expect(result.layerCount).toBe(0) + expect(result.rulesOutsideLayers).toBe(0) + }) + + it('each issue has correct shape', () => { + const css = '.unlayered { color: red; }' + const result = lintCascadeLayers(css) + expect(result.issues).toHaveLength(1) + const issue = result.issues[0] + expect(issue.selector).toBe('.unlayered') + expect(issue.rule).toBe('rules-outside-layers') + expect(issue.severity).toBe('suggestion') + expect(typeof issue.reason).toBe('string') + expect(issue.reason.length).toBeGreaterThan(20) + }) + + it('returns empty result for empty CSS', () => { + const result = lintCascadeLayers('') + expect(result.layers).toEqual([]) + expect(result.layerCount).toBe(0) + expect(result.rulesOutsideLayers).toBe(0) + expect(result.issues).toEqual([]) + }) + + it('wire into lintCss includes cascadeLayers in result', () => { + const css = '@layer base { .reset { margin: 0; } }' + const result = lintCss(css) + expect(result.cascadeLayers).toBeDefined() + expect(result.cascadeLayers.layers).toEqual(['base']) + expect(result.cascadeLayers.rulesOutsideLayers).toBe(0) + }) + + it('groups rules by their named @layer block', () => { + const css = `@layer base { .reset { margin: 0; } } +@layer components { .button { border: none; } .card { display: grid; } }` + const result = lintCascadeLayers(css) + expect(result.rulesByLayer).toEqual({ + base: ['.reset'], + components: ['.button', '.card'], + }) + }) + + it('detects !important inside a layer as important-in-layer', () => { + const css = `@layer reset { * { margin: 0 !important; } }` + const result = lintCascadeLayers(css) + const important = result.issues.filter( + (i) => i.rule === 'important-in-layer' + ) + expect(important).toHaveLength(1) + expect(important[0].selector).toBe('*') + expect(important[0].layer).toBe('reset') + expect(important[0].severity).toBe('warning') + }) + + it('does not flag !important outside layers as important-in-layer', () => { + const css = `.critical { color: red !important; }` + const result = lintCascadeLayers(css) + expect( + result.issues.filter((i) => i.rule === 'important-in-layer') + ).toHaveLength(0) + expect(result.issues).toHaveLength(1) + expect(result.issues[0].rule).toBe('rules-outside-layers') + }) + + it('flags high-specificity unlayered rules after layers as post-layer-specificity', () => { + const css = `@layer base { h1 { font-size: 2rem; } } +#hero { font-size: 3rem; }` + const result = lintCascadeLayers(css) + const postLayer = result.issues.filter( + (i) => i.rule === 'post-layer-specificity' + ) + expect(postLayer).toHaveLength(1) + expect(postLayer[0].selector).toBe('#hero') + expect(postLayer[0].severity).toBe('warning') + // Same selector must not also be reported as a plain rules-outside-layers issue. + expect(result.issues.filter((i) => i.selector === '#hero')).toHaveLength(1) + }) + + it('flags unlayered !important after layers as post-layer-specificity', () => { + const css = `@layer base { h1 { font-size: 2rem; } } +.title { font-size: 2.5rem !important; }` + const result = lintCascadeLayers(css) + const postLayer = result.issues.filter( + (i) => i.rule === 'post-layer-specificity' + ) + expect(postLayer).toHaveLength(1) + expect(postLayer[0].selector).toBe('.title') + }) + + it('keeps low-specificity unlayered rules as rules-outside-layers after layers', () => { + const css = `@layer base { h1 { font-size: 2rem; } } +.legacy { float: left; }` + const result = lintCascadeLayers(css) + expect(result.issues).toHaveLength(1) + expect(result.issues[0].rule).toBe('rules-outside-layers') + expect(result.issues[0].selector).toBe('.legacy') + }) + + it('does not flag high-specificity unlayered rules when no layer exists', () => { + const css = `#hero { font-size: 3rem; }` + const result = lintCascadeLayers(css) + expect(result.issues).toHaveLength(1) + expect(result.issues[0].rule).toBe('rules-outside-layers') + }) + + it('reports implicit order for block-only layer declarations', () => { + const css = `@layer base { .reset { margin: 0; } } +@layer theme { .card { display: grid; } }` + const result = lintCascadeLayers(css) + expect(result.orderExplicit).toBe(false) + expect(result.hierarchy.map((h) => h.order)).toEqual([ + 'implicit', + 'implicit', + ]) + }) + + it('reports explicit order when a statement declares layer order', () => { + const css = `@layer reset, base; +@layer base { .reset { margin: 0; } } +@layer components { .card { display: grid; } }` + const result = lintCascadeLayers(css) + expect(result.orderExplicit).toBe(true) + expect(result.hierarchy.map((h) => h.order)).toEqual([ + 'explicit', + 'explicit', + 'implicit', + ]) + }) + + it('builds ordered hierarchy with rank and rulesCount', () => { + const css = `@layer reset, base; +@layer base { .reset { margin: 0; } .heading { font-weight: bold; } } +@layer components { .card { display: grid; } }` + const result = lintCascadeLayers(css) + expect(result.hierarchy).toEqual([ + { name: 'reset', rank: 0, order: 'explicit', rulesCount: 0 }, + { name: 'base', rank: 1, order: 'explicit', rulesCount: 2 }, + { name: 'components', rank: 2, order: 'implicit', rulesCount: 1 }, + ]) + }) + + it('returns empty hierarchy for empty CSS', () => { + const result = lintCascadeLayers('') + expect(result.orderExplicit).toBe(false) + expect(result.hierarchy).toEqual([]) + }) +}) + +describe('lintCascadeLayers with the multi-layer fixture', () => { + const fixture = readFileSync( + new URL('../__fixtures__/cascade-layers.css', import.meta.url), + 'utf8' + ) + + it('detects the full explicit layer order', () => { + const result = lintCascadeLayers(fixture) + expect(result.layers).toEqual(['reset', 'base', 'components', 'utilities']) + expect(result.layerCount).toBe(4) + expect(result.orderExplicit).toBe(true) + }) + + it('groups rules into their named layer and preserves declaration order', () => { + const result = lintCascadeLayers(fixture) + expect(result.rulesByLayer.reset).toEqual(['*', 'img']) + expect( + result.rulesByLayer.base.map((s) => s.replace(/\s+/g, ' ').trim()) + ).toEqual([':root', 'body', 'h1, h2, h3']) + expect(result.rulesByLayer.components).toEqual([ + '.card', + '.button', + '.modal', + ]) + expect(result.rulesByLayer.utilities).toEqual(['.mt-0', '.text-center']) + }) + + it('flags unlayered legacy overrides and the high-specificity rule', () => { + const result = lintCascadeLayers(fixture) + expect(result.rulesOutsideLayers).toBe(3) + const outside = result.issues.filter( + (i) => i.rule === 'rules-outside-layers' + ) + expect(outside.map((i) => i.selector)).toEqual([ + '.legacy-clearfix::after', + '.footer', + ]) + const postLayer = result.issues.filter( + (i) => i.rule === 'post-layer-specificity' + ) + expect(postLayer).toHaveLength(1) + expect(postLayer[0].selector).toBe('#hero') + expect(postLayer[0].severity).toBe('warning') + }) + + it('flags !important inside the reset layer', () => { + const result = lintCascadeLayers(fixture) + const important = result.issues.filter( + (i) => i.rule === 'important-in-layer' + ) + expect(important).toHaveLength(1) + expect(important[0].selector).toBe('*') + expect(important[0].layer).toBe('reset') + expect(important[0].severity).toBe('warning') + }) + + it('builds a hierarchy with explicit order and per-layer rule counts', () => { + const result = lintCascadeLayers(fixture) + expect(result.hierarchy).toEqual([ + { name: 'reset', rank: 0, order: 'explicit', rulesCount: 2 }, + { name: 'base', rank: 1, order: 'explicit', rulesCount: 3 }, + { name: 'components', rank: 2, order: 'explicit', rulesCount: 3 }, + { name: 'utilities', rank: 3, order: 'explicit', rulesCount: 2 }, + ]) + }) +}) diff --git a/lib/css-lint-rules.mjs b/lib/css-lint-rules.mjs index 2df989d..f681e5d 100644 --- a/lib/css-lint-rules.mjs +++ b/lib/css-lint-rules.mjs @@ -372,13 +372,292 @@ export function lintGapDecorationAdoption(css, opts = {}) { } } +/** + * Find the closing brace matching the open brace at `openIdx`. + * Handles nested braces; does not handle CSS string literals. + * @param {string} src + * @param {number} openIdx + * @returns {number} index of matching '}', or -1 if not found + */ +function findMatchingBrace(src, openIdx) { + let depth = 0 + for (let i = openIdx; i < src.length; i++) { + if (src[i] === '{') depth += 1 + else if (src[i] === '}') { + depth -= 1 + if (depth === 0) return i + } + } + return -1 +} + +/** + * Scan @layer at-rule declarations (both statements and blocks) and extract + * layer names and block spans for rules-outside-layers detection. + * + * Handle three forms: + * 1. @layer name, name2; → statement, declares order only + * 2. @layer name { ... } → block, defines rules inside a layer + * 3. @layer { ... } → anonymous block, no name + * + * Names are validated as CSS identifiers. + * @param {string} src + * @returns {{ names: string[], isBlock: boolean, start?: number, end?: number }[]} + */ +function scanAtLayers(src) { + const results = [] + const RE_VALID_NAME = /^-?[_a-zA-Z][\w-]*$/ + const re = /@layer\b/gi + let m + while ((m = re.exec(src)) !== null) { + const tail = src.slice(m.index + m[0].length) + const nameMatch = /^\s*([^{};]*)/.exec(tail) + if (!nameMatch) continue + const nameStr = nameMatch[1].trim() + // Find the indicator: '{' (block) or ';' (statement) + const delimRe = /^\s*([{;])/ + const afterPhrase = tail.slice(nameMatch[0].length) + const delimM = delimRe.exec(afterPhrase) + + if (!delimM) continue + + if (delimM[1] === ';') { + // Statement form: @layer reset, base; + const names = nameStr + .split(',') + .map((s) => s.trim()) + .filter((n) => RE_VALID_NAME.test(n)) + if (names.length > 0) { + results.push({ names, isBlock: false, index: m.index }) + } + } else if (delimM[1] === '{') { + // Block form: @layer reset { ... } + const openBraceIdx = + m.index + m[0].length + nameMatch[0].length + delimM.index + const closeIdx = findMatchingBrace(src, openBraceIdx) + if (closeIdx === -1) continue + const names = nameStr + .split(',') + .map((s) => s.trim()) + .filter((n) => RE_VALID_NAME.test(n)) + results.push({ + names, + isBlock: true, + index: m.index, + start: openBraceIdx, + end: closeIdx, + }) + re.lastIndex = closeIdx + 1 + } + } + return results +} + +/** + * Find style rules (selectors followed by '{') at brace-depth 0 whose open + * brace does not fall inside any @layer block span. These are rules declared + * outside any @layer — unlayered styles that override all layers. + * + * @param {string} src + * @param {{ start: number, end: number }[]} layerSpans + * @returns {{ selector: string, index: number, body: string }[]} + */ +function findUnlayeredRules(src, layerSpans) { + const rules = [] + let depth = 0 + for (let i = 0; i < src.length; i++) { + if (src[i] === '{') { + if (depth === 0) { + // Read back from 'i' to the preceding '}', ';', or start-of-string + // to capture the full selector text. + let k = i - 1 + while (k >= 0) { + if (src[k] === '}' || src[k] === ';') break + k -= 1 + } + const selector = src.slice(k + 1, i).trim() + if (selector && !selector.startsWith('@')) { + const insideLayer = layerSpans.some( + (span) => i > span.start && i < span.end + ) + if (!insideLayer) { + const close = findMatchingBrace(src, i) + rules.push({ + selector, + index: i, + body: close === -1 ? '' : src.slice(i + 1, close), + }) + } + } + } + depth += 1 + } else if (src[i] === '}') { + depth -= 1 + if (depth < 0) depth = 0 + } + } + return rules +} + +/** + * Find style rules directly inside a named @layer block. Selectors and bodies + * are returned so callers can both group rules by layer and inspect their + * declarations for anti-patterns (e.g. !important). Rules nested deeper than + * one level (e.g. inside an @media query) are skipped; at-rules are ignored. + * + * @param {string} src + * @param {number} start - index of the @layer block's opening brace + * @param {number} end - index of the @layer block's closing brace + * @returns {{ selector: string, body: string }[]} + */ +function findRulesInLayer(src, start, end) { + const rules = [] + let depth = 0 + for (let i = start; i <= end; i++) { + if (src[i] === '{') { + if (depth === 1) { + let k = i - 1 + while ( + k >= start && + src[k] !== '}' && + src[k] !== ';' && + src[k] !== '{' + ) { + k -= 1 + } + const selector = src.slice(k + 1, i).trim() + if (selector && !selector.startsWith('@')) { + const close = findMatchingBrace(src, i) + rules.push({ + selector, + body: close === -1 ? '' : src.slice(i + 1, close), + }) + } + } + depth += 1 + } else if (src[i] === '}') { + depth -= 1 + } + } + return rules +} + +/** + * Detect CSS Cascade Layers (@layer) structure: ordered layer names and rules + * declared outside any layer (unlayered styles override all layers). + * + * @param {string} css - Raw CSS source + * @returns {import('../lib/types.ts').CascadeLayerAudit} + */ +export function lintCascadeLayers(css) { + const src = String(css).replace(/\/\*[\s\S]*?\*\//g, ' ') + const declarations = scanAtLayers(src) + + // Build ordered, deduped layer name list preserving first-seen order. + const layerSet = new Set() + const layers = [] + for (const d of declarations) { + for (const name of d.names) { + if (!layerSet.has(name)) { + layerSet.add(name) + layers.push(name) + } + } + } + + // Collect block spans for rules-outside-layers check. + const layerSpans = declarations + .filter((d) => d.isBlock) + .map((d) => ({ start: d.start, end: d.end })) + + const unlayered = findUnlayeredRules(src, layerSpans) + + const hasLayers = declarations.length > 0 + const firstLayerIndex = hasLayers + ? Math.min(...declarations.map((d) => d.index)) + : -1 + + // Unlayered rules are a suggestion; unlayered rules that also carry high + // specificity and appear after the layer order is established are flagged as + // a more specific anti-pattern (post-layer specificity). + const issues = unlayered.map(({ selector, index, body }) => { + const highSpecificity = selector.includes('#') || /!important/i.test(body) + if (hasLayers && index > firstLayerIndex && highSpecificity) { + return { + selector, + rule: 'post-layer-specificity', + severity: 'warning', + reason: + 'Unlayered rule with high specificity declared after @layer blocks. Unlayered styles already override every layer regardless of specificity, so the extra specificity is misleading and likely an attempt to out-specify the layer cascade. Move this rule into the intended @layer (or drop the specificity) instead.', + } + } + return { + selector, + rule: 'rules-outside-layers', + severity: 'suggestion', + reason: + 'Rule declared outside any @layer block. Unlayered styles take precedence over all layers, which can make layer ordering ineffective. Consider placing this rule inside a @layer block.', + } + }) + + // Milestone 2: group rules by layer and detect !important inside layers. + const rulesByLayer = {} + for (const d of declarations) { + if (!d.isBlock || d.names.length === 0) continue + const layerName = d.names[0] + const rules = findRulesInLayer(src, d.start, d.end) + if (!(layerName in rulesByLayer)) rulesByLayer[layerName] = [] + for (const rule of rules) { + rulesByLayer[layerName].push(rule.selector) + if (/!important/i.test(rule.body)) { + issues.push({ + selector: rule.selector, + rule: 'important-in-layer', + severity: 'warning', + layer: layerName, + reason: `Uses !important inside @layer "${layerName}". !important reverses layer priority: an !important declaration in an earlier layer outranks normal declarations in later layers and unlayered styles, which can make low-priority layers like resets unexpectedly win. Prefer moving the declaration to the appropriate layer instead.`, + }) + } + } + } + + // Milestone 3: layer hierarchy + implicit/explicit ordering detection. + const explicitNames = new Set() + for (const d of declarations) { + if (!d.isBlock) { + for (const name of d.names) explicitNames.add(name) + } + } + const orderExplicit = declarations.some( + (d) => !d.isBlock && d.names.length > 0 + ) + const hierarchy = layers.map((name, rank) => ({ + name, + rank, + order: explicitNames.has(name) ? 'explicit' : 'implicit', + rulesCount: (rulesByLayer[name] || []).length, + })) + + return { + layers, + layerCount: layers.length, + rulesOutsideLayers: unlayered.length, + rulesByLayer, + issues, + orderExplicit, + hierarchy, + } +} + /** * Run all lint rules against CSS and return combined findings. */ export function lintCss(css, projectDir) { const gapResult = lintGapDecorationHacks(css) const compatResult = lintGapDecorationsCompat(css, projectDir) + const cascadeLayers = lintCascadeLayers(css) return { findings: [...gapResult.findings, ...compatResult.findings], + cascadeLayers, } } diff --git a/lib/types.ts b/lib/types.ts index 7171834..734734c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -169,6 +169,43 @@ export interface PropertyTypeIssue { declaredSyntax: string // the @property syntax descriptor, e.g. '' } +export interface CascadeLayerIssue { + selector: string + rule: string // 'rules-outside-layers' | 'post-layer-specificity' | 'important-in-layer' + severity: 'suggestion' | 'warning' + reason: string + /** Layer name for 'important-in-layer' findings. */ + layer?: string +} + +export interface LayerHierarchyEntry { + /** Layer name. */ + name: string + /** Priority rank: 0 = lowest priority (first in the cascade). */ + rank: number + /** 'explicit' if named in an @layer order statement, 'implicit' if order is by first appearance only. */ + order: 'explicit' | 'implicit' + /** Number of style rules directly inside this layer's block(s). */ + rulesCount: number +} + +export interface CascadeLayerAudit { + /** Layer names in declaration order (first-declared = lowest priority). */ + layers: string[] + /** Number of distinct layers detected. */ + layerCount: number + /** Count of style rules declared outside any @layer block. */ + rulesOutsideLayers: number + /** Style-rule selectors grouped by the named @layer block that contains them. */ + rulesByLayer: Record + /** Per-selector findings for rules outside @layer and layer anti-patterns. */ + issues: CascadeLayerIssue[] + /** Whether layer order was established by an explicit `@layer a, b, c;` statement. */ + orderExplicit: boolean + /** Ordered layer hierarchy for CLI visualization (lowest priority first). */ + hierarchy: LayerHierarchyEntry[] +} + export interface AuditReport { brand: string chaosScore: number @@ -183,6 +220,7 @@ export interface AuditReport { adoptionSuggestions?: AdoptionSuggestion[] overflowSafetyIssues?: OverflowSafetyIssue[] propertyTypeIssues?: PropertyTypeIssue[] + cascadeLayers?: CascadeLayerAudit } export interface ColorDecision {