import { describe, expect, it } from "vitest"; import { componentId as makeComponentId, defineFragment, FactIndex, compileComponentFacts, compileGlobalGovernanceFacts, g, makeClassNameDynamicFact, makeStyleDeclarationFact, makeTokenDefinitionFact, makeUsageComponentFact, makeUsageImportFact, makeUsageInlineStyleFact, makeUsageNodeFact, makeUsagePropResolvedFact, makeUsageTextChildFact, ruleA11yRequiredAccessibleName, ruleComponentsForbiddenPropValue, ruleComponentsPreferLibrary, ruleComponentsUnknownProp, ruleJsxPreferredComponent, ruleJsxPreferredImportPath, rulePropsInvalidValue, ruleStylesNoRawColor, ruleStylesNoRawDimensions, ruleStylesNoRawSpacing, ruleStylesNoRawTypography, ruleThemeNoThemeCoupledLiteral, ruleTokensRequireDualFallback, runRules, } from "../index.js"; import type { Fact, FactId } from "../index.js"; type ButtonProps = { variant?: "primary" | "secondary" | "ghost" | "link"; size?: "sm" | "md" | "lg"; disabled?: boolean; }; function Button(_props: ButtonProps) { return null; } const buttonId = makeComponentId("@fragments-sdk/ui", "Button"); function buildButtonFragment() { return defineFragment({ component: Button, meta: { name: "Button", description: "Interactive element for user-triggered actions.", category: "forms", }, guidance: { when: ["Submitting forms"], whenNot: ["Simple navigation without side effects"], }, props: { variant: { type: "enum", values: ["primary", "secondary", "ghost", "link"], required: false, }, size: { type: "enum", values: ["sm", "md", "lg"], required: false }, disabled: { type: "boolean", required: false }, }, govern: (govern) => [ govern.capability("dom.button"), govern.prop("variant").forbid("secondary", { when: { path: "apps/checkout/**" }, because: "Checkout CTAs should stay visually dominant.", fix: { replaceWith: "primary" }, severity: "error", }), govern.accessibility().requireName({ because: "Buttons must announce their action.", severity: "error", }), ], }); } function buildBaseConfigFacts() { return compileGlobalGovernanceFacts({ scales: { space: g.scale.px([0, 4, 8, 12, 16, 20, 24, 32]), }, styles: [ g.styles.rawColors().forbid({ except: ["transparent"], prefer: "token", severity: "error", }), g.styles.rawSpacing().mustMatchScale("space", { appliesTo: ["padding", "margin", "gap"], severity: "error", }), ], jsx: [g.jsx.unknownProps().forbid({ severity: "error" })], }); } function emptyIndexWithGovernance(): FactIndex { const ix = new FactIndex(); ix.addMany(buildBaseConfigFacts()); ix.addMany(compileComponentFacts(buttonId, buildButtonFragment())); return ix; } function indexWithComponentUsageGovernance(): FactIndex { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ rules: { "components/usage": { enabled: true, severity: "error" } }, }) ); ix.addMany(compileComponentFacts(buttonId, buildButtonFragment())); return ix; } interface ButtonUsageInput { file: string; nodePath: string; line: number; column: number; props?: Array<{ prop: string; resolution?: "static" | "dynamic" | "spread"; value?: unknown; }>; inlineStyles?: Array<{ property: string; valueKind: "static" | "number" | "css-variable"; value: string; }>; text?: string; } function addButtonUsage(ix: FactIndex, input: ButtonUsageInput): { nodeId: FactId } { const node = makeUsageNodeFact({ file: input.file, nodePath: input.nodePath, element: "Button", location: { file: input.file, line: input.line, column: input.column, }, }); ix.add(node); ix.add(makeUsageComponentFact({ nodeId: node.id, componentId: buttonId })); for (const prop of input.props ?? []) { ix.add( makeUsagePropResolvedFact({ nodeId: node.id, prop: prop.prop, resolution: prop.resolution ?? "static", value: prop.value, }) ); } for (const inline of input.inlineStyles ?? []) { ix.add( makeUsageInlineStyleFact({ nodeId: node.id, property: inline.property, valueKind: inline.valueKind, value: inline.value, }) ); } if (input.text !== undefined) { ix.add(makeUsageTextChildFact({ nodeId: node.id, text: input.text, index: 0 })); } return { nodeId: node.id }; } // --------------------------------------------------------------------------- // components/unknown-prop // --------------------------------------------------------------------------- describe("components/unknown-prop", () => { it("fires for an unknown prop with the prop's component location", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/marketing/page.tsx", nodePath: "0/0", line: 12, column: 4, props: [ { prop: "variant", value: "primary" }, { prop: "wibble", value: "yes" }, ], text: "Continue", }); const findings = ruleComponentsUnknownProp(ix); expect(findings).toHaveLength(1); expect(findings[0].ruleId).toBe("components/unknown-prop"); expect(findings[0].severity).toBe("serious"); expect(findings[0].location).toMatchObject({ file: "apps/marketing/page.tsx", line: 12, column: 4, }); expect(findings[0].attributes).toMatchObject({ prop: "wibble" }); expect(findings[0].evidence.length).toBeGreaterThan(0); }); it("does not fire for universal props (aria-*, data-*, on*, key/ref/className/style)", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/marketing/page.tsx", nodePath: "0", line: 1, column: 0, props: [ { prop: "aria-label", value: "Save" }, { prop: "data-testid", value: "save-btn" }, { prop: "onClick", resolution: "dynamic" }, { prop: "className", value: "x" }, { prop: "style", resolution: "dynamic" }, { prop: "key", value: "k" }, ], }); expect(ruleComponentsUnknownProp(ix)).toHaveLength(0); }); it("does not fire when the global jsx.unknownProps policy is absent", () => { const ix = new FactIndex(); ix.addMany(compileComponentFacts(buttonId, buildButtonFragment())); addButtonUsage(ix, { file: "apps/marketing/page.tsx", nodePath: "0", line: 1, column: 0, props: [{ prop: "wibble", value: "yes" }], }); expect(ruleComponentsUnknownProp(ix)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // components/forbidden-prop-value // --------------------------------------------------------------------------- describe("components/forbidden-prop-value", () => { it("fires only when the file matches the policy path glob", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, props: [{ prop: "variant", value: "secondary" }], text: "Continue", }); addButtonUsage(ix, { file: "apps/marketing/home.tsx", nodePath: "0/0", line: 10, column: 4, props: [{ prop: "variant", value: "secondary" }], text: "Learn more", }); const findings = ruleComponentsForbiddenPropValue(ix); expect(findings).toHaveLength(1); expect(findings[0].location.file).toBe("apps/checkout/page.tsx"); expect(findings[0].severity).toBe("serious"); }); it("attaches a deterministic replacePropValue fix from policy.replaceWith", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, props: [{ prop: "variant", value: "secondary" }], text: "Continue", }); const findings = ruleComponentsForbiddenPropValue(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toMatchObject({ kind: "replacePropValue", prop: "variant", value: "primary", deterministic: true, }); }); it("does not fire for dynamic prop values", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 1, column: 0, props: [{ prop: "variant", resolution: "dynamic" }], }); expect(ruleComponentsForbiddenPropValue(ix)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // props/invalid-value // --------------------------------------------------------------------------- describe("props/invalid-value", () => { it("fires for static enum values outside the component prop schema", () => { const ix = indexWithComponentUsageGovernance(); addButtonUsage(ix, { file: "apps/marketing/page.tsx", nodePath: "0/0", line: 12, column: 4, props: [{ prop: "variant", value: "danger" }], text: "Delete workspace", }); const findings = rulePropsInvalidValue(ix); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ ruleId: "props/invalid-value", code: "FUI6002", severity: "serious", location: { file: "apps/marketing/page.tsx", line: 12, column: 4, }, attributes: { componentId: buttonId, prop: "variant", rawValue: "danger", allowedValues: ["primary", "secondary", "ghost", "link"], }, }); expect(findings[0].fix).toBeUndefined(); expect(findings[0].evidence.length).toBeGreaterThan(0); }); it("skips dynamic, spread, unknown, and unconstrained props", () => { const ix = indexWithComponentUsageGovernance(); addButtonUsage(ix, { file: "apps/marketing/page.tsx", nodePath: "0/0", line: 12, column: 4, props: [ { prop: "variant", resolution: "dynamic" }, { prop: "size", resolution: "spread", value: "xxl" }, { prop: "wibble", value: "danger" }, { prop: "disabled", value: "definitely" }, ], }); expect(rulePropsInvalidValue(ix)).toHaveLength(0); }); it("does not fire unless the rule is enabled", () => { const ix = new FactIndex(); ix.addMany(compileComponentFacts(buttonId, buildButtonFragment())); addButtonUsage(ix, { file: "apps/marketing/page.tsx", nodePath: "0/0", line: 12, column: 4, props: [{ prop: "variant", value: "danger" }], }); expect(rulePropsInvalidValue(ix)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // imports/preferred-path and components/preferred-component // --------------------------------------------------------------------------- describe("preferred JSX imports and components", () => { it("emits a deterministic import replacement for configured source mappings", () => { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ jsx: [ g.jsx.importPath().prefer("@legacy/ui", "@fragments-sdk/ui", { severity: "error", because: "Use the canonical design-system package.", }), ], }) ); ix.add( makeUsageImportFact({ file: "apps/checkout/page.tsx", local: "Button", imported: "Button", source: "@legacy/ui", location: { file: "apps/checkout/page.tsx", line: 1, column: 0 }, }) ); const findings = ruleJsxPreferredImportPath(ix); expect(findings).toHaveLength(1); expect(findings[0].ruleId).toBe("imports/preferred-path"); expect(findings[0].fix).toMatchObject({ kind: "replaceImport", from: "@legacy/ui", to: "@fragments-sdk/ui", deterministic: true, }); expect(findings[0].attributes).toMatchObject({ importPath: "@legacy/ui", suggestedImport: "@fragments-sdk/ui", }); }); it("emits a deterministic component replacement for canonical component mappings", () => { const legacyButtonId = makeComponentId("@legacy/ui", "LegacyButton"); const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ jsx: [ g.jsx.component().prefer(legacyButtonId, buttonId, { severity: "warn", because: "Use the canonical Button component.", }), ], }) ); const node = makeUsageNodeFact({ file: "apps/checkout/page.tsx", nodePath: "0/0", element: "LegacyButton", location: { file: "apps/checkout/page.tsx", line: 4, column: 9 }, }); ix.add(node); ix.add(makeUsageComponentFact({ nodeId: node.id, componentId: legacyButtonId })); const findings = ruleJsxPreferredComponent(ix); expect(findings).toHaveLength(1); expect(findings[0].ruleId).toBe("components/preferred-component"); expect(findings[0].severity).toBe("moderate"); expect(findings[0].fix).toMatchObject({ kind: "replaceComponent", from: "LegacyButton", to: "Button", deterministic: true, }); }); }); // --------------------------------------------------------------------------- // styles/no-raw-color // --------------------------------------------------------------------------- describe("styles/no-raw-color", () => { it("fires for hex colors in inline JSX style", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, inlineStyles: [{ property: "color", valueKind: "static", value: "#2563eb" }], text: "x", }); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].attributes).toMatchObject({ source: "jsx", color: "#2563eb" }); }); it("fires for raw colors in SCSS declarations", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/Button.module.scss", selector: ".button", declarationPath: "0", property: "color", value: "#2563eb", location: { file: "libs/ui/Button.module.scss", line: 3, column: 2, }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].attributes).toMatchObject({ source: "css", color: "#2563eb" }); expect(findings[0].location.line).toBe(3); }); it("only exempts raw custom property definitions inside declared token source files", () => { const tokenIx = emptyIndexWithGovernance(); tokenIx.add( makeStyleDeclarationFact({ file: "src/styles/tokens.css", selector: ":root", declarationPath: "0", property: "--fui-color-accent", value: "#39594d", declaredTokenSource: true, location: { file: "src/styles/tokens.css", line: 1, column: 0 }, }) ); const productIx = emptyIndexWithGovernance(); productIx.add( makeStyleDeclarationFact({ file: "src/landing.scss", selector: ":root", declarationPath: "0", property: "--lp-accent", value: "#39594d", location: { file: "src/landing.scss", line: 1, column: 0 }, }) ); expect(ruleStylesNoRawColor(tokenIx)).toHaveLength(0); expect(ruleStylesNoRawColor(productIx)).toHaveLength(1); }); it("does not fire for var(--token) references", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "var(--fui-color-accent, #ff0000)", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); addButtonUsage(ix, { file: "apps/x.tsx", nodePath: "0", line: 1, column: 0, inlineStyles: [ { property: "color", valueKind: "css-variable", value: "var(--fui-color-accent)" }, ], text: "x", }); expect(ruleStylesNoRawColor(ix)).toHaveLength(0); }); it("does not flag a hex inside a var() fallback within a shorthand", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".bordered", declarationPath: "0", property: "border", value: "1px solid var(--color-text, #1a1a1a)", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); // The hex is the var() fallback, not a raw color — flagging it would both be // a false positive and fight the dual-fallback rule that mandates it. expect(ruleStylesNoRawColor(ix)).toHaveLength(0); }); it("does not fire for policy exemptions like 'transparent'", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "background", value: "transparent", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); expect(ruleStylesNoRawColor(ix)).toHaveLength(0); }); it("fires for rgb() and hsl() function syntax", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "background", value: "rgb(0, 0, 0)", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".y", declarationPath: "0", property: "background", value: "hsl(220, 100%, 50%)", location: { file: "libs/ui/x.scss", line: 5, column: 0 }, }) ); expect(ruleStylesNoRawColor(ix)).toHaveLength(2); }); it("finds colors embedded in border and shadow shorthands", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".border", declarationPath: "0", property: "border", value: "1px solid #ccc", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".shadow", declarationPath: "1", property: "box-shadow", value: "0 1px 2px rgb(0, 0, 0)", location: { file: "libs/ui/x.scss", line: 2, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(2); expect(findings.map((finding) => finding.attributes?.color)).toEqual(["#ccc", "rgb(0, 0, 0)"]); }); }); // --------------------------------------------------------------------------- // styles/no-raw-spacing // --------------------------------------------------------------------------- describe("styles/no-raw-spacing", () => { it("fires for off-scale pixel values, silent for on-scale values", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".bad", declarationPath: "0", property: "padding", value: "13px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".ok", declarationPath: "0", property: "padding", value: "12px", location: { file: "libs/ui/x.scss", line: 5, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].attributes).toMatchObject({ rawValue: "13px" }); }); it("checks every value in spacing shorthands", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".bad", declarationPath: "0", property: "padding", value: "4px 10px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".ok", declarationPath: "1", property: "padding", value: "4px 12px", location: { file: "libs/ui/x.scss", line: 2, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].attributes).toMatchObject({ rawValue: "4px 10px", suggestedValue: "4px 8px", }); }); it("treats inline numeric props as pixel values (padding: 13 → fires)", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, inlineStyles: [{ property: "padding", valueKind: "number", value: "13" }], text: "x", }); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].attributes).toMatchObject({ source: "jsx", rawValue: "13" }); }); it("normalizes rem values before checking a px spacing scale", () => { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ scales: { space: g.scale.px([0, 4, 8, 12, 16, 20, 24, 32, 80]), }, styles: [ g.styles.rawSpacing().mustMatchScale("space", { appliesTo: ["padding"], severity: "error", }), ], }) ); for (const [index, value] of [ "0.25rem", "0.5rem", "1rem", "1.5rem", "2rem", "5rem", ].entries()) { ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: `.rem-${index}`, declarationPath: String(index), property: "padding", value, location: { file: "libs/ui/x.scss", line: index + 1, column: 0 }, }) ); } expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0); }); it("normalizes em values for detection but does not autofix without an explicit em base", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".em", declarationPath: "0", property: "padding", value: "1.1em", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toBeUndefined(); expect(findings[0].attributes).toMatchObject({ rawValue: "1.1em", normalizedValue: 17.6, assumedEmBasePx: 16, suggestedValue: "16px", }); }); it("emits a (non-deterministic) rem snap suggestion when the root font size is explicit", () => { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ scales: { space: { kind: "scale", unit: "px", values: [0, 14, 28], rootFontSizePx: 14, }, }, styles: [ g.styles.rawSpacing().mustMatchScale("space", { appliesTo: ["padding"], severity: "error", }), ], }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".rem", declarationPath: "0", property: "padding", value: "1.1rem", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toMatchObject({ value: "14px", deterministic: false, }); expect(findings[0].attributes).toMatchObject({ suggestedValue: "14px", }); expect(Number(findings[0].attributes?.normalizedValue)).toBeCloseTo(15.4); }); it("does not fire for properties without a scale policy", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "border-radius", value: "13px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0); }); it("does not fire for var(--token) references", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "padding", value: "var(--fui-space-3)", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0); }); it("allows negative values whose magnitude is on the configured scale", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "margin", value: "-4px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); expect(ruleStylesNoRawSpacing(ix)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // a11y/required-accessible-name // --------------------------------------------------------------------------- describe("a11y/required-accessible-name", () => { it("does not fire when there is a non-empty text child", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/x.tsx", nodePath: "0", line: 1, column: 0, text: "Continue", }); expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0); }); it("does not fire with aria-label, aria-labelledby, or title", () => { const cases = [ { prop: "aria-label", value: "Save" }, { prop: "aria-labelledby", resolution: "dynamic" as const }, { prop: "title", value: "Save" }, ]; for (const propInput of cases) { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/x.tsx", nodePath: "0", line: 1, column: 0, props: [propInput], }); expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0); } }); it("fires for an empty Button with no name source", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/x.tsx", nodePath: "0", line: 7, column: 4, }); const findings = ruleA11yRequiredAccessibleName(ix); expect(findings).toHaveLength(1); expect(findings[0].location).toMatchObject({ line: 7, column: 4 }); }); it("does not fire for aria-hidden=true", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/x.tsx", nodePath: "0", line: 1, column: 0, props: [{ prop: "aria-hidden", value: true }], }); expect(ruleA11yRequiredAccessibleName(ix)).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // Phase 5 vertical slice — runRules over the canonical fixture // --------------------------------------------------------------------------- describe("runRules — Phase 5 vertical slice", () => { it("produces three findings for the canonical checkout fixture", () => { const ix = emptyIndexWithGovernance(); // Mirrors the brief's fixture: // addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, props: [{ prop: "variant", value: "secondary" }], inlineStyles: [ { property: "padding", valueKind: "number", value: "13" }, { property: "color", valueKind: "static", value: "#2563eb" }, ], text: "Continue", }); const findings = runRules(ix); const ruleIds = findings.map((f) => f.ruleId).sort(); expect(ruleIds).toEqual([ "components/forbidden-prop-value", "styles/no-raw-color", "styles/no-raw-spacing", ]); for (const finding of findings) { expect(finding.evidence.length).toBeGreaterThan(0); expect(finding.fingerprint).toMatch(/^[0-9a-f]{16}$/); } }); it("emits stable fingerprints across runs", () => { function buildIndex(): FactIndex { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, props: [{ prop: "variant", value: "secondary" }], inlineStyles: [ { property: "padding", valueKind: "number", value: "13" }, { property: "color", valueKind: "static", value: "#2563eb" }, ], text: "Continue", }); return ix; } const a = runRules(buildIndex()).map((f) => f.fingerprint); const b = runRules(buildIndex()).map((f) => f.fingerprint); expect(b).toEqual(a); }); it("emits the same fingerprint when the line number changes (fingerprint is content-addressed)", () => { function buildIndex(line: number): FactIndex { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line, column: 8, props: [{ prop: "variant", value: "secondary" }], text: "Continue", }); return ix; } const baseline = runRules(buildIndex(42)); const moved = runRules(buildIndex(99)); expect(moved.map((f) => f.fingerprint)).toEqual(baseline.map((f) => f.fingerprint)); expect(moved[0].location.line).toBe(99); expect(baseline[0].location.line).toBe(42); }); it("every finding's evidence is non-empty and resolvable in the index", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 1, column: 0, props: [{ prop: "variant", value: "secondary" }], inlineStyles: [{ property: "color", valueKind: "static", value: "#2563eb" }], }); const findings = runRules(ix); expect(findings.length).toBeGreaterThan(0); for (const finding of findings) { expect(finding.evidence.length).toBeGreaterThan(0); for (const evidence of finding.evidence) { expect(ix.has(evidence.factId)).toBe(true); } } }); }); // --------------------------------------------------------------------------- // Phase 6 — deterministic fix payloads on rules // --------------------------------------------------------------------------- describe("Phase 6 fix payloads", () => { it("styles/no-raw-spacing emits a nearest-scale replaceStyleValue fix for SCSS", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".bad", declarationPath: "0", property: "padding", value: "13px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); // Off-scale snaps change the rendered value, so the fix is a suggestion, // not a deterministic auto-apply (BUG 13 — no silent layout change). expect(findings[0].fix).toEqual({ kind: "replaceStyleValue", title: "Replace padding with 12px", property: "padding", value: "12px", deterministic: false, }); expect(findings[0].attributes).toMatchObject({ suggestedValue: "12px" }); }); it("styles/no-raw-spacing emits a bare-number fix for inline numeric props", () => { const ix = emptyIndexWithGovernance(); addButtonUsage(ix, { file: "apps/checkout/page.tsx", nodePath: "0/0", line: 42, column: 8, inlineStyles: [{ property: "padding", valueKind: "number", value: "13" }], text: "x", }); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toEqual({ kind: "replaceStyleValue", title: "Replace padding with 12", property: "padding", value: "12", deterministic: false, }); }); it("styles/no-raw-spacing flags but does NOT auto-snap a non-zero value to 0", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".tiny", declarationPath: "0", property: "padding", value: "2px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); // 2px's nearest scale value is 0 — snapping would silently remove the // padding, so no deterministic fix is offered. expect(findings[0].fix).toBeUndefined(); }); it("styles/no-raw-spacing snaps to the nearest allowed value (ties break low)", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".tie", declarationPath: "0", property: "margin", value: "10px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); // Scale is [0, 4, 8, 12, 16, 20, 24, 32]; 10 is equidistant from 8 and 12, // tie breaks low → 8px. expect(findings[0].fix).toMatchObject({ value: "8px" }); }); it("styles/no-raw-spacing preserves negative signs in nearest-scale fixes", () => { const ix = emptyIndexWithGovernance(); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".tie", declarationPath: "0", property: "margin", value: "-10px", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawSpacing(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toMatchObject({ value: "-8px" }); expect(findings[0].attributes).toMatchObject({ suggestedValue: "-8px" }); }); it("styles/no-raw-color emits a token-substitution fix when a color token matches", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color", }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#2563eb", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toEqual({ kind: "replaceStyleValue", title: "Replace color with var(--fui-color-accent)", property: "color", value: "var(--fui-color-accent)", deterministic: true, }); expect(findings[0].attributes).toMatchObject({ suggestedToken: "--fui-color-accent" }); }); it("styles/no-raw-color prefers a role-matching token when several share a value", () => { const ix = emptyIndexWithGovernance(); ix.addMany([ makeTokenDefinitionFact({ name: "--color-bg", value: "#ffffff", category: "color" }), makeTokenDefinitionFact({ name: "--color-text", value: "#ffffff", category: "color" }), ]); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#ffffff", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); // Foreground property -> the text token, not the background token. expect(findings[0].fix).toMatchObject({ value: "var(--color-text)", deterministic: true }); }); it("styles/no-raw-color emits no fix when no token matches the raw color", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color", }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#ff00ff", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toBeUndefined(); expect(findings[0].attributes).toMatchObject({ suggestedToken: undefined }); }); it("styles/no-raw-color suggests the nearest token when no exact value matches", () => { const ix = emptyIndexWithGovernance(); ix.addMany([ makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color" }), makeTokenDefinitionFact({ name: "--fui-color-danger", value: "#dc2626", category: "color" }), ]); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#2563f0", // one shade off the accent token location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].message).toContain("Closest token is `--fui-color-accent` (#2563eb)"); // A nearest match is a hint, never a fix — snapping changes the rendered color. expect(findings[0].fix).toBeUndefined(); expect(findings[0].attributes).toMatchObject({ suggestedToken: "--fui-color-accent", tokenMatch: "nearest", }); }); it("styles/no-raw-color suggests nothing when the nearest token is a different hue", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color" }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#ff0000", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].message).not.toContain("Closest token"); expect(findings[0].attributes).toMatchObject({ suggestedToken: undefined }); }); it("styles/no-raw-color marks exact value matches with tokenMatch: exact", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color" }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#2563eb", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings[0].attributes).toMatchObject({ tokenMatch: "exact" }); }); it("styles/no-raw-color normalizes 3-digit hex when matching tokens", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-white", value: "#ffffff", category: "color", }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "background", value: "#FFF", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings[0].fix).toMatchObject({ value: "var(--fui-color-white)" }); }); it("styles/no-raw-color preserves shorthand declarations when fixing embedded colors", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color", }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "border", value: "1px solid #2563eb", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings[0].fix).toMatchObject({ value: "1px solid var(--fui-color-accent)", }); }); it("styles/no-raw-color emits a fix for inline JSX colors when a token matches", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color", }) ); addButtonUsage(ix, { file: "apps/x.tsx", nodePath: "0", line: 1, column: 0, inlineStyles: [{ property: "color", valueKind: "static", value: "#2563eb" }], text: "x", }); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].fix).toEqual({ kind: "replaceStyleValue", title: "Replace color with var(--fui-color-accent)", property: "color", value: "var(--fui-color-accent)", deterministic: true, }); }); it("token-match fix evidence resolves through the index (token fact is included)", () => { const ix = emptyIndexWithGovernance(); ix.add( makeTokenDefinitionFact({ name: "--fui-color-accent", value: "#2563eb", category: "color", }) ); ix.add( makeStyleDeclarationFact({ file: "libs/ui/x.scss", selector: ".x", declarationPath: "0", property: "color", value: "#2563eb", location: { file: "libs/ui/x.scss", line: 1, column: 0 }, }) ); const findings = ruleStylesNoRawColor(ix); expect(findings).toHaveLength(1); expect(findings[0].evidence.length).toBeGreaterThanOrEqual(3); for (const ev of findings[0].evidence) { expect(ix.has(ev.factId)).toBe(true); } }); }); describe("tokens/require-dual-fallback", () => { it("flags CSS token vars without SCSS fallbacks", () => { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ rules: { "tokens/require-dual-fallback": { enabled: true, severity: "error" }, }, }) ); ix.addMany([ makeTokenDefinitionFact({ name: "--fui-color-brand", value: "#123456", category: "color", }), makeStyleDeclarationFact({ file: "src/button.scss", selector: ".button", declarationPath: "0", property: "color", value: "var(--fui-color-brand)", location: { file: "src/button.scss", line: 2, column: 2 }, }), ]); const findings = ruleTokensRequireDualFallback(ix); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ ruleId: "tokens/require-dual-fallback", code: "FUI2003", severity: "serious", fix: { kind: "replaceStyleValue", value: "var(--fui-color-brand, $fui-color-brand)", }, }); }); it("does NOT fire on plain .css/.module.css files (no Sass pipeline)", () => { for (const file of ["src/button.css", "src/button.module.css", "src/Button.tsx"]) { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ rules: { "tokens/require-dual-fallback": { enabled: true, severity: "error" } }, }) ); ix.addMany([ makeTokenDefinitionFact({ name: "--fui-color-brand", value: "#123456", category: "color" }), makeStyleDeclarationFact({ file, selector: ".button", declarationPath: "0", property: "color", value: "var(--fui-color-brand)", location: { file, line: 2, column: 2 }, }), ]); expect(ruleTokensRequireDualFallback(ix), `should skip ${file}`).toHaveLength(0); } }); }); describe("theme/no-theme-coupled-literal", () => { it("flags theme-sensitive literal colors in custom shadows", () => { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ rules: { "theme/no-theme-coupled-literal": { enabled: true, severity: "warning", }, }, }) ); ix.add( makeStyleDeclarationFact({ file: "src/card.scss", selector: ".card", declarationPath: "0", property: "box-shadow", value: "0 0 0 1px rgba(0, 0, 0, 0.16)", location: { file: "src/card.scss", line: 4, column: 2 }, }) ); const findings = ruleThemeNoThemeCoupledLiteral(ix); expect(findings).toHaveLength(1); expect(findings[0]).toMatchObject({ ruleId: "theme/no-theme-coupled-literal", code: "FUI2014", attributes: { color: "rgba(0, 0, 0, 0.16)", }, }); }); }); describe("components/prefer-library", () => { function preferLibraryIndex() { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ rules: { "components/prefer-library": { enabled: true, severity: "warning", options: { canonicalSources: [ { kind: "npm", specifier: "@acme/ui", include: [ "Alert", "Button", "Checkbox", "Collapsible", "IconButton", "NumberInput", "Radio", "Select", "Table", ], }, ], }, }, }, }) ); return ix; } function addRawNode( ix: FactIndex, input: { element: string; nodePath: string; role?: string; interactive?: boolean; props?: Array<{ prop: string; value?: unknown; resolution?: "static" | "dynamic" | "jsx" }>; text?: string; } ) { const node = makeUsageNodeFact({ file: "src/app.tsx", nodePath: input.nodePath, element: input.element, role: input.role, interactive: input.interactive, location: { file: "src/app.tsx", line: 5, column: 4 }, }); ix.add(node); for (const prop of input.props ?? []) { ix.add( makeUsagePropResolvedFact({ nodeId: node.id, prop: prop.prop, resolution: prop.resolution ?? "static", value: prop.value, }) ); } if (input.text !== undefined) { ix.add(makeUsageTextChildFact({ nodeId: node.id, text: input.text, index: 0 })); } return node; } it("caps advisory-tier severity at warn even when the rule is set to error (only confident tiers escalate)", () => { const ix = new FactIndex(); ix.addMany( compileGlobalGovernanceFacts({ rules: { "components/prefer-library": { enabled: true, severity: "error", options: { canonicalSources: [ { kind: "npm", specifier: "@acme/ui", include: ["Button", "Select"] }, ], }, }, }, }) ); addRawNode(ix, { element: "button", nodePath: "0:0", interactive: true, text: "Go" }); addRawNode(ix, { element: "select", nodePath: "0:1" }); const byRaw = Object.fromEntries( ruleComponentsPreferLibrary(ix).map((finding) => [finding.attributes?.rawValue, finding]) ); // Confident exact-html