From 2adfdf3af2ba7bfcec3d11ed51393caf172cfcb4 Mon Sep 17 00:00:00 2001 From: Isaque Santos Date: Thu, 10 Sep 2026 11:03:24 -0300 Subject: [PATCH 1/2] chore(webkit): run the design-system gate in four stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adoption report has been a single non-blocking step since #2335. That measures, but nothing verifies the measurement, and nothing checks the wiring it depends on. This makes it a gate with stages that are deliberately not equally strict. **canary — blocks.** `scripts/webkit-canary.mjs` writes five fixtures that violate a rule on purpose and asserts each is still flagged by that exact rule. It exists because every way of losing the rules is silent: an unresolved catalog disables 8 of the 12 with one stderr line, an extension missing from the preset reports nothing at all, and a config edit that drops the preset leaves a lint that still passes. In all three cases the adoption number reads *better*. Two of the three happened while this was being built, which is why this is the one stage that blocks. **wiring — reports, for now.** `webkit doctor` decides whether webkit is registered with Tailwind by looking only at `src/webkit.css`; this project does it correctly in `src/styles/main.css`, so the check returns a false FAIL. It becomes blocking as soon as the doctor reads the project's real CSS entry — the job carries that note. **adoption — reports.** The script is now self-contained: it runs ESLint itself instead of consuming a JSON that a previous step wrote, so the number cannot come from a stale file. It gained an adoption score (share of clean `.vue`/`.astro` files, so a file with twenty findings weighs the same as one with a single finding), a baseline ratchet, `--format json`, and a coverage note that names what it did not look at. `--fail-on new` is the flip to blocking, once `.webkit-baseline.json` is committed. **style — reports.** stylelint over the stylesheets; clean today. `Webkit gate` aggregates them and is the single check to mark required: it passes when every stage succeeded *or was cleanly skipped*, so a stage can be disabled without changing which check is required. The report step is removed from `pr-checks.yml` — it lives in the new workflow now, so the measurement does not run twice. Two findings from wiring this up, both in `scripts/lib/webkit-lint.mjs`: - The scripts shell out to `node_modules/.bin/eslint`, not the Node API and not the resolved bin file. Measured here with eslint 9.39.5: the API returned a fatal "Unexpected token interface" for every `.astro` file while the CLI linted them fine, and `node ` reproduced the failure. pnpm's shim exports NODE_PATH before exec'ing node; without it ESLint cannot resolve astro-eslint-parser and silently uses the default one. All three paths succeed — two just report a smaller number. - `pnpm run` prints "Already up to date" and "Done in Xms" on stdout, so the CI step uses `pnpm --silent` to keep that out of the Summary. These scripts are a stand-in. `webkit report` and `webkit canary` are being added to @aziontech/webkit itself (aziontech/webkit#964); they only reach a consumer through an npm release, and this repo needed the gate before that release. When the published package carries them, this workflow becomes one `uses:` of the design system's reusable workflow and all three scripts go away. --- .github/GOVERNANCE.md | 4 +- .github/workflows/pr-checks.yml | 10 - .github/workflows/webkit-gate.yml | 150 +++++++++ package.json | 5 +- scripts/lib/webkit-lint.mjs | 83 +++++ scripts/webkit-adoption-report.mjs | 519 +++++++++++++++++------------ scripts/webkit-canary.mjs | 160 +++++++++ 7 files changed, 696 insertions(+), 235 deletions(-) create mode 100644 .github/workflows/webkit-gate.yml create mode 100644 scripts/lib/webkit-lint.mjs create mode 100644 scripts/webkit-canary.mjs diff --git a/.github/GOVERNANCE.md b/.github/GOVERNANCE.md index 0f3c26f2a5..6d9d27bac7 100644 --- a/.github/GOVERNANCE.md +++ b/.github/GOVERNANCE.md @@ -68,7 +68,9 @@ AI-assisted PRs follow the same rules as any other. The author, not the agent, i On every PR: the site builds, frontmatter namespaces and permalinks are present and unique, and the PR title matches the convention. A broken build or a duplicate permalink blocks the merge. -On every PR, informational only: a **webkit adoption report** runs the design-system ESLint rules over the UI and writes the result to the run Summary — how many `webkit/*` violations there are, which rules, which files, and what the check did *not* look at. It never blocks the merge. It exists so the distance between this codebase and the design system is a number someone can watch, instead of something noticed in review. +On every PR, the **Webkit gate** runs the design system's checks in four stages. Two of them block: the wiring must be sound, and a set of deliberately-broken fixtures must still be caught by the rules that guard them — that second one is what fails when the checks themselves stop working, which is otherwise silent. The other two report: an **adoption report** in the run Summary (how many `webkit/*` violations, which rules, which files, and what the check did *not* look at) and stylelint over the stylesheets. `Webkit gate` is the single check that aggregates them. + +The adoption report never blocks on its own. It exists so the distance between this codebase and the design system is a number someone can watch, instead of something noticed in review. It becomes a ratchet — failing only on violations a PR *introduces* — once `.webkit-baseline.json` is committed. Weekly: a link check crawls the built site for broken internal links and opens an issue when it finds them. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 9f2d182ec7..3ce4c80a6c 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -41,13 +41,3 @@ jobs: run: pnpm build:local env: NODE_OPTIONS: --max-old-space-size=8120 - - # Design-system adoption: measured, never enforced. The report lands in the run - # Summary; `if: always()` so it still appears when a step above fails, and - # `continue-on-error` so a red number never blocks the PR. - - name: Webkit adoption report - if: always() - continue-on-error: true - run: | - pnpm lint:webkit - pnpm --silent report:webkit-adoption >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/webkit-gate.yml b/.github/workflows/webkit-gate.yml new file mode 100644 index 0000000000..bb2f315295 --- /dev/null +++ b/.github/workflows/webkit-gate.yml @@ -0,0 +1,150 @@ +name: Webkit gate + +# The design-system gate, in four stages that are deliberately not equally strict. +# +# wiring — the toolkit is wired (catalog resolves, lint configs exist, MCP registered, +# nothing pinned to `latest`). Reports for now; see the note on the job. +# canary — fixtures that violate a rule on purpose must still be flagged by that exact +# rule. BLOCKS: it is the only stage that fails when the measurement itself +# stops working, and every way of losing the rules is otherwise silent. +# adoption — the score and the tables, written to the run Summary. Reports. Flip it to a +# ratchet by adding `--fail-on new` once .webkit-baseline.json is committed. +# style — the shipped stylelint config over CSS/SCSS/Vue/Astro. Reports. +# +# `Webkit gate` is the one check to mark required: it passes when every stage succeeded or +# was cleanly skipped. +# +# These stages run local scripts because the equivalent commands (`webkit report`, +# `webkit canary`) are being added to @aziontech/webkit itself — aziontech/webkit#964 — and +# only reach a consumer through an npm release. When that release lands, this whole file is +# replaced by one `uses:` of the design system's reusable workflow. + +on: + pull_request: + branches: + - main + - release/new-azion-docs + types: [opened, synchronize, reopened] + +concurrency: + group: webkit-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + wiring: + name: Wiring + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + + # Not blocking yet, and for a specific reason: `webkit doctor` decides whether webkit + # is registered with Tailwind by looking only at `src/webkit.css`. This project does + # it correctly in `src/styles/main.css`, so the check reports a false FAIL. It becomes + # blocking as soon as the doctor reads the project's real CSS entry. + - name: Check the toolkit wiring + continue-on-error: true + run: pnpm doctor:webkit + + canary: + name: Canary + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + + # Blocking. If this goes red the adoption number below is meaningless, not merely + # worse — the rules are not reaching the code at all. + - name: The rules still reach this project + run: pnpm canary:webkit + + adoption: + name: Adoption report + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Measure and write the report + if: always() + run: pnpm --silent report:webkit-adoption >> "$GITHUB_STEP_SUMMARY" + + - name: Keep the numbers as an artifact + if: always() + run: pnpm --silent report:webkit-adoption --format json > webkit-adoption.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: webkit-adoption + path: webkit-adoption.json + if-no-files-found: warn + + style: + name: Tokens in CSS + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Lint the stylesheets + continue-on-error: true + run: pnpm lint:style + + webkit-gate: + name: Webkit gate + needs: [wiring, canary, adoption, style] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # `skipped` counts as OK, so a stage can be disabled without changing which check is + # required. + - name: Check every stage passed or was skipped + run: | + declare -A results=( + [wiring]="${{ needs.wiring.result }}" + [canary]="${{ needs.canary.result }}" + [adoption]="${{ needs.adoption.result }}" + [style]="${{ needs.style.result }}" + ) + failed=0 + for stage in "${!results[@]}"; do + result="${results[$stage]}" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "FAIL $stage: $result" + failed=1 + else + echo "ok $stage: $result" + fi + done + if [[ $failed -eq 1 ]]; then + echo "The webkit gate did not pass. The adoption stage's Summary says where the project stands." + exit 1 + fi + echo "Webkit gate passed." diff --git a/package.json b/package.json index 4f7ab9a094..91e6ebebac 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,9 @@ "nav:retarget": "tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/nav/retarget-links.ts", "lint:eslint": "eslint .", "lint:style": "stylelint \"src/**/*.{css,scss,vue,astro}\"", - "lint:webkit": "eslint . -f json -o .eslint-report.json || true", - "report:webkit-adoption": "node scripts/webkit-adoption-report.mjs .eslint-report.json", + "report:webkit-adoption": "node scripts/webkit-adoption-report.mjs", + "canary:webkit": "node scripts/webkit-canary.mjs", + "doctor:webkit": "webkit doctor", "translation-status": "tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/translation-status.ts", "cp-doc-helpcenter": "cp -R ../docs_help_center/* src/includes/help_center/en/", "test:frontmatter": "node test-frontmatter.js" diff --git a/scripts/lib/webkit-lint.mjs b/scripts/lib/webkit-lint.mjs new file mode 100644 index 0000000000..1b4bbfc14b --- /dev/null +++ b/scripts/lib/webkit-lint.mjs @@ -0,0 +1,83 @@ +// Shared plumbing for the two webkit gate scripts. +// +// These scripts are a local stand-in for `webkit report` / `webkit canary`, which are being +// added to @aziontech/webkit itself (aziontech/webkit#964). They live here because the +// commands only reach a consumer through an npm release, and this repo needed the gate +// before that release. When the published package carries them, both scripts and this +// module go away and the CI calls the design system's own reusable workflow instead. + +import { spawnSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' + +/** Extensions the design system governs — the denominator of the adoption score. */ +export const UI_EXTENSIONS = ['vue', 'astro'] + +/** + * Run ESLint through this project's `node_modules/.bin/eslint` **shim**, never the Node API + * and never the resolved bin file. + * + * Measured here on 2026-09-10 with eslint 9.39.5, all three on the same files: + * + * new ESLint().lintFiles(['.']) → 0 findings in .astro (fatal parse error) + * node node_modules/…/eslint/bin/eslint.js → 0 findings in .astro (same failure) + * node_modules/.bin/eslint → 3 findings in .astro (correct) + * + * pnpm's shim exports NODE_PATH into its .pnpm directories before exec'ing node; without it + * ESLint cannot resolve astro-eslint-parser and silently falls back to the default parser. + * All three paths *succeed* — two just report a smaller number. A gate cannot be quietly + * wrong, so it uses the same entry point `pnpm lint:eslint` does. + */ +export function runESLint(cwd, patterns, extraArgs = []) { + const shim = join( + cwd, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'eslint.cmd' : 'eslint' + ) + if (!existsSync(shim)) { + return { ok: false, reason: 'no node_modules/.bin/eslint — run pnpm install first' } + } + + const proc = spawnSync( + shim, + [...patterns, '--format', 'json', '--no-error-on-unmatched-pattern', ...extraArgs], + { cwd, encoding: 'utf-8', maxBuffer: 256 * 1024 * 1024 } + ) + if (proc.error) return { ok: false, reason: proc.error.message } + + // ESLint exits 1 when it finds errors, which is the normal case here. Only output that + // is not JSON means the run itself failed. + let results + try { + results = JSON.parse(proc.stdout) + } catch { + const detail = (proc.stderr || proc.stdout || '').trim().split('\n').slice(0, 6).join('\n') + return { ok: false, reason: `ESLint produced no JSON report (exit ${proc.status}).\n${detail}` } + } + if (!Array.isArray(results)) return { ok: false, reason: 'the ESLint report was not an array' } + return { ok: true, results } +} + +/** + * The installed catalog is what the rules validate against. When it does not resolve the + * plugin disables eight of its twelve rules with a single stderr line — so "clean" and + * "blind" look identical unless something checks for it. + */ +export function readCatalog(cwd) { + try { + const require = createRequire(join(cwd, '__webkit__.js')) + const path = require.resolve('@aziontech/webkit/catalog.json') + const catalog = JSON.parse(readFileSync(path, 'utf-8')) + return { available: true, version: catalog.webkitVersion ?? null } + } catch { + return { available: false, version: null } + } +} + +export function extensionOf(path) { + const base = path.slice(path.lastIndexOf('/') + 1) + const dot = base.lastIndexOf('.') + return dot === -1 ? '' : base.slice(dot + 1) +} diff --git a/scripts/webkit-adoption-report.mjs b/scripts/webkit-adoption-report.mjs index 49a333e42d..ba2093ffc1 100644 --- a/scripts/webkit-adoption-report.mjs +++ b/scripts/webkit-adoption-report.mjs @@ -1,245 +1,320 @@ #!/usr/bin/env node -// Aggregates an ESLint JSON report into a design-system adoption report. +// Measures how much of this project's UI is actually the design system. // -// Reads only `webkit/*` rule results — everything else in the ESLint output belongs to -// other concerns. Markdown goes to stdout so the caller can redirect it straight into -// $GITHUB_STEP_SUMMARY; progress and warnings go to stderr. +// node scripts/webkit-adoption-report.mjs Markdown to stdout +// node scripts/webkit-adoption-report.mjs --format json the same numbers, for a machine +// node scripts/webkit-adoption-report.mjs --update snapshot the baseline +// node scripts/webkit-adoption-report.mjs --fail-on new fail only on new violations // -// node scripts/webkit-adoption-report.mjs .eslint-report.json [--format markdown|json] -// -// This is a stopgap: the design system is building a `webkit report` command that will -// own this measurement for every consumer. When it lands, the CI step calls that instead -// and this file goes away. +// Markdown goes to stdout and progress to stderr, so CI can redirect it straight into +// $GITHUB_STEP_SUMMARY. See scripts/lib/webkit-lint.mjs for why it shells out to the +// eslint shim, and for when this script goes away. -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' -const require = createRequire(import.meta.url); +import { extensionOf, readCatalog, runESLint, UI_EXTENSIONS } from './lib/webkit-lint.mjs' -/** Extensions the design system governs — the denominator of the adoption score. */ -const UI_EXTENSIONS = new Set(['vue', 'astro']); +const BASELINE = '.webkit-baseline.json' +const FAIL_MODES = new Set(['never', 'new', 'any']) const RULE_PURPOSE = { - 'webkit/valid-import-path': 'import path that does not exist in the installed version', - 'webkit/no-deep-internal-import': 'reaches into `@aziontech/webkit/src/` internals', - 'webkit/no-barrel-import': 'bare-package barrel import (the package has no barrel)', - 'webkit/no-whole-icon-set-import': 'pulls the whole icon set instead of one icon', - 'webkit/no-hardcoded-color': 'hardcoded colour, palette class or raw text size', - 'webkit/no-hardcoded-motion': 'literal duration/easing, or motion without `motion-reduce:`', - 'webkit/prefer-tree-shakeable-root': 'compound entry imported where the root would do', - 'webkit/no-deprecated-component': 'component marked deprecated in the catalog', - 'webkit/prefer-webkit-component': 'foreign UI library where webkit has an equivalent', - 'webkit/prefer-define-model': 'hand-rolled `modelValue` + `update:modelValue` pair', - 'webkit/no-style-override': '`class`/`style` on a webkit component — restyling it', - 'webkit/authoring-standards': 'shared authoring standards (typed slots, comments, …)', -}; - -function parseArgs(argv) { - const args = argv.slice(2); - const file = args.find((a) => !a.startsWith('--')); - const formatIndex = args.indexOf('--format'); - const format = formatIndex === -1 ? 'markdown' : args[formatIndex + 1]; - if (!file) { - process.stderr.write( - 'usage: node scripts/webkit-adoption-report.mjs [--format markdown|json]\n', - ); - process.exit(1); - } - if (format !== 'markdown' && format !== 'json') { - process.stderr.write(`unknown --format "${format}" (expected markdown or json)\n`); - process.exit(1); - } - return { file, format }; + 'valid-import-path': 'import path that does not exist in the installed version', + 'no-deep-internal-import': 'reaches into the package internals instead of a published entry', + 'no-barrel-import': 'bare-package barrel import (there is no barrel entry)', + 'no-whole-icon-set-import': 'pulls the whole icon set instead of one icon', + 'no-hardcoded-color': 'hardcoded colour, palette class or raw text size', + 'no-hardcoded-motion': 'literal duration/easing, or motion with no reduced-motion escape', + 'prefer-tree-shakeable-root': 'compound entry imported where the root would do', + 'no-deprecated-component': 'component marked deprecated in the catalog', + 'prefer-webkit-component': 'foreign UI library where a webkit component exists', + 'prefer-define-model': 'hand-rolled modelValue + update:modelValue pair', + 'no-style-override': 'class/style on a webkit component — restyling it', + 'authoring-standards': 'shared authoring standards (typed slots, comments, deprecation)' } -/** The installed catalog is what the rules validate against — name the version we measured. */ -function readCatalog() { - try { - const path = require.resolve('@aziontech/webkit/catalog.json'); - const catalog = JSON.parse(readFileSync(path, 'utf-8')); - return { available: true, version: catalog.webkitVersion ?? null }; - } catch { - return { available: false, version: null }; - } -} +const bare = (rule) => rule.replace(/^webkit\//, '') -function extensionOf(filePath) { - const base = filePath.split('/').pop() ?? ''; - const dot = base.lastIndexOf('.'); - return dot === -1 ? '' : base.slice(dot + 1); +function parseArgs(argv) { + const args = argv.slice(2) + const valueOf = (flag, fallback) => { + const i = args.indexOf(flag) + return i === -1 ? fallback : args[i + 1] + } + const format = valueOf('--format', 'markdown') + const failOn = valueOf('--fail-on', 'never') + if (format !== 'markdown' && format !== 'json') { + process.stderr.write(`--format must be markdown or json (got "${format}")\n`) + process.exit(1) + } + if (!FAIL_MODES.has(failOn)) { + process.stderr.write(`--fail-on must be never, new or any (got "${failOn}")\n`) + process.exit(1) + } + return { format, failOn, update: args.includes('--update'), baseline: valueOf('--baseline', BASELINE) } } +/** Aggregate the ESLint results, keeping only `webkit/*`. */ function collect(results, cwd) { - const byRule = new Map(); - const byFile = new Map(); - const byExtension = new Map(); - const uiFiles = new Set(); - let total = 0; - - for (const result of results) { - const relative = result.filePath.startsWith(cwd) - ? result.filePath.slice(cwd.length + 1) - : result.filePath; - const extension = extensionOf(relative); - if (UI_EXTENSIONS.has(extension)) uiFiles.add(relative); - - for (const message of result.messages) { - if (!message.ruleId?.startsWith('webkit/')) continue; - total += 1; - byRule.set(message.ruleId, (byRule.get(message.ruleId) ?? 0) + 1); - byExtension.set(extension, (byExtension.get(extension) ?? 0) + 1); - const entry = byFile.get(relative) ?? { count: 0, rules: new Set() }; - entry.count += 1; - entry.rules.add(message.ruleId); - byFile.set(relative, entry); - } - } - - const dirtyUiFiles = [...byFile.keys()].filter((f) => UI_EXTENSIONS.has(extensionOf(f))); - // Same shape as the architecture report in azion-console-kit: share of files that are - // clean, not of violations. One file with 20 findings weighs the same as one with 1. - const score = - uiFiles.size === 0 ? 100 : Math.round((1 - dirtyUiFiles.length / uiFiles.size) * 100); - - return { - total, - filesAffected: byFile.size, - uiFilesTotal: uiFiles.size, - uiFilesClean: uiFiles.size - dirtyUiFiles.length, - score, - byRule: [...byRule.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), - byExtension: [...byExtension.entries()].sort((a, b) => b[1] - a[1]), - byFile: [...byFile.entries()] - .map(([file, entry]) => ({ file, count: entry.count, rules: [...entry.rules].sort() })) - .sort((a, b) => b.count - a.count || a.file.localeCompare(b.file)), - }; + const byRule = new Map() + const byFile = new Map() + const byExtension = new Map() + const uiFiles = new Set() + const keys = [] + + for (const result of results) { + const path = relative(cwd, result.filePath) || result.filePath + const extension = extensionOf(path) + if (UI_EXTENSIONS.includes(extension)) uiFiles.add(path) + + for (const message of result.messages) { + // A fatal parse error carries a null ruleId. It is not a violation, and the file was + // not measured at all — counting it would inflate the number and hide the breakage. + const rule = message.ruleId + if (!rule || !rule.startsWith('webkit/')) continue + keys.push(`${path}::${rule}`) + byRule.set(rule, (byRule.get(rule) ?? 0) + 1) + byExtension.set(extension, (byExtension.get(extension) ?? 0) + 1) + const entry = byFile.get(path) ?? { count: 0, rules: new Set() } + entry.count += 1 + entry.rules.add(rule) + byFile.set(path, entry) + } + } + + const dirtyUi = [...byFile.keys()].filter((f) => UI_EXTENSIONS.includes(extensionOf(f))) + // Share of clean UI files, not of violations: a file counts once however many findings it + // has, so the number moves when a file is finished rather than when the cheap ones go. + const score = uiFiles.size === 0 ? 100 : Math.round((1 - dirtyUi.length / uiFiles.size) * 100) + + return { + total: keys.length, + keys, + filesAffected: byFile.size, + uiFilesTotal: uiFiles.size, + uiFilesClean: uiFiles.size - dirtyUi.length, + score, + byRule: [...byRule.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])), + byExtension: [...byExtension.entries()].sort((a, b) => b[1] - a[1]), + byFile: [...byFile.entries()] + .map(([file, entry]) => ({ file, count: entry.count, rules: [...entry.rules].sort() })) + .sort((a, b) => b.count - a.count || a.file.localeCompare(b.file)) + } } -function statusFor(score) { - if (score === 100) return 'every UI file clean'; - if (score >= 90) return 'close'; - if (score >= 75) return 'needs work'; - return 'far from it'; +/** + * Multiset diff: keys repeat, so a SECOND violation of an already-baselined rule in the + * same file counts as introduced. A plain Set would let it through. + */ +function diffBaseline(current, baseline) { + const counts = (list) => { + const map = new Map() + for (const key of list) map.set(key, (map.get(key) ?? 0) + 1) + return map + } + const now = counts(current) + const before = counts(baseline) + + const introduced = [] + for (const [key, n] of now) { + for (let i = 0; i < n - (before.get(key) ?? 0); i++) introduced.push(key) + } + const fixed = [] + for (const [key, n] of before) { + for (let i = 0; i < n - (now.get(key) ?? 0); i++) fixed.push(key) + } + return { introduced: introduced.sort(), fixed: fixed.sort() } } -function renderMarkdown(report, catalog) { - const lines = []; - const push = (line = '') => lines.push(line); - - push('## Webkit adoption'); - push(); - - if (!catalog.available) { - push( - '> **The webkit catalog could not be resolved, so 8 of the 12 rules silently did nothing.** ' + - 'This report is not a clean bill of health — install `@aziontech/webkit` or set ' + - '`WEBKIT_CATALOG_PATH`, then run it again.', - ); - push(); - } - - const version = catalog.version ? `\`@aziontech/webkit@${catalog.version}\`` : 'unknown version'; - push(`Measured against ${version}.`); - push(); - push('| | |'); - push('|---|---|'); - push(`| Violations | **${report.total}** |`); - push(`| Files affected | ${report.filesAffected} |`); - push( - `| UI files clean | ${report.uiFilesClean} of ${report.uiFilesTotal} — **${report.score}%** (${statusFor(report.score)}) |`, - ); - push(); - - if (report.total === 0) { - push('No `webkit/*` violations. Read the coverage note below before celebrating.'); - } else { - push('### By rule'); - push(); - push('| Rule | Count | What it catches |'); - push('|---|---:|---|'); - for (const [rule, count] of report.byRule) { - const purpose = RULE_PURPOSE[rule] ?? '—'; - push(`| \`${rule.replace('webkit/', '')}\` | ${count} | ${purpose} |`); - } - push(); - - push('### By file'); - push(); - push('| File | Count | Rules |'); - push('|---|---:|---|'); - const shown = report.byFile.slice(0, 15); - for (const { file, count, rules } of shown) { - const ruleList = rules.map((r) => `\`${r.replace('webkit/', '')}\``).join(', '); - push(`| \`${file}\` | ${count} | ${ruleList} |`); - } - if (report.byFile.length > shown.length) { - push(); - push(`_${report.byFile.length - shown.length} more file(s) not shown._`); - } - push(); - } - - push('### Coverage — what this did and did not look at'); - push(); - const extensions = report.byExtension.length - ? report.byExtension.map(([ext, n]) => `\`.${ext}\` (${n})`).join(', ') - : 'none'; - push(`- Violations found in: ${extensions}.`); - push( - `- The adoption score counts ${[...UI_EXTENSIONS].map((e) => `\`.${e}\``).join(' and ')} files only — ` + - 'those are the ones the design system governs.', - ); - push( - '- `no-style-override` does **not** run on `.astro`: it needs vue-eslint-parser\'s template ' + - 'visitor, which astro-eslint-parser does not provide. Restyled webkit components inside ' + - 'Astro files are invisible here.', - ); - push( - '- Raw HTML where a webkit component exists (`