You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
289 lines
10 KiB
289 lines
10 KiB
/**
|
|
* 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<string>` value means "presence-only" (no enum).
|
|
const declaredByPart = new Map<string, Map<string, ReadonlySet<string>>>()
|
|
for (const [partKebab, contracts] of compiled.contracts.dataAttrsByPart) {
|
|
const byAttr = new Map<string, ReadonlySet<string>>()
|
|
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<string, string>()
|
|
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<string>()
|
|
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<string, string>,
|
|
declaredByPart: ReadonlyMap<string, ReadonlyMap<string, ReadonlySet<string>>>,
|
|
seenContract: Set<string>
|
|
): Pick<LintFinding, 'status' | 'attrs' | 'eidosOnlyAttrs' | 'reason'> {
|
|
let currentPart: string | undefined
|
|
let currentPartAttrs: ReadonlyMap<string, ReadonlySet<string>> | 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')
|
|
}
|