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.
svelte-kit-vice/src/uix/eidos/lint.ts

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')
}

Powered by TurnKey Linux.