/// /** * `@estiva-app/ui/eslint` — the UI Guardrails' lint rules, as a plugin (UIG-3, * seam S1 in docs/GATES.md §16). * * A plugin rather than config, so every app — Peek, Ship, Leaf — gets a new * rule through an ordinary version bump, and an app's editor hook runs the * very rule code its CI runs. * * import estiva from '@estiva-app/ui/eslint' * export default [{ files: ['src/**\/*.tsx'], ...estiva.configs.recommended }] * * Register it under `estiva` (the configs do): the rule ids are * `estiva/`, and `countGates` counts those ids. * * Built on its own by build.mjs, for Node, into `dist/eslint/`; nothing here * reaches the components' browser bundle. */ import { createRequire } from 'node:module' import type { ESLint, Linter } from 'eslint' import { componentHasAPage, componentHasAStory } from './has-a-page-and-a-story' import { noHandRolledBehaviour } from './no-hand-rolled-behaviour' import { noHandmadeEmptyState } from './no-handmade-empty-state' import { noHandmadeHeader } from './no-handmade-header' import { noCopiedLook } from './no-copied-look' import { noFieldOutsideAForm } from './no-field-outside-a-form' import { noNativeTitle } from './no-native-title' import { noRawElement } from './no-raw-element' import { noRebuiltBehaviour } from './no-rebuilt-behaviour' import { noRestyledPart } from './no-restyled-part' import { rawElementOutsideAWrapper } from './raw-element-outside-a-wrapper' import { APP_RULE_IDS, PLUGIN_KEY, type AppRuleName } from './rule-ids' export { ESCAPE_MARKER, MIN_REASON, SETTINGS_KEY, isEscaped, type EstivaSettings } from './escape' export { OWNED_BEHAVIOURS, type OwnedBehaviour } from './no-rebuilt-behaviour' export { PART_LOOK_PROPS, PLACEMENT } from './no-restyled-part' export { PARTS_WITH_A_TITLE } from './no-native-title' export { MIN_COVER, MIN_SHARED } from './looks-of' export { FIELD_PARTS } from './no-field-outside-a-form' const { version } = createRequire(import.meta.url)('../../package.json') as { version: string } export { APP_RULE_IDS, PLUGIN_KEY } /** * The rules an **app** runs: they say an app must not build what the package * already has — a raw control (UIG-7), or a behaviour one of its parts owns * (UIG-8) — nor restyle a part it uses (UIG-9). `recommended` and `strict` * carry these and only these. Their names live in `rule-ids.ts`, which * `create-estiva-app` reads without loading a rule; `satisfies` fails the * typecheck when a rule is missing from either. */ const appRules = { 'no-raw-element': noRawElement, 'no-rebuilt-behaviour': noRebuiltBehaviour, 'no-restyled-part': noRestyledPart, 'no-handmade-header': noHandmadeHeader, 'no-handmade-empty-state': noHandmadeEmptyState, 'no-native-title': noNativeTitle, 'no-copied-look': noCopiedLook, 'no-field-outside-a-form': noFieldOutsideAForm, } satisfies Record /** * The rules the **package itself** runs, pointed inward (UIG-5): don't bury a * raw element inside a component, don't rebuild what Base UI owns, don't ship a * component without a page or a story. * * They are in `configs.package`, never in `recommended`, on purpose. Peek and * Ship spread `recommended`, so a rule added here must not arrive in an app * with the next version bump: an app's components are not primitives, so * "buried inside a component" means nothing there (`no-raw-element` is the * app's version, UIG-7), and an app has no `.mdx` pages at all. `index.test.ts` holds the * apps' list to exactly the app rules. * * Five rules are in both sets: `no-restyled-part` (UIG-9), and the * fingerprints `no-handmade-header` (UIG-22), `no-handmade-empty-state` * (UIG-23), `no-native-title` (UIG-24) and `no-copied-look` (UIG-25, a * warning). The package restyles none of its own parts either (Katerina, 17 * September), and "my review isn't enough" (23 September). */ const packageRules = { 'raw-element-outside-a-wrapper': rawElementOutsideAWrapper, 'no-hand-rolled-behaviour': noHandRolledBehaviour, 'component-has-a-page': componentHasAPage, 'component-has-a-story': componentHasAStory, 'no-restyled-part': noRestyledPart, 'no-handmade-header': noHandmadeHeader, 'no-handmade-empty-state': noHandmadeEmptyState, 'no-native-title': noNativeTitle, 'no-copied-look': noCopiedLook, } const rules = { ...appRules, ...packageRules } const plugin = { meta: { name: '@estiva-app/ui/eslint', version }, rules, configs: {} as { recommended: Linter.Config; strict: Linter.Config; package: Linter.Config }, } satisfies ESLint.Plugin /** The ids `package` carries — what this package's own gate runs and counts (UIG-5). */ export const PACKAGE_RULE_IDS = Object.keys(packageRules).map((name) => `${PLUGIN_KEY}/${name}`) /** * `recommended` switches every **app** rule on at the level it was ruled at: an * error blocks, a warning is reported and never blocks. `strict` makes every app * rule an error. They part at the first warning-level rule, `no-copied-look` * (UIG-25): `recommended` warns on it, `strict` blocks on it. * * `package` is the inward set (UIG-5), which only this package runs. */ plugin.configs.recommended = { name: '@estiva-app/ui/recommended', plugins: { [PLUGIN_KEY]: plugin }, rules: { [`${PLUGIN_KEY}/no-raw-element`]: 'error', [`${PLUGIN_KEY}/no-rebuilt-behaviour`]: 'error', [`${PLUGIN_KEY}/no-restyled-part`]: 'error', [`${PLUGIN_KEY}/no-handmade-header`]: 'error', [`${PLUGIN_KEY}/no-handmade-empty-state`]: 'error', [`${PLUGIN_KEY}/no-native-title`]: 'error', // A warning, never an error (Katerina, 13 September): likeness is a judgement. [`${PLUGIN_KEY}/no-copied-look`]: 'warn', [`${PLUGIN_KEY}/no-field-outside-a-form`]: 'error', }, } plugin.configs.strict = { name: '@estiva-app/ui/strict', plugins: { [PLUGIN_KEY]: plugin }, rules: Object.fromEntries(APP_RULE_IDS.map((id) => [id, 'error'])), } plugin.configs.package = { name: '@estiva-app/ui/package', plugins: { [PLUGIN_KEY]: plugin }, // Every inward rule blocks, but the copied look, which warns here too (UIG-25). rules: Object.fromEntries(PACKAGE_RULE_IDS.map((id) => [id, id === `${PLUGIN_KEY}/no-copied-look` ? 'warn' : 'error'])), } export default plugin export interface GateRuleCount { errors: number warnings: number escapes: number } export interface GateCount { /** Per rule id, including rules with nothing to report. */ rules: Record /** * Reports of these rules that an `eslint-disable` directive silenced. Not * an escape: a gate fails on any of these (docs/GATES.md §16, S4). */ disabled: { filePath: string; line: number; ruleId: string }[] } /** * Count what a lint of these rules found, for `.gates-count.json` (seam S3). * * Run the lint with `settings: { estiva: { reportEscapes: true } }` for the * escapes to be counted; without it they are silent and count 0. A marker * with no reason, or inside a directive, counts as an error of its rule. * * `seed` is which rules the count lists when they found nothing, and it is the * app rules unless a caller says otherwise: an app's count file must not gain * rows for the inward rules (UIG-5) that its gate does not run. This package's * own count script passes `PACKAGE_RULE_IDS`. */ export function countGates(results: ESLint.LintResult[], seed: readonly string[] = APP_RULE_IDS): GateCount { const counts: Record = Object.fromEntries(seed.map((id) => [id, { errors: 0, warnings: 0, escapes: 0 }])) const disabled: GateCount['disabled'] = [] const ours = (ruleId: string | null | undefined): ruleId is string => typeof ruleId === 'string' && ruleId.startsWith(`${PLUGIN_KEY}/`) for (const result of results) { for (const message of result.messages) { // Seeded or not: every rule of this plugin that reported is counted, so a // lint of a set the caller did not name is still counted in full. if (!ours(message.ruleId)) continue const count = (counts[message.ruleId] ??= { errors: 0, warnings: 0, escapes: 0 }) if (message.messageId === 'escaped') count.escapes += 1 else if (message.severity === 2) count.errors += 1 else count.warnings += 1 } for (const message of result.suppressedMessages ?? []) { if (ours(message.ruleId)) disabled.push({ filePath: result.filePath, line: message.line, ruleId: message.ruleId }) } } return { rules: counts, disabled } }