diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 215eaf51..e027f11b 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -92,30 +92,31 @@ Override score, severity, or enable/disable individual rules: ### All Rule IDs + **Structure (9 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| -| `no-auto-layout` | -7 | blocking | -| `absolute-position-in-auto-layout` | -10 | blocking | -| `missing-responsive-behavior` | -4 | risk | +| `no-auto-layout` | -10 | blocking | +| `absolute-position-in-auto-layout` | -7 | blocking | +| `fixed-size-in-auto-layout` | -3 | risk | +| `missing-size-constraint` | -3 | risk | +| `missing-responsive-behavior` | -3 | risk | | `group-usage` | -5 | risk | -| `fixed-size-in-auto-layout` | -5 | risk | -| `missing-size-constraint` | -5 | risk | -| `unnecessary-node` | -2 | suggestion | -| `z-index-dependent-layout` | -5 | risk | | `deep-nesting` | -4 | risk | +| `z-index-dependent-layout` | -5 | risk | +| `unnecessary-node` | -2 | suggestion | **Token (7 rules)** | Rule ID | Default Score | Default Severity | |---------|--------------|-----------------| | `raw-color` | -2 | missing-info | -| `raw-font` | -8 | blocking | +| `raw-font` | -4 | risk | | `inconsistent-spacing` | -2 | missing-info | -| `magic-number-spacing` | -3 | missing-info | -| `raw-shadow` | -7 | risk | -| `raw-opacity` | -5 | risk | +| `magic-number-spacing` | -3 | risk | +| `raw-shadow` | -3 | missing-info | +| `raw-opacity` | -2 | missing-info | | `multiple-fill-colors` | -3 | missing-info | **Component (4 rules)** @@ -134,7 +135,7 @@ Override score, severity, or enable/disable individual rules: | `default-name` | -2 | missing-info | | `non-semantic-name` | -2 | missing-info | | `inconsistent-naming-convention` | -2 | missing-info | -| `numeric-suffix-name` | -2 | missing-info | +| `numeric-suffix-name` | -1 | suggestion | | `too-long-name` | -1 | suggestion | **Behavior (4 rules)** @@ -145,6 +146,7 @@ Override score, severity, or enable/disable individual rules: | `prototype-link-in-design` | -2 | missing-info | | `overflow-behavior-unknown` | -3 | missing-info | | `wrap-behavior-unknown` | -3 | missing-info | + ### Example Configs diff --git a/package.json b/package.json index 2e25dd78..68fa93d0 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:run": "vitest run", "lint": "tsc --noEmit", "build:plugin": "bash scripts/build-plugin.sh", + "sync-docs": "tsx scripts/sync-rule-docs.ts", "clean": "rm -rf dist" }, "files": [ @@ -69,6 +70,7 @@ "@types/pngjs": "^6.0.5", "playwright": "^1.58.2", "tsup": "^8.5.1", + "tsx": "^4.19.0", "typescript": "^5.9.3", "vitest": "^4.1.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fbf302d..6dd5b014 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,13 +44,16 @@ importers: version: 1.58.2 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + tsx: + specifier: ^4.19.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3)) + version: 4.1.0(@types/node@25.5.0)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) packages: @@ -784,6 +787,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1082,6 +1088,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.0.0-rc.10: resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1218,6 +1227,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -1651,13 +1665,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3))': + '@vitest/mocker@4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3) + vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.0': dependencies: @@ -1941,6 +1955,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + get-tsconfig@4.13.7: + dependencies: + resolve-pkg-maps: 1.0.0 + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -2121,11 +2139,12 @@ snapshots: pngjs@7.0.0: {} - postcss-load-config@6.0.1(postcss@8.5.8)(yaml@2.8.3): + postcss-load-config@6.0.1(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.8 + tsx: 4.21.0 yaml: 2.8.3 postcss@8.5.8: @@ -2158,6 +2177,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + rolldown@1.0.0-rc.10: dependencies: '@oxc-project/types': 0.120.0 @@ -2335,7 +2356,7 @@ snapshots: tslib@2.8.1: optional: true - tsup@8.5.1(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -2346,7 +2367,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.8)(yaml@2.8.3) + postcss-load-config: 6.0.1(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -2363,6 +2384,13 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.7 + optionalDependencies: + fsevents: 2.3.3 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -2379,7 +2407,7 @@ snapshots: vary@1.1.2: {} - vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3): + vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.3 @@ -2390,12 +2418,13 @@ snapshots: '@types/node': 25.5.0 esbuild: 0.27.4 fsevents: 2.3.3 + tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.0(@types/node@25.5.0)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3)): + vitest@4.1.0(@types/node@25.5.0)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3)) + '@vitest/mocker': 4.1.0(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -2412,7 +2441,7 @@ snapshots: tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(yaml@2.8.3) + vite: 8.0.1(@types/node@25.5.0)(esbuild@0.27.4)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 diff --git a/scripts/sync-rule-docs.ts b/scripts/sync-rule-docs.ts new file mode 100644 index 00000000..124abad8 --- /dev/null +++ b/scripts/sync-rule-docs.ts @@ -0,0 +1,85 @@ +/** + * Auto-generate rule tables in docs/REFERENCE.md from rule-config.ts + rule registry. + * + * Usage: npx tsx scripts/sync-rule-docs.ts + * + * Replaces content between RULE_TABLE_START and RULE_TABLE_END markers in REFERENCE.md. + */ + +// Import rules to populate registry +import "../src/core/rules/index.js"; +import { ruleRegistry } from "../src/core/rules/rule-registry.js"; +import { RULE_CONFIGS } from "../src/core/rules/rule-config.js"; +import { CATEGORIES } from "../src/core/contracts/category.js"; +import type { Category } from "../src/core/contracts/category.js"; +import type { RuleId } from "../src/core/contracts/rule.js"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REFERENCE_PATH = resolve(__dirname, "../docs/REFERENCE.md"); +const START_MARKER = ""; + +function generateTables(): string { + const lines: string[] = []; + + for (const category of CATEGORIES) { + const rules = ruleRegistry + .getByCategory(category as Category) + .map((r) => { + const id = r.definition.id as RuleId; + const config = RULE_CONFIGS[id]; + if (!config) { + throw new Error(`Missing RULE_CONFIGS entry for rule "${id}"`); + } + return { id, config }; + }); + + const label = category.charAt(0).toUpperCase() + category.slice(1); + lines.push(`**${label} (${rules.length} rules)**`); + lines.push(""); + lines.push("| Rule ID | Default Score | Default Severity |"); + lines.push("|---------|--------------|-----------------|"); + + for (const { id, config } of rules) { + lines.push(`| \`${id}\` | ${config.score} | ${config.severity} |`); + } + + lines.push(""); + } + + // Remove trailing empty line + if (lines.at(-1) === "") lines.pop(); + + return lines.join("\n"); +} + +const content = readFileSync(REFERENCE_PATH, "utf-8"); +const startIdx = content.indexOf(START_MARKER); +const endIdx = content.indexOf(END_MARKER); + +if (startIdx === -1 || endIdx === -1) { + console.error("RULE_TABLE markers not found in REFERENCE.md"); + process.exit(1); +} + +const before = content.slice(0, startIdx); +const after = content.slice(endIdx + END_MARKER.length); +const tables = generateTables(); + +const updated = + before + + `${START_MARKER} — auto-generated by scripts/sync-rule-docs.ts, do not edit manually -->\n` + + tables + + "\n" + + END_MARKER + + after; + +if (content === updated) { + console.log("docs/REFERENCE.md is already up to date."); +} else { + writeFileSync(REFERENCE_PATH, updated, "utf-8"); + console.log("docs/REFERENCE.md updated."); +} diff --git a/src/core/contracts/rule.ts b/src/core/contracts/rule.ts index 7eda902c..6ee79a9b 100644 --- a/src/core/contracts/rule.ts +++ b/src/core/contracts/rule.ts @@ -90,7 +90,7 @@ export interface Rule { * Rule ID type for type safety */ export type RuleId = - // Structure (9) + // Structure | "no-auto-layout" | "absolute-position-in-auto-layout" | "fixed-size-in-auto-layout" @@ -100,7 +100,7 @@ export type RuleId = | "deep-nesting" | "z-index-dependent-layout" | "unnecessary-node" - // Token (7) + // Token | "raw-color" | "raw-font" | "inconsistent-spacing" @@ -108,18 +108,18 @@ export type RuleId = | "raw-shadow" | "raw-opacity" | "multiple-fill-colors" - // Component (4) + // Component | "missing-component" | "detached-instance" | "missing-component-description" | "variant-structure-mismatch" - // Naming (5) + // Naming | "default-name" | "non-semantic-name" | "inconsistent-naming-convention" | "numeric-suffix-name" | "too-long-name" - // Behavior (4) + // Behavior | "text-truncation-unhandled" | "prototype-link-in-design" | "overflow-behavior-unknown" diff --git a/src/core/engine/scoring.test.ts b/src/core/engine/scoring.test.ts index cee9eea2..26e8ddc4 100644 --- a/src/core/engine/scoring.test.ts +++ b/src/core/engine/scoring.test.ts @@ -272,7 +272,7 @@ describe("calculateGrade (via calculateScores)", () => { const categories: Category[] = ["structure", "token", "component", "naming", "behavior"]; const rulesPerCat: Record = { structure: ["no-auto-layout", "group-usage", "deep-nesting", "fixed-size-in-auto-layout", "missing-responsive-behavior", "absolute-position-in-auto-layout", "missing-size-constraint", "z-index-dependent-layout", "unnecessary-node"], - token: ["raw-color", "raw-font", "inconsistent-spacing", "magic-number-spacing", "raw-shadow"], + token: ["raw-color", "raw-font", "inconsistent-spacing", "magic-number-spacing", "raw-shadow", "raw-opacity", "multiple-fill-colors"], component: ["missing-component", "detached-instance", "missing-component-description", "variant-structure-mismatch"], naming: ["default-name", "non-semantic-name", "inconsistent-naming-convention", "numeric-suffix-name", "too-long-name"], behavior: ["text-truncation-unhandled", "prototype-link-in-design", "overflow-behavior-unknown", "wrap-behavior-unknown"], diff --git a/src/core/rules/index.ts b/src/core/rules/index.ts index ee73fc16..9527d0c5 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -4,17 +4,9 @@ export * from "./rule-registry.js"; export * from "./rule-config.js"; -// Structure rules (9) +// Rule definitions (auto-register via defineRule on import) export * from "./structure/index.js"; - -// Token rules (7) export * from "./token/index.js"; - -// Component rules (4) export * from "./component/index.js"; - -// Naming rules (5) export * from "./naming/index.js"; - -// Behavior rules (4) export * from "./behavior/index.js"; diff --git a/src/core/rules/rule-config.test.ts b/src/core/rules/rule-config.test.ts new file mode 100644 index 00000000..d80bae89 --- /dev/null +++ b/src/core/rules/rule-config.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { RULE_CONFIGS } from "./rule-config.js"; +import { ruleRegistry } from "./rule-registry.js"; +import type { RuleId } from "../contracts/rule.js"; + +// Import all rules to populate registry +import "./index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REFERENCE_PATH = resolve(__dirname, "../../../docs/REFERENCE.md"); + +describe("rule-config sync", () => { + describe("REFERENCE.md matches rule-config.ts", () => { + const content = readFileSync(REFERENCE_PATH, "utf-8"); + + // Parse only the auto-generated rule table block between markers + const tableStart = content.indexOf(""); + if (tableStart === -1 || tableEnd === -1 || tableEnd <= tableStart) { + throw new Error("REFERENCE.md rule table markers are missing or misordered"); + } + const tableContent = content.slice(tableStart, tableEnd); + + const tableRows = [...tableContent.matchAll(/\| `([^`]+)` \| (-?\d+) \| ([a-z-]+) \|/g)]; + const docRules = new Map( + tableRows + .filter((m) => m[1] !== undefined && m[2] !== undefined && m[3] !== undefined) + .map((m) => [m[1], { score: Number(m[2]), severity: m[3] }]) + ); + + for (const [id, config] of Object.entries(RULE_CONFIGS)) { + it(`${id}: score matches`, () => { + const doc = docRules.get(id); + expect(doc).toBeDefined(); + expect(doc!.score).toBe(config.score); + }); + + it(`${id}: severity matches`, () => { + const doc = docRules.get(id); + expect(doc).toBeDefined(); + expect(doc!.severity).toBe(config.severity); + }); + } + + it("REFERENCE.md has no extra rules beyond rule-config.ts", () => { + const configIds = new Set(Object.keys(RULE_CONFIGS)); + for (const docId of docRules.keys()) { + expect(configIds.has(docId)).toBe(true); + } + }); + + it("REFERENCE.md has all rules from rule-config.ts", () => { + for (const id of Object.keys(RULE_CONFIGS)) { + expect(docRules.has(id)).toBe(true); + } + }); + }); + + describe("rule registry covers all rule-config.ts entries", () => { + it("every RULE_CONFIGS entry has a registered rule", () => { + for (const id of Object.keys(RULE_CONFIGS)) { + expect(ruleRegistry.has(id as RuleId)).toBe(true); + } + }); + + it("every registered rule has a RULE_CONFIGS entry", () => { + for (const rule of ruleRegistry.getAll()) { + expect(RULE_CONFIGS[rule.definition.id as RuleId]).toBeDefined(); + } + }); + }); + + describe("rules/index.ts has no stale count comments", () => { + const indexContent = readFileSync( + resolve(import.meta.dirname, "./index.ts"), + "utf-8" + ); + + it("no hardcoded rule count comments exist", () => { + const countPattern = /rules.*\(\d+\)/i; + expect(countPattern.test(indexContent)).toBe(false); + }); + }); +}); diff --git a/src/core/rules/rule-config.ts b/src/core/rules/rule-config.ts index a60aaa27..f6d0c2a2 100644 --- a/src/core/rules/rule-config.ts +++ b/src/core/rules/rule-config.ts @@ -5,9 +5,7 @@ import type { RuleConfig, RuleId } from "../contracts/rule.js"; * Edit scores/severity here without touching rule logic */ export const RULE_CONFIGS: Record = { - // ============================================ - // Structure (9 rules) - // ============================================ + // ── Structure ── "no-auto-layout": { severity: "blocking", score: -10, @@ -65,9 +63,7 @@ export const RULE_CONFIGS: Record = { }, }, - // ============================================ - // Token (7 rules) - // ============================================ + // ── Token ── "raw-color": { severity: "missing-info", score: -2, @@ -113,9 +109,7 @@ export const RULE_CONFIGS: Record = { }, }, - // ============================================ - // Component (4 rules) - // ============================================ + // ── Component ── "missing-component": { severity: "risk", score: -7, @@ -142,9 +136,7 @@ export const RULE_CONFIGS: Record = { enabled: true, }, - // ============================================ - // Naming (5 rules) - // ============================================ + // ── Naming ── "default-name": { severity: "missing-info", score: -2, @@ -174,9 +166,7 @@ export const RULE_CONFIGS: Record = { }, }, - // ============================================ - // Behavior (4 rules) - // ============================================ + // ── Behavior ── "text-truncation-unhandled": { severity: "risk", score: -5,