import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { extractFindingIgnoreValue, filterDetectionFindings, getHookConsent, setHookConsent, getLocalConfigPath, getConfigPath, ensureConfigGitExclude, readDetectionConfig, readRawDetectionConfig, shouldIgnoreDetectionFile, writeDetectionConfig, } from '../../cli/lib/impeccable-config.mjs'; describe('cli/lib/impeccable-config', () => { let root; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'imp-cfg-')); }); afterEach(() => rmSync(root, { recursive: true, force: true })); test('getHookConsent is undefined until a decision is recorded, then round-trips', () => { expect(getHookConsent(root)).toBeUndefined(); setHookConsent(root, 'declined'); expect(getHookConsent(root)).toBe('declined'); setHookConsent(root, 'accepted'); expect(getHookConsent(root)).toBe('accepted'); }); test('setHookConsent preserves unrelated keys in config.local.json', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getLocalConfigPath(root), JSON.stringify({ updateCheck: false, hook: { quiet: true } })); setHookConsent(root, 'declined'); const raw = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8')); expect(raw.updateCheck).toBe(false); expect(raw.hook.quiet).toBe(true); expect(raw.hook.consent).toBe('declined'); }); test('config.local.json (per-developer) overrides config.json for consent', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getConfigPath(root), JSON.stringify({ hook: { consent: 'accepted' } })); writeFileSync(getLocalConfigPath(root), JSON.stringify({ hook: { consent: 'declined' } })); expect(getHookConsent(root)).toBe('declined'); }); test('malformed config is tolerated (no throw, undefined consent)', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getLocalConfigPath(root), '{ not json'); expect(getHookConsent(root)).toBeUndefined(); }); test('writing consent gitignores config.local.json via .git/info/exclude', () => { execFileSync('git', ['init', '-q'], { cwd: root }); setHookConsent(root, 'declined'); const exclude = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8'); expect(exclude).toContain('.impeccable/config.local.json'); // Idempotent: a second write does not duplicate the marker block. ensureConfigGitExclude(root); const again = readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8'); expect((again.match(/impeccable-config-ignore-start/g) || []).length).toBe(1); // It uses .git/info/exclude, not a tracked .gitignore. expect(existsSync(join(root, '.gitignore'))).toBe(false); }); test('ensureConfigGitExclude is a no-op outside a git repo', () => { expect(ensureConfigGitExclude(root)).toBe(false); }); test('readDetectionConfig merges shared and local detector filters', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getConfigPath(root), JSON.stringify({ detector: { ignoreRules: ['side-tab'], ignoreFiles: ['src/legacy/**'], ignoreValues: [ { rule: 'overused-font', value: 'Avenir Next', reason: 'team default' }, { rule: 'design-system-color', value: '*', files: ['src/demo.css'] }, ], designSystem: { enabled: false }, }, })); writeFileSync(getLocalConfigPath(root), JSON.stringify({ detector: { ignoreRules: ['gradient-text'], ignoreFiles: ['src/local/**'], ignoreValues: [ { rule: 'overused-font', value: 'Avenir Next', reason: 'local override' }, { rule: 'bounce-easing', value: 'bounce-ball' }, ], designSystem: { enabled: true }, }, })); const cfg = readDetectionConfig(root); expect(cfg.ignoreRules).toEqual(['side-tab', 'gradient-text']); expect(cfg.ignoreFiles).toEqual(['src/legacy/**', 'src/local/**']); expect(cfg.ignoreValues).toEqual([ { rule: 'overused-font', value: 'avenir next', reason: 'local override' }, { rule: 'design-system-color', value: '*', files: ['src/demo.css'] }, { rule: 'bounce-easing', value: 'bounce-ball' }, ]); expect(cfg.designSystem).toEqual({ enabled: true }); }); test('readDetectionConfig remains backward-compatible with legacy hook filters', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getConfigPath(root), JSON.stringify({ hook: { ignoreRules: ['side-tab'], ignoreFiles: ['src/legacy/**'], ignoreValues: [{ rule: 'overused-font', value: 'Avenir Next' }], designSystem: { enabled: false }, }, })); const cfg = readDetectionConfig(root); expect(cfg.ignoreRules).toEqual(['side-tab']); expect(cfg.ignoreFiles).toEqual(['src/legacy/**']); expect(cfg.ignoreValues).toEqual([{ rule: 'overused-font', value: 'avenir next' }]); expect(cfg.designSystem).toEqual({ enabled: false }); }); test('writeDetectionConfig writes detector config and strips legacy hook filters', () => { mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getConfigPath(root), JSON.stringify({ updateCheck: false, hook: { consent: 'accepted', quiet: true, ignoreRules: ['legacy-rule'], ignoreFiles: ['legacy/**'], ignoreValues: [{ rule: 'overused-font', value: 'Legacy' }], }, })); const config = readRawDetectionConfig(root); config.ignoreRules.push('side-tab'); config.ignoreFiles.push('src/legacy/**'); writeDetectionConfig(root, config); const raw = JSON.parse(readFileSync(getConfigPath(root), 'utf-8')); expect(raw.updateCheck).toBe(false); expect(raw.hook).toEqual({ consent: 'accepted', quiet: true }); expect(raw.detector.ignoreRules).toEqual(['legacy-rule', 'side-tab']); expect(raw.detector.ignoreFiles).toEqual(['legacy/**', 'src/legacy/**']); expect(raw.detector.ignoreValues).toEqual([{ rule: 'overused-font', value: 'legacy' }]); expect(raw.detector.designSystem).toBeUndefined(); }); test('writeDetectionConfig local ignores do not create an implicit design-system override', () => { execFileSync('git', ['init', '-q'], { cwd: root }); mkdirSync(join(root, '.impeccable'), { recursive: true }); writeFileSync(getConfigPath(root), JSON.stringify({ detector: { designSystem: { enabled: false } }, })); const local = readRawDetectionConfig(root, { local: true }); local.ignoreValues.push({ rule: 'overused-font', value: 'Inter' }); writeDetectionConfig(root, local, { local: true }); const rawLocal = JSON.parse(readFileSync(getLocalConfigPath(root), 'utf-8')); expect(rawLocal.detector.designSystem).toBeUndefined(); expect(readDetectionConfig(root).designSystem).toEqual({ enabled: false }); expect(readFileSync(join(root, '.git', 'info', 'exclude'), 'utf-8')).toContain('.impeccable/config.local.json'); }); test('shouldIgnoreDetectionFile matches relative and absolute paths', () => { const cfg = { ignoreFiles: ['src/legacy/**', '*.generated.tsx'] }; expect(shouldIgnoreDetectionFile(join(root, 'src', 'legacy', 'Card.tsx'), root, cfg)).toBe(true); expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.generated.tsx'), root, cfg)).toBe(true); expect(shouldIgnoreDetectionFile(join(root, 'src', 'Card.tsx'), root, cfg)).toBe(false); }); test('filterDetectionFindings matches hook ignore value semantics', () => { const findings = [ { antipattern: 'overused-font', file: join(root, 'src', 'main.css'), line: 1, snippet: 'Primary font: Avenir Next' }, { antipattern: 'overused-font', file: join(root, 'src', 'other.css'), line: 2, snippet: 'Primary font: Karla' }, { antipattern: 'design-system-color', file: join(root, 'src', 'demo.css'), line: 3, ignoreValue: '#8b5cf6' }, { antipattern: 'design-system-color', file: join(root, 'src', 'real.css'), line: 4, ignoreValue: '#8b5cf6' }, { antipattern: 'design-system-font', file: join(root, 'src', 'demo.css'), line: 5, ignoreValue: 'Avenir Next' }, { antipattern: 'overused-font', file: join(root, 'src', 'fonts.css'), line: 6, snippet: 'Google Fonts: space grotesk' }, ]; const filtered = filterDetectionFindings(findings, { ignoreRules: [], ignoreValues: [ { rule: 'overused-font', value: 'avenir next' }, { rule: 'design-system-color', value: '*', files: ['src/demo.css'] }, { rule: 'design-system-font', value: '*' }, { rule: 'overused-font', value: 'space grotesk' }, ], }); expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([ 'overused-font:2', 'design-system-color:4', 'design-system-font:5', ]); }); test('filterDetectionFindings honors file-scoped wildcard ignores for non-value-bearing rules', () => { const findings = [ { antipattern: 'side-tab', file: join(root, 'components', 'TopicCard.jsx'), line: 331, snippet: "borderLeft: '7px solid" }, { antipattern: 'side-tab', file: join(root, 'components', 'Other.jsx'), line: 12, snippet: "borderLeft: '7px solid" }, ]; const filtered = filterDetectionFindings(findings, { ignoreRules: [], ignoreValues: [{ rule: 'side-tab', value: '*', files: ['**/TopicCard.jsx'] }], }); expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual(['side-tab:12']); }); test('filterDetectionFindings matches equivalent design-system color values', () => { const findings = [ { antipattern: 'design-system-color', file: join(root, 'src', 'rgb.css'), line: 1, ignoreValue: 'rgb(139, 92, 246)' }, { antipattern: 'design-system-color', file: join(root, 'src', 'hex.css'), line: 2, ignoreValue: '#8b5cf6' }, { antipattern: 'design-system-color', file: join(root, 'src', 'alpha.css'), line: 3, ignoreValue: 'rgba(139, 92, 246, 0.5)' }, { antipattern: 'design-system-color', file: join(root, 'src', 'other.css'), line: 4, ignoreValue: '#8b5cf7' }, { antipattern: 'design-system-radius', file: join(root, 'src', 'radius.css'), line: 5, ignoreValue: 'rgb(139, 92, 246)' }, ]; const filtered = filterDetectionFindings(findings, { ignoreValues: [ { rule: 'design-system-color', value: '#8b5cf6' }, { rule: 'design-system-color', value: 'rgb(139 92 246 / 100%)' }, ], }); expect(filtered.map((f) => `${f.antipattern}:${f.line}`)).toEqual([ 'design-system-color:3', 'design-system-color:4', 'design-system-radius:5', ]); }); test('filterDetectionFindings normalizes every supported CSS color unit', () => { const findings = [ { antipattern: 'design-system-color', line: 1, ignoreValue: '#f00' }, { antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100% 0% 0%)' }, { antipattern: 'design-system-color', line: 3, ignoreValue: 'hsl(360deg 100% 50%)' }, { antipattern: 'design-system-color', line: 4, ignoreValue: 'hsl(180deg 100% 50%)' }, { antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(3.141592653589793rad 100% 50%)' }, { antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0.5turn 100% 50%)' }, { antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(200grad 100% 50%)' }, { antipattern: 'design-system-color', line: 8, ignoreValue: 'rgba(255, 0, 0, 0.5)' }, { antipattern: 'design-system-color', line: 9, ignoreValue: 'rgb(100% 0% 0% / 50%)' }, { antipattern: 'design-system-color', line: 10, ignoreValue: 'hsla(0, 100%, 50%, 50%)' }, { antipattern: 'design-system-color', line: 11, ignoreValue: '#f008' }, ]; const filtered = filterDetectionFindings(findings, { ignoreValues: [ { rule: 'design-system-color', value: '#ff0000' }, { rule: 'design-system-color', value: '#00ffff' }, { rule: 'design-system-color', value: '#ff000080' }, { rule: 'design-system-color', value: '#ff000088' }, ], }); expect(filtered).toEqual([]); }); test('filterDetectionFindings rejects out-of-range and malformed CSS colors', () => { const findings = [ { antipattern: 'design-system-color', line: 1, ignoreValue: 'rgb(256 0 0)' }, { antipattern: 'design-system-color', line: 2, ignoreValue: 'rgb(100.1% 0% 0%)' }, { antipattern: 'design-system-color', line: 3, ignoreValue: 'rgba(255, 0, 0, 101%)' }, { antipattern: 'design-system-color', line: 4, ignoreValue: 'rgba(255, 0, 0, -0.1)' }, { antipattern: 'design-system-color', line: 5, ignoreValue: 'hsl(0 100 50%)' }, { antipattern: 'design-system-color', line: 6, ignoreValue: 'hsl(0 101% 50%)' }, { antipattern: 'design-system-color', line: 7, ignoreValue: 'hsl(0foo 100% 50%)' }, { antipattern: 'design-system-color', line: 8, ignoreValue: '#ff00000' }, ]; const filtered = filterDetectionFindings(findings, { ignoreValues: [ { rule: 'design-system-color', value: '#ff0000' }, { rule: 'design-system-color', value: '#ff000080' }, ], }); expect(filtered.map((finding) => finding.line)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); }); test('extractFindingIgnoreValue handles fonts, Google font URLs, and motion snippets', () => { expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Primary font: Avenir Next (80% of text)' })).toBe('avenir next'); expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'https://fonts.googleapis.com/css2?family=Alumni+Sans:wght@700' })).toBe('alumni sans'); expect(extractFindingIgnoreValue({ antipattern: 'overused-font', snippet: 'Google Fonts: space grotesk' })).toBe('space grotesk'); expect(extractFindingIgnoreValue({ antipattern: 'bounce-easing', snippet: 'animation: bounce-ball 1s infinite' })).toBe('bounce-ball'); }); // This list is duplicated in skill/scripts/hook-lib.mjs. The two had drifted: // font-size waivers worked in the hook but not in the CLI, so the same config // filtered differently depending on which entry point read it. test('extractFindingIgnoreValue covers design-system-font-size, matching the hook', () => { expect(extractFindingIgnoreValue({ antipattern: 'design-system-font-size', ignoreValue: '0.82rem' })).toBe('0.82rem'); expect(extractFindingIgnoreValue({ antipattern: 'design-system-radius', ignoreValue: '18px' })).toBe('18px'); // A rule with no waivable value still extracts nothing. expect(extractFindingIgnoreValue({ antipattern: 'side-tab', snippet: 'border-left: 4px' })).toBe(''); }); });