/** * Lints an eidos CSS file against a `CompiledMorfo` contract. * * The compiler emits `contracts.cssSelectors` — the closed set of * `[data-*]` selectors the morfo runtime can produce. Every selector * in an eidos CSS file falls into one of three buckets: * * - `morfo-backed`: every `data-*` attr is declared on a part. The * enum value (if any) matches the part's allowed values. The * runtime is what writes this attr — the eidos rule reacts to it. * - `eidos-only`: the marker is present (`[data-toggle]`) but at * least one `data-*` in the selector is not declared in the * morfo. Valid by convention — those are eidos design tokens * authored on the host element by the consumer (e.g. `data-color`, * `data-size`, `data-variant`). * - `invalid`: the selector references an attr that *is* declared on * the morfo part, but with a value outside the declared enum. * E.g. `[data-toggle][data-state='maybe']` when the morfo enum is * `{'on', 'off'}`. This is a bug. * * No CSS-AST dependency — `[data-*]` selectors in eidos are flat and * regex-extractable. */ import type { CompiledMorfo } from '../morfo/compile' import { PALETTE_SCALES } from './lib/types' export interface LintAttrRef { readonly attr: string readonly value?: string } export interface LintFinding { readonly raw: string // the full selector list as authored readonly rule: string // one selector inside the comma list readonly status: 'morfo-backed' | 'eidos-only' | 'invalid' readonly attrs: readonly LintAttrRef[] readonly eidosOnlyAttrs?: readonly LintAttrRef[] readonly reason?: string } export interface LintClassHook { readonly rule: string readonly className: string } export interface LintReport { readonly component: string readonly findings: readonly LintFinding[] readonly counts: { readonly morfoBacked: number readonly eidosOnly: number readonly invalid: number readonly classHooks: number } /** `data-*` attrs the runtime promised but eidos never styled. */ readonly unusedFromContract: readonly string[] /** * Class selectors carrying styles in component CSS — a second, ungoverned * hook grammar that escapes the `[data-*]` contract this linter validates. * Catalog norm (checkpoint verdict S1, 2026-07-07): selector hooks are * ALWAYS `data-{component}-{kebab}`; classes are allowed only as * selector-less utilities. */ readonly classHooks: readonly LintClassHook[] } const SELECTOR_LINE = /(?:^|\})\s*([^\s{}][^{}]*?)\s*\{/g const ATTR_BRACKET = /\[([a-z][\w-]*)(?:\s*=\s*(?:'([^']*)'|"([^"]*)"|([^\]]+)))?\]/gi const COMMENT = /\/\*[\s\S]*?\*\//g export function lintEidosCss(cssText: string, compiled: CompiledMorfo): LintReport { const text = cssText.replace(COMMENT, '') // Index every (attr, value) pair the morfo declares per part. // Empty `Set` value means "presence-only" (no enum). const declaredByPart = new Map>>() for (const [partKebab, contracts] of compiled.contracts.dataAttrsByPart) { const byAttr = new Map>() for (const c of contracts) { byAttr.set(c.attr, new Set(c.values ?? [])) } declaredByPart.set(partKebab, byAttr) } // Marker → part kebab. `data-{component}` ↔ provider, `data-{component}-{kebab}` ↔ kebab. const markerToPart = new Map() for (const [kebab, marker] of Object.entries(compiled.parts.attrs)) { markerToPart.set(marker, kebab) } // Track which (part, attr, value) triples actually appear in CSS. const seenContract = new Set() const findings: LintFinding[] = [] const classHooks: LintClassHook[] = [] const ruleMatches = [...text.matchAll(SELECTOR_LINE)] for (const match of ruleMatches) { const raw = match[1].trim() const rules = splitTopLevelCommas(raw) for (const rule of rules) { // S1 (checkpoint 2026-07-07): class selectors are an ungoverned hook // grammar — collect them regardless of whether the rule also carries // data attrs. Bracketed segments are stripped first so attribute // values containing dots don't read as classes. const noBrackets = rule.replace(/\[[^\]]*\]/g, '') for (const m of noBrackets.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g)) { classHooks.push({ rule, className: m[1] }) } const attrs = extractAttrs(rule) if (attrs.length === 0) continue const finding = classify(rule, attrs, markerToPart, declaredByPart, seenContract) findings.push({ raw, rule, ...finding }) } } // Diff seenContract against everything the compiler promised. const unusedFromContract: string[] = [] for (const sel of compiled.contracts.cssSelectors) { const key = `${sel.part}|${sel.attr}|${sel.value ?? ''}` if (!seenContract.has(key)) { unusedFromContract.push(sel.selector) } } const counts = { morfoBacked: findings.filter((f) => f.status === 'morfo-backed').length, eidosOnly: findings.filter((f) => f.status === 'eidos-only').length, invalid: findings.filter((f) => f.status === 'invalid').length, classHooks: classHooks.length } return { component: compiled.kebab, findings: Object.freeze(findings), counts, unusedFromContract: Object.freeze(unusedFromContract.sort()), classHooks: Object.freeze(classHooks) } } function classify( rule: string, attrs: readonly LintAttrRef[], markerToPart: ReadonlyMap, declaredByPart: ReadonlyMap>>, seenContract: Set ): Pick { let currentPart: string | undefined let currentPartAttrs: ReadonlyMap> | undefined let hasKnownMarker = false const eidosOnlyAttrs: LintAttrRef[] = [] let invalidReason: string | undefined for (const ref of attrs) { const markerPart = markerToPart.get(ref.attr) if (markerPart) { currentPart = markerPart currentPartAttrs = declaredByPart.get(markerPart) hasKnownMarker = true continue } if (!currentPartAttrs) { if (ref.attr.startsWith('data-')) eidosOnlyAttrs.push(ref) continue } // Skip non-data attrs (`role`, `aria-*` checked but not enforced here), // the marker is already handled. if (!currentPartAttrs.has(ref.attr)) { // `aria-*` and `role` are still part of the contract surface but the // compiler tracks them in the part's static/dynamic plans, not in // dataAttrsByPart. Treat any non-data attr as "eidos may use it". if (!ref.attr.startsWith('data-')) continue eidosOnlyAttrs.push(ref) continue } const allowed = currentPartAttrs.get(ref.attr)! if (ref.value !== undefined && allowed.size > 0 && !allowed.has(ref.value)) { // The universal palette cascade (render-css `universalPaletteDecls`) // extends `data-color` with the donor scales — an eidos-owned axis // extension (TSC scope `color:*`). The morfo enum keeps declaring // the SEMANTIC roles; the scale values are sanctioned eidos-only // vocabulary, not drift. if (ref.attr === 'data-color' && (PALETTE_SCALES as readonly string[]).includes(ref.value)) { eidosOnlyAttrs.push(ref) continue } invalidReason = `[${ref.attr}='${ref.value}'] is not in the morfo enum {${[...allowed].map((v) => `'${v}'`).join(', ')}} for part "${currentPart}".` } seenContract.add(`${currentPart}|${ref.attr}|${ref.value ?? ''}`) } if (invalidReason) { return { status: 'invalid', attrs, reason: invalidReason } } if (eidosOnlyAttrs.length) { const attrsText = eidosOnlyAttrs.map(formatAttrRef).join(', ') const reason = hasKnownMarker ? `Eidos design token(s): ${attrsText} not declared on nearest morfo part.` : `Selector has no known morfo part marker: ${attrsText}.` return { status: 'eidos-only', attrs, eidosOnlyAttrs, reason } } return { status: 'morfo-backed', attrs } } function formatAttrRef(ref: LintAttrRef): string { return `[${ref.attr}${ref.value !== undefined ? `='${ref.value}'` : ''}]` } function extractAttrs(rule: string): LintAttrRef[] { const out: LintAttrRef[] = [] for (const m of rule.matchAll(ATTR_BRACKET)) { const attr = m[1].toLowerCase() const value = m[2] ?? m[3] ?? m[4] out.push(value !== undefined ? { attr, value } : { attr }) } return out } function splitTopLevelCommas(input: string): string[] { const out: string[] = [] let depth = 0 let buf = '' for (const ch of input) { if (ch === '(' || ch === '[') depth++ else if (ch === ')' || ch === ']') depth-- if (ch === ',' && depth === 0) { out.push(buf.trim()) buf = '' } else { buf += ch } } if (buf.trim()) out.push(buf.trim()) return out } export function formatLintReport(report: LintReport): string { const lines: string[] = [] lines.push(`eidos lint — ${report.component}`) lines.push( ` morfo-backed: ${report.counts.morfoBacked} eidos-only: ${report.counts.eidosOnly} invalid: ${report.counts.invalid} class-hooks: ${report.counts.classHooks}` ) lines.push('') const grouped = { 'morfo-backed': [] as LintFinding[], 'eidos-only': [] as LintFinding[], invalid: [] as LintFinding[] } for (const f of report.findings) grouped[f.status].push(f) if (grouped.invalid.length) { lines.push(' ✗ invalid (selector value not in morfo enum):') for (const f of grouped.invalid) { lines.push(` ${f.rule}`) if (f.reason) lines.push(` ${f.reason}`) } lines.push('') } if (grouped['morfo-backed'].length) { lines.push(' ✓ morfo-backed (runtime writes the attr, eidos reacts):') for (const f of grouped['morfo-backed']) lines.push(` ${f.rule}`) lines.push('') } if (grouped['eidos-only'].length) { lines.push(' · eidos-only (wrapper attrs or non-morfo DOM signals):') for (const f of grouped['eidos-only']) lines.push(` ${f.rule}`) lines.push('') } if (report.classHooks.length) { lines.push(' ✗ class hooks (norm S1 2026-07-07: hooks are ALWAYS data-attrs; classes only as selector-less utilities):') for (const h of report.classHooks) lines.push(` .${h.className} in ${h.rule}`) lines.push('') } if (report.unusedFromContract.length) { lines.push(' · contract selectors with no eidos rule yet:') for (const sel of report.unusedFromContract) lines.push(` ${sel}`) } return lines.join('\n') }