From e47932717e3f8b912574e5ac404a9b3a858219cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:03:24 +0000 Subject: [PATCH 1/3] test: cover colour/scale/theme libs, CI-gate negatives, forms + motion CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the highest-value coverage gaps found in the repo-wide test audit. New unit tests (configurator/vitest): - colorUtils: OKLCH→sRGB, luminance, contrast ratio/rating, oklch parse/round-trip - variableScales: shape + exact var(--sf-*) wire format of every sibling scale - powerKnobs: range invariants (min≤default≤max, on-grid) + encode/decode round-trip - theme.svelte: persistence, toggle, forceTheme (no-persist), dark-class binding - domains: domainOf routing + misc fallback (locks the check:curation contract) New negative/writer tests (node --test) — prove the CI gates actually bite: - version-sync (writer): propagates version to every artifact; checker agrees; idempotent - check-llm-guide / check-macro-catalog / check-token-registry: each fails on its own drift class instead of silently going green New behavioural specs (Playwright): - forms.spec: input/textarea/select/:disabled + --sf-field-border-color indirection - motion.spec: prefers-reduced-motion gating + --sf-motion-scale duration scaling Enabling fix: version-sync.js and the three check-*.js gates now honour SLASHED_ROOT (mirroring check-version-sync.js) so they can run against throwaway fixture trees. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SETyJ7R12Bxeuux7yF6y7F --- configurator/tests/colorUtils.test.ts | 135 ++++++++++++++++++++++ configurator/tests/domains.test.ts | 50 ++++++++ configurator/tests/powerKnobs.test.ts | 69 +++++++++++ configurator/tests/theme.test.ts | 66 +++++++++++ configurator/tests/variableScales.test.ts | 46 ++++++++ scripts/check-llm-guide.js | 3 +- scripts/check-macro-catalog.js | 3 +- scripts/check-token-registry.js | 4 +- scripts/version-sync.js | 4 +- tests/check-llm-guide.test.js | 88 ++++++++++++++ tests/check-macro-catalog.test.js | 77 ++++++++++++ tests/check-token-registry.test.js | 116 +++++++++++++++++++ tests/forms.spec.js | 95 +++++++++++++++ tests/motion.spec.js | 73 ++++++++++++ tests/version-sync.test.js | 119 +++++++++++++++++++ 15 files changed, 944 insertions(+), 4 deletions(-) create mode 100644 configurator/tests/colorUtils.test.ts create mode 100644 configurator/tests/domains.test.ts create mode 100644 configurator/tests/powerKnobs.test.ts create mode 100644 configurator/tests/theme.test.ts create mode 100644 configurator/tests/variableScales.test.ts create mode 100644 tests/check-llm-guide.test.js create mode 100644 tests/check-macro-catalog.test.js create mode 100644 tests/check-token-registry.test.js create mode 100644 tests/forms.spec.js create mode 100644 tests/motion.spec.js create mode 100644 tests/version-sync.test.js diff --git a/configurator/tests/colorUtils.test.ts b/configurator/tests/colorUtils.test.ts new file mode 100644 index 00000000..758d7cf0 --- /dev/null +++ b/configurator/tests/colorUtils.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for src/lib/colorUtils.ts — the colour maths behind every picker, + * the WCAG panel, and the exported palette. Pure functions, no DOM. + * + * Why this matters: these are ~7 pure functions with zero unit coverage. A + * silent regression (a flipped coefficient, a gamma tweak, a rounding change) + * would corrupt EVERY resolved colour in the configurator and only surface as + * a wrong export in a user's hands. These tests pin the contract. + */ +import { describe, test, expect } from 'vitest'; +import { + oklchToRgb, + rgbToHex, + getRelativeLuminance, + getContrastRatio, + getContrastRating, + parseOklch, + stringifyOklch, +} from '../src/lib/colorUtils'; + +describe('oklchToRgb', () => { + test('pure white: L=1, C=0 → [255,255,255]', () => { + expect(oklchToRgb(1, 0, 0)).toEqual([255, 255, 255]); + }); + + test('pure black: L=0, C=0 → [0,0,0]', () => { + expect(oklchToRgb(0, 0, 0)).toEqual([0, 0, 0]); + }); + + test('output is always clamped into the [0,255] byte range', () => { + // Sweep hues and a high chroma that pushes several hues out of sRGB gamut; + // the function must clamp, never emit NaN or out-of-range bytes. + for (let h = 0; h < 360; h += 15) { + const rgb = oklchToRgb(0.6, 0.37, h); + expect(rgb).toHaveLength(3); + for (const ch of rgb) { + expect(Number.isInteger(ch)).toBe(true); + expect(ch).toBeGreaterThanOrEqual(0); + expect(ch).toBeLessThanOrEqual(255); + } + } + }); + + test('a mid red hue skews the red channel highest', () => { + const [r, g, b] = oklchToRgb(0.63, 0.25, 29); // ~ sRGB red + expect(r).toBeGreaterThan(g); + expect(r).toBeGreaterThan(b); + }); +}); + +describe('rgbToHex', () => { + test('pads single-digit channels to two hex digits', () => { + expect(rgbToHex(0, 0, 0)).toBe('#000000'); + expect(rgbToHex(255, 255, 255)).toBe('#ffffff'); + expect(rgbToHex(1, 2, 3)).toBe('#010203'); + }); +}); + +describe('getRelativeLuminance', () => { + test('white luminance is 1, black is 0', () => { + expect(getRelativeLuminance(255, 255, 255)).toBeCloseTo(1, 5); + expect(getRelativeLuminance(0, 0, 0)).toBeCloseTo(0, 5); + }); + + test('green weighs more than red weighs more than blue', () => { + const r = getRelativeLuminance(255, 0, 0); + const g = getRelativeLuminance(0, 255, 0); + const b = getRelativeLuminance(0, 0, 255); + expect(g).toBeGreaterThan(r); + expect(r).toBeGreaterThan(b); + }); +}); + +describe('getContrastRatio', () => { + test('black-on-white is the canonical 21:1', () => { + const white = getRelativeLuminance(255, 255, 255); + const black = getRelativeLuminance(0, 0, 0); + expect(getContrastRatio(white, black)).toBeCloseTo(21, 1); + }); + + test('identical colours give the 1:1 floor and order does not matter', () => { + const l = getRelativeLuminance(128, 128, 128); + expect(getContrastRatio(l, l)).toBeCloseTo(1, 5); + const a = getRelativeLuminance(255, 255, 255); + const b = getRelativeLuminance(0, 0, 0); + expect(getContrastRatio(a, b)).toBeCloseTo(getContrastRatio(b, a), 5); + }); +}); + +describe('getContrastRating', () => { + test('21:1 passes every WCAG tier', () => { + const r = getContrastRating(21); + expect(r).toMatchObject({ + aaNormal: 'PASS', aaLarge: 'PASS', aaaNormal: 'PASS', aaaLarge: 'PASS', + }); + expect(r.ratioText).toBe('21.00:1'); + }); + + test('boundary values map to the exact spec thresholds', () => { + // 4.5 is the AA-normal / AAA-large floor; below it both fail. + expect(getContrastRating(4.5)).toMatchObject({ aaNormal: 'PASS', aaaLarge: 'PASS', aaaNormal: 'FAIL' }); + expect(getContrastRating(4.49)).toMatchObject({ aaNormal: 'FAIL', aaaLarge: 'FAIL' }); + // 3.0 is the AA-large floor; 7.0 is the AAA-normal floor. + expect(getContrastRating(3.0).aaLarge).toBe('PASS'); + expect(getContrastRating(2.99).aaLarge).toBe('FAIL'); + expect(getContrastRating(7.0).aaaNormal).toBe('PASS'); + }); +}); + +describe('parseOklch ↔ stringifyOklch', () => { + test('parses a canonical oklch() string', () => { + expect(parseOklch('oklch(0.7 0.15 200)')).toEqual({ l: 0.7, c: 0.15, h: 200, valid: true }); + }); + + test('accepts a percentage lightness', () => { + const p = parseOklch('oklch(70% 0.15 200)'); + expect(p.valid).toBe(true); + expect(p.l).toBeCloseTo(0.7, 5); + }); + + test('non-oklch input returns the documented fallback with valid:false', () => { + const p = parseOklch('#ff0000'); + expect(p.valid).toBe(false); + expect(p).toMatchObject({ l: 0.5, c: 0.15, h: 200 }); + }); + + test('stringify → parse round-trips the components', () => { + const s = stringifyOklch(0.732, 0.123, 275); + expect(s).toBe('oklch(0.732 0.123 275)'); + const p = parseOklch(s); + expect(p.l).toBeCloseTo(0.732, 3); + expect(p.c).toBeCloseTo(0.123, 3); + expect(p.h).toBe(275); + }); +}); diff --git a/configurator/tests/domains.test.ts b/configurator/tests/domains.test.ts new file mode 100644 index 00000000..2e1bd474 --- /dev/null +++ b/configurator/tests/domains.test.ts @@ -0,0 +1,50 @@ +/** + * Unit tests for src/lib/domains.ts — the runtime that routes each token to the + * panel (domain) it appears under. Previously only exercised indirectly via the + * curation CLI. `domainOf` is the runtime side of the same contract + * check:curation guards, so it deserves its own direct lock. + * + * Why this matters: `domainOf` and check-curation's classifier must agree — if + * they drift, a knob can pass CI yet land in the wrong panel (or a phantom one). + */ +import { describe, test, expect } from 'vitest'; +import { domainOf, DOMAIN_PATTERNS } from '../src/lib/domains'; + +describe('domainOf', () => { + test.each([ + ['--sf-color-primary', 'colors'], + ['--sf-font-body', 'typography'], + ['--sf-space-m', 'spacing'], + ['--sf-radius-l', 'borders'], + ['--sf-shadow-m', 'shadows'], + ['--sf-motion-scale', 'motion'], + ['--sf-container-wide', 'layout'], + ['--sf-btn-pad', 'components'], + ['--sf-blur-m', 'effects'], + ])('%s → %s', (name, expected) => { + expect(domainOf(name)).toBe(expected); + }); + + test('an unrecognised name falls back to "misc"', () => { + expect(domainOf('--sf-totally-unknown-xyz')).toBe('misc'); + }); + + test('a name matching a misc pattern also resolves to "misc"', () => { + // z-index tokens legitimately live in the Misc panel. + expect(domainOf('--sf-z-modal')).toBe('misc'); + expect(domainOf('--sf-focus-ring-width')).toBe('misc'); + }); + + test('never returns a domain key that is absent from DOMAIN_PATTERNS', () => { + const keys = new Set(Object.keys(DOMAIN_PATTERNS)); + for (const n of ['--sf-color-x', '--sf-unknown', '--sf-z-base', '--sf-btn-y']) { + expect(keys.has(domainOf(n))).toBe(true); + } + }); + + test('a more specific domain wins over the misc fallback', () => { + // "size-" is a misc pattern, but "--sf-btn-*" should still classify as a + // component if it ever collides — this guards the precedence order. + expect(domainOf('--sf-container-narrow')).toBe('layout'); + }); +}); diff --git a/configurator/tests/powerKnobs.test.ts b/configurator/tests/powerKnobs.test.ts new file mode 100644 index 00000000..ec86acdb --- /dev/null +++ b/configurator/tests/powerKnobs.test.ts @@ -0,0 +1,69 @@ +/** + * Contract tests for src/lib/powerKnobs.ts — the curated "power knob" scalars + * (contrast bias, text/space/radius scales, shadow strength, motion scale…) + * surfaced as high-leverage sliders. + * + * Why this matters: each knob's {min, default, max, step} is fed straight into + * a range input, and some carry an encode/decode pair that rewrites the value + * into a calc() expression on the way to CSS and back. A default outside its + * own range, a zero/negative step, or a non-round-tripping codec would produce + * a broken or un-resettable control. None of this was covered. + */ +import { describe, test, expect } from 'vitest'; +import { KNOBS_BY_DOMAIN } from '../src/lib/powerKnobs'; + +const ALL = Object.values(KNOBS_BY_DOMAIN).flat(); + +describe('KNOBS_BY_DOMAIN structure', () => { + test('is non-empty and every knob targets a distinct --sf-* token', () => { + expect(ALL.length).toBeGreaterThan(0); + const names = ALL.map((k) => k.name); + for (const n of names) expect(n).toMatch(/^--sf-[a-z0-9-]+$/); + expect(new Set(names).size).toBe(names.length); + }); + + test('every knob has a human label and help text', () => { + for (const k of ALL) { + expect(k.label, k.name).toBeTruthy(); + expect(k.help, k.name).toBeTruthy(); + } + }); +}); + +describe('range invariants', () => { + test.each(ALL.map((k) => [k.name, k] as const))('%s: min ≤ default ≤ max, step > 0', (_n, k) => { + expect(k.min).toBeLessThanOrEqual(k.default); + expect(k.default).toBeLessThanOrEqual(k.max); + expect(k.max).toBeGreaterThan(k.min); + expect(k.step).toBeGreaterThan(0); + // The slider must be able to reach the default from min in whole steps + // (float tolerance) — otherwise "reset to default" lands off-grid. + const stepsFromMin = (k.default - k.min) / k.step; + expect(Math.abs(stepsFromMin - Math.round(stepsFromMin))).toBeLessThan(1e-6); + }); +}); + +describe('encode/decode round-trip (knobs that carry a codec)', () => { + const coded = ALL.filter((k) => typeof k.encode === 'function' && typeof k.decode === 'function'); + + test('at least one knob exercises the codec path (shadow strength)', () => { + expect(coded.some((k) => k.name === '--sf-shadow-strength')).toBe(true); + }); + + test.each(coded.map((k) => [k.name, k] as const))( + '%s: decode(encode(v)) recovers v across the range', + (_n, k) => { + for (let v = k.min; v <= k.max + 1e-9; v += k.step) { + const round = Math.round(v * 1000) / 1000; + const encoded = k.encode!(round); + expect(typeof encoded).toBe('string'); + expect(k.decode!(encoded)).toBeCloseTo(round, 5); + } + }, + ); + + test('shadow decode returns NaN on an unparseable value (defensive fallback)', () => { + const shadow = coded.find((k) => k.name === '--sf-shadow-strength')!; + expect(Number.isNaN(shadow.decode!('not-a-calc'))).toBe(true); + }); +}); diff --git a/configurator/tests/theme.test.ts b/configurator/tests/theme.test.ts new file mode 100644 index 00000000..71be2207 --- /dev/null +++ b/configurator/tests/theme.test.ts @@ -0,0 +1,66 @@ +/** + * Unit tests for src/lib/theme.svelte.ts — the Studio chrome light/dark store. + * + * Why this matters: this module is imported by 8 components and owns the + * persisted studio theme + the `dark` class binding Tailwind's `dark:` variant + * keys off. It had no unit coverage — a regression in persistence or the root + * toggle would flip the whole studio's appearance. jsdom provides localStorage; + * matchMedia is intentionally absent, so the module's typeof-guards are also + * exercised here. + */ +import { describe, test, expect, beforeEach } from 'vitest'; +import { themeState, setTheme, toggleTheme, forceTheme, bindThemeRoot } from '../src/lib/theme.svelte'; + +const STORAGE_KEY = 'slashed-studio-theme'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('setTheme', () => { + test('updates the reactive state and persists the choice', () => { + setTheme('dark'); + expect(themeState.value).toBe('dark'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('dark'); + + setTheme('light'); + expect(themeState.value).toBe('light'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('light'); + }); +}); + +describe('toggleTheme', () => { + test('flips between light and dark', () => { + setTheme('light'); + toggleTheme(); + expect(themeState.value).toBe('dark'); + toggleTheme(); + expect(themeState.value).toBe('light'); + }); +}); + +describe('bindThemeRoot', () => { + test('applies/removes the `dark` class to reflect the current theme', () => { + const el = document.createElement('div'); + setTheme('dark'); + bindThemeRoot(el); + expect(el.classList.contains('dark')).toBe(true); + + setTheme('light'); + expect(el.classList.contains('dark')).toBe(false); + + setTheme('dark'); + expect(el.classList.contains('dark')).toBe(true); + }); +}); + +describe('forceTheme', () => { + test('sets the in-memory theme WITHOUT persisting it', () => { + localStorage.clear(); + forceTheme('dark'); + expect(themeState.value).toBe('dark'); + // The whole point of forceTheme: no storage write, so an embedding host can + // pin appearance without clobbering the user's own saved preference. + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +}); diff --git a/configurator/tests/variableScales.test.ts b/configurator/tests/variableScales.test.ts new file mode 100644 index 00000000..af28ce03 --- /dev/null +++ b/configurator/tests/variableScales.test.ts @@ -0,0 +1,46 @@ +/** + * Unit tests for src/lib/variableScales.ts — the sibling-scale option lists + * SliderRow's variable picker offers when a knob defaults to another token + * (e.g. `--sf-gap: var(--sf-space-m)`). + * + * Why this matters: these lists are consumed as-is to build dropdowns whose + * `value` strings are written straight into the exported CSS. A malformed + * `var(--sf-…)` string, a wrong prefix, or a dropped step would silently emit + * broken CSS. This pins the shape and the exact wire format. + */ +import { describe, test, expect } from 'vitest'; +import { + SPACE_SCALE, + RADIUS_SCALE, + BORDER_WIDTH_SCALE, + CONTAINER_SCALE, + SIZE_SCALE, + SHADOW_SCALE, + type VarOption, +} from '../src/lib/variableScales'; + +const ALL: Record = { + SPACE_SCALE: { scale: SPACE_SCALE, prefix: 'space', steps: ['2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl'] }, + RADIUS_SCALE: { scale: RADIUS_SCALE, prefix: 'radius', steps: ['2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', 'full'] }, + BORDER_WIDTH_SCALE: { scale: BORDER_WIDTH_SCALE, prefix: 'border-width', steps: ['1', '2', '3', '4'] }, + CONTAINER_SCALE: { scale: CONTAINER_SCALE, prefix: 'container', steps: ['narrow', 'prose', 'default', 'wide', 'full'] }, + SIZE_SCALE: { scale: SIZE_SCALE, prefix: 'size', steps: ['xs', 's', 'm', 'l', 'xl'] }, + SHADOW_SCALE: { scale: SHADOW_SCALE, prefix: 'shadow', steps: ['xs', 's', 'm', 'l', 'xl'] }, +}; + +describe.each(Object.entries(ALL))('%s', (_name, { scale, prefix, steps }) => { + test('has one option per documented step, in order', () => { + expect(scale.map((o) => o.label)).toEqual(steps.map((s) => `${prefix}-${s}`)); + }); + + test('every value is a well-formed var(--sf--) reference', () => { + for (const [i, opt] of scale.entries()) { + expect(opt.value).toBe(`var(--sf-${prefix}-${steps[i]})`); + expect(opt.value).toMatch(/^var\(--sf-[a-z0-9-]+\)$/); + } + }); + + test('labels are unique', () => { + expect(new Set(scale.map((o) => o.label)).size).toBe(scale.length); + }); +}); diff --git a/scripts/check-llm-guide.js b/scripts/check-llm-guide.js index 57481c20..5d6af15f 100644 --- a/scripts/check-llm-guide.js +++ b/scripts/check-llm-guide.js @@ -26,7 +26,8 @@ import fs from 'node:fs'; import path from 'node:path'; -const ROOT = path.resolve(import.meta.dirname, '..'); +// SLASHED_ROOT lets negative tests run the gate against a fixture tree. +const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); const GUIDE = path.join(ROOT, 'docs', 'llm-guide.md'); const REGISTRY = path.join(ROOT, 'token-registry.json'); const API_INDEX = path.join(ROOT, 'docs', 'api-index.json'); diff --git a/scripts/check-macro-catalog.js b/scripts/check-macro-catalog.js index 283ca8ca..5136b434 100644 --- a/scripts/check-macro-catalog.js +++ b/scripts/check-macro-catalog.js @@ -16,7 +16,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { stripComments, stripStrings } from './lib/parse.js'; -const ROOT = path.resolve(import.meta.dirname, '..'); +// SLASHED_ROOT lets negative tests run the gate against a fixture tree. +const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); // Source files that define the macro layer const CSS_SOURCES = [ diff --git a/scripts/check-token-registry.js b/scripts/check-token-registry.js index fc05b2ed..2ce83ba7 100644 --- a/scripts/check-token-registry.js +++ b/scripts/check-token-registry.js @@ -23,7 +23,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; -const ROOT = path.resolve(import.meta.dirname, '..'); +// SLASHED_ROOT lets negative tests run the gate against a fixture tree (with no +// git baseline, invariants 1–2 are skipped; 3–4 and the id guards still run). +const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); const REGISTRY = path.join(ROOT, 'token-registry.json'); const API_INDEX = path.join(ROOT, 'docs', 'api-index.json'); diff --git a/scripts/version-sync.js b/scripts/version-sync.js index ff885c43..a5c9d9f1 100644 --- a/scripts/version-sync.js +++ b/scripts/version-sync.js @@ -15,7 +15,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { readFile as readFileLib } from './lib/parse.js'; -const ROOT = path.resolve(import.meta.dirname, '..'); +// SLASHED_ROOT lets tests point the writer at a throwaway fixture tree; falls +// back to the repo root in normal use. Mirrors scripts/check-version-sync.js. +const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); function readFile(rel) { return readFileLib(rel, ROOT); diff --git a/tests/check-llm-guide.test.js b/tests/check-llm-guide.test.js new file mode 100644 index 00000000..3bd58b24 --- /dev/null +++ b/tests/check-llm-guide.test.js @@ -0,0 +1,88 @@ +/** + * Negative tests for scripts/check-llm-guide.js. + * + * The gap this closes: check:llm-guide is a hard CI gate (Check 1: every --sf-* + * the guide names must exist as a live token), but nothing proved it still + * fails on a stale reference. These build a fixture (SLASHED_ROOT), confirm a + * clean guide passes, then plant a stale token name and a missing-file case and + * assert the gate exits non-zero. + */ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const GATE = path.join(ROOT, 'scripts', 'check-llm-guide.js'); + +const tmpDirs = []; + +function buildFixture(guideBody) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-guide-')); + tmpDirs.push(dir); + fs.mkdirSync(path.join(dir, 'core')); + fs.mkdirSync(path.join(dir, 'optional')); + fs.mkdirSync(path.join(dir, 'docs')); + + // Live set (a): registry names. + fs.writeFileSync( + path.join(dir, 'token-registry.json'), + JSON.stringify({ tokens: [{ name: '--sf-color-text' }] }), + ); + // Live set (b): custom-property declarations in CSS source. + fs.writeFileSync(path.join(dir, 'core', 'tokens.css'), `:root { --sf-space-m: 1rem; }\n`); + // No public knobs → no Check-2 warnings to muddy the pass case. + fs.writeFileSync(path.join(dir, 'docs', 'api-index.json'), JSON.stringify({ entries: [] })); + + const body = guideBody ?? `# LLM guide\n\nUse \`--sf-color-text\` for body text and \`--sf-space-m\` for gaps.\n`; + fs.writeFileSync(path.join(dir, 'docs', 'llm-guide.md'), body); + return dir; +} + +function runGate(dir) { + return spawnSync(process.execPath, [GATE], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); +} + +describe('check-llm-guide failure cases', () => { + test('passes when every referenced token is live', () => { + const r = runGate(buildFixture()); + assert.equal(r.status, 0, `expected pass:\n${r.stderr}`); + }); + + test('fails when the guide references a token absent from the live set', () => { + const dir = buildFixture( + `# LLM guide\n\nUse \`--sf-color-text\` and the removed \`--sf-ghost-token\`.\n`, + ); + const r = runGate(dir); + assert.equal(r.status, 1, 'expected exit 1 for a stale token reference'); + assert.match(r.stderr, /stale: --sf-ghost-token/); + }); + + test('fails when docs/llm-guide.md is missing', () => { + const dir = buildFixture(); + fs.rmSync(path.join(dir, 'docs', 'llm-guide.md')); + const r = runGate(dir); + assert.equal(r.status, 1, 'expected exit 1 when the guide file is absent'); + assert.match(r.stderr, /llm-guide\.md not found/); + }); + + test('a bare prefix (name ending in "-") is prose, not a checked reference', () => { + // "--sf-color-text--on-" is glob-like prose and must NOT be flagged stale. + const dir = buildFixture( + `# LLM guide\n\n\`--sf-color-text\`, \`--sf-space-m\`, and patterns like --sf-color-text--on- families.\n`, + ); + const r = runGate(dir); + assert.equal(r.status, 0, `bare-prefix prose should not fail:\n${r.stderr}`); + }); +}); + +after(() => { + for (const d of tmpDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/tests/check-macro-catalog.test.js b/tests/check-macro-catalog.test.js new file mode 100644 index 00000000..32d5198e --- /dev/null +++ b/tests/check-macro-catalog.test.js @@ -0,0 +1,77 @@ +/** + * Negative tests for scripts/check-macro-catalog.js. + * + * The gap this closes: check:macros is a CI gate with no test proving it can + * still detect drift. If its regex or skip-lists silently rot, CI keeps passing + * while docs/macros.md and the macro CSS diverge. These build a minimal fixture + * (SLASHED_ROOT), assert it passes, then introduce each drift class and assert + * a non-zero exit with the expected message. + */ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const GATE = path.join(ROOT, 'scripts', 'check-macro-catalog.js'); + +const tmpDirs = []; + +function buildFixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-macros-')); + tmpDirs.push(dir); + fs.mkdirSync(path.join(dir, 'core')); + fs.mkdirSync(path.join(dir, 'docs')); + fs.writeFileSync(path.join(dir, 'core', 'macros.css'), `@layer slashed.macros {\n .sf-prose { max-inline-size: 65ch; }\n}\n`); + fs.writeFileSync(path.join(dir, 'core', 'motion.css'), `@layer slashed.motion {\n /* no exposed classes here */\n}\n`); + fs.writeFileSync(path.join(dir, 'docs', 'macros.md'), `# Macros\n\n- \`.sf-prose\` — readable measure.\n`); + return dir; +} + +function runGate(dir) { + return spawnSync(process.execPath, [GATE], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); +} + +describe('check-macro-catalog failure cases', () => { + test('passes on an in-sync fixture', () => { + const r = runGate(buildFixture()); + assert.equal(r.status, 0, `expected pass:\n${r.stderr}`); + }); + + test('fails when a class exists in CSS but is undocumented', () => { + const dir = buildFixture(); + fs.appendFileSync(path.join(dir, 'core', 'macros.css'), `\n.sf-brandnew { display: grid; }\n`); + const r = runGate(dir); + assert.equal(r.status, 1, 'expected exit 1 for undocumented CSS class'); + assert.match(r.stderr, /NOT documented/); + assert.match(r.stderr, /sf-brandnew/); + }); + + test('fails when docs reference a class absent from source CSS', () => { + const dir = buildFixture(); + fs.appendFileSync(path.join(dir, 'docs', 'macros.md'), `\n- \`.sf-phantom\` — does not exist.\n`); + const r = runGate(dir); + assert.equal(r.status, 1, 'expected exit 1 for phantom documented class'); + assert.match(r.stderr, /NOT in source CSS/); + assert.match(r.stderr, /sf-phantom/); + }); + + test('fails when a required source file is missing', () => { + const dir = buildFixture(); + fs.rmSync(path.join(dir, 'core', 'motion.css')); + const r = runGate(dir); + assert.equal(r.status, 1, 'expected exit 1 for missing source file'); + assert.match(r.stderr, /Missing source file/); + }); +}); + +after(() => { + for (const d of tmpDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/tests/check-token-registry.test.js b/tests/check-token-registry.test.js new file mode 100644 index 00000000..e4043378 --- /dev/null +++ b/tests/check-token-registry.test.js @@ -0,0 +1,116 @@ +/** + * Negative tests for scripts/check-token-registry.js. + * + * The gap this closes: check:registry guards the "ids are permanent + unique + + * bounded" contract the shareable config codec depends on, but nothing proved + * it still fails on a violation. Run against a fixture with no git baseline + * (SLASHED_ROOT points at /tmp), invariants 1–2 are vacuous, but the id guards + * (duplicate, uint16 ceiling, nextId > maxId) and the catalogue-registration + * check still run — so we can assert each one bites. + */ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const GATE = path.join(ROOT, 'scripts', 'check-token-registry.js'); + +const tmpDirs = []; + +function buildFixture(registry, apiIndex) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-registry-')); + tmpDirs.push(dir); + fs.mkdirSync(path.join(dir, 'docs')); + const reg = registry ?? { + _meta: { nextId: 3 }, + tokens: [ + { id: 1, name: '--sf-a' }, + { id: 2, name: '--sf-b' }, + ], + }; + const api = apiIndex ?? { + entries: [ + { type: 'token', name: '--sf-a' }, + { type: 'token', name: '--sf-b' }, + ], + }; + fs.writeFileSync(path.join(dir, 'token-registry.json'), JSON.stringify(reg, null, 2)); + fs.writeFileSync(path.join(dir, 'docs', 'api-index.json'), JSON.stringify(api, null, 2)); + return dir; +} + +function runGate(dir) { + return spawnSync(process.execPath, [GATE], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); +} + +describe('check-token-registry failure cases', () => { + test('passes on a well-formed fixture', () => { + const r = runGate(buildFixture()); + assert.equal(r.status, 0, `expected pass:\n${r.stderr}`); + }); + + test('fails on a duplicate id', () => { + const dir = buildFixture({ + _meta: { nextId: 3 }, + tokens: [{ id: 1, name: '--sf-a' }, { id: 1, name: '--sf-b' }], + }, { entries: [{ type: 'token', name: '--sf-a' }, { type: 'token', name: '--sf-b' }] }); + const r = runGate(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /duplicate id/); + }); + + test('fails when nextId is not strictly greater than the max id', () => { + const dir = buildFixture({ + _meta: { nextId: 2 }, // maxId is 2 → must be > 2 + tokens: [{ id: 1, name: '--sf-a' }, { id: 2, name: '--sf-b' }], + }); + const r = runGate(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /must be greater than the max id/); + }); + + test('fails when an id exceeds the uint16 wire ceiling', () => { + const dir = buildFixture({ + _meta: { nextId: 70000 }, + tokens: [{ id: 65536, name: '--sf-a' }, { id: 2, name: '--sf-b' }], + }); + const r = runGate(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /uint16 wire limit/); + }); + + test('fails when a catalogue token has no active registry entry', () => { + const dir = buildFixture(undefined, { + entries: [ + { type: 'token', name: '--sf-a' }, + { type: 'token', name: '--sf-b' }, + { type: 'token', name: '--sf-c' }, // present in catalogue, absent from registry + ], + }); + const r = runGate(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /--sf-c.*no active registry entry/s); + }); + + test('a `removed` registry entry does NOT satisfy a live catalogue token', () => { + const dir = buildFixture({ + _meta: { nextId: 3 }, + tokens: [{ id: 1, name: '--sf-a' }, { id: 2, name: '--sf-b', removed: true }], + }); + const r = runGate(dir); + assert.equal(r.status, 1); + assert.match(r.stderr, /--sf-b.*no active registry entry/s); + }); +}); + +after(() => { + for (const d of tmpDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); diff --git a/tests/forms.spec.js b/tests/forms.spec.js new file mode 100644 index 00000000..a31ca6c0 --- /dev/null +++ b/tests/forms.spec.js @@ -0,0 +1,95 @@ +// @ts-check +// Behavioural tests for optional/forms.css — the classless form-control layer, +// previously with no dedicated spec (only touched incidentally by a11y.spec). +// Asserts the computed styling contract for text inputs, textarea, select, the +// disabled state, and the --sf-field-border-color token indirection that the +// :user-invalid / :user-valid states pivot on. Loads the optimal bundle (which +// includes optional/forms.css) in both themes. +import { test, expect } from '@playwright/test'; +import { renderWithBundle, NO_TRANSITIONS_STYLE } from './render-helpers.js'; + +const TRANSPARENT = new Set(['rgba(0, 0, 0, 0)', 'transparent']); + +/** Read a set of computed properties for #t. */ +function computed(page, props) { + return page.evaluate((p) => { + const cs = getComputedStyle(document.getElementById('t')); + return Object.fromEntries(p.map((k) => [k, cs.getPropertyValue(k)])); + }, props); +} + +for (const theme of ['light', 'dark']) { + test.describe(`forms — ${theme} theme`, () => { + async function mount(page, html) { + await renderWithBundle(page, html, { extraStyle: NO_TRANSITIONS_STYLE }); + await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme); + } + + test('a bare text input is a full-width block with padding and a visible border', async ({ page }) => { + await mount(page, ``); + const cs = await computed(page, [ + 'display', 'appearance', 'border-top-width', 'border-top-style', + 'padding-block-start', 'padding-inline-start', + ]); + expect(cs.display).toBe('block'); + expect(cs.appearance).toBe('none'); + expect(parseFloat(cs['border-top-width'])).toBeGreaterThan(0); + expect(cs['border-top-style']).toBe('solid'); + expect(parseFloat(cs['padding-block-start'])).toBeGreaterThan(0); + expect(parseFloat(cs['padding-inline-start'])).toBeGreaterThan(0); + }); + + test('input stretches to its container width', async ({ page }) => { + await mount(page, `
`); + const w = await page.evaluate(() => document.getElementById('t').getBoundingClientRect().width); + expect(Math.round(w)).toBe(400); + }); + + test('an untyped input ( :not([type]) ) is styled like a text input', async ({ page }) => { + await mount(page, ``); + const cs = await computed(page, ['appearance', 'display']); + expect(cs.appearance).toBe('none'); + expect(cs.display).toBe('block'); + }); + + test(':disabled dims the control and shows a not-allowed cursor', async ({ page }) => { + await mount(page, ``); + const cs = await computed(page, ['opacity', 'cursor']); + expect(parseFloat(cs.opacity)).toBeLessThan(1); + expect(cs.cursor).toBe('not-allowed'); + }); + + test('--sf-field-border-color overrides the resting border colour', async ({ page }) => { + await mount(page, ``); + const cs = await computed(page, ['border-top-color']); + // The border resolves through --sf-field-border-color (the same hook + // :user-invalid/:user-valid flip to danger/success). + expect(cs['border-top-color']).toBe('rgb(1, 2, 3)'); + }); + + test('textarea is vertically resizable with a minimum height', async ({ page }) => { + await mount(page, ``); + const cs = await computed(page, ['resize', 'min-block-size']); + expect(cs.resize).toBe('vertical'); + expect(parseFloat(cs['min-block-size'])).toBeGreaterThan(0); + }); + + test('select reserves inline-end space for its chevron', async ({ page }) => { + await mount(page, ``); + const [selPad, inputPad] = await page.evaluate(() => [ + getComputedStyle(document.getElementById('t')).paddingInlineEnd, + getComputedStyle(document.getElementById('ref')).paddingInlineEnd, + ]); + expect(parseFloat(selPad)).toBeGreaterThan(parseFloat(inputPad)); + }); + + test('placeholder colour is a real, non-transparent value', async ({ page }) => { + await mount(page, ``); + const c = await page.evaluate(() => { + const cs = getComputedStyle(document.getElementById('t'), '::placeholder'); + return cs.color; + }); + expect(TRANSPARENT.has(c)).toBe(false); + }); + }); +} diff --git a/tests/motion.spec.js b/tests/motion.spec.js new file mode 100644 index 00000000..0c034fce --- /dev/null +++ b/tests/motion.spec.js @@ -0,0 +1,73 @@ +// @ts-check +// Behavioural tests for core/motion.css — previously the only core module with +// no dedicated spec. Covers the two contracts that matter for accessibility and +// the motion knob: +// 1. prefers-reduced-motion: reduce gates OUT transitions and entrance +// animations (they live inside @media (prefers-reduced-motion: no-preference)). +// 2. --sf-motion-scale linearly scales every duration token (calc(Nms * scale)), +// so it can slow motion down or disable it entirely (scale 0 → 0s). +// Loads the optimal bundle (which includes core/motion.css) WITHOUT the usual +// transition-killing helper, since the whole point here is to observe motion. +import { test, expect } from '@playwright/test'; +import { renderWithBundle } from './render-helpers.js'; + +/** Read one computed property of #t. */ +function prop(page, name) { + return page.evaluate((n) => getComputedStyle(document.getElementById('t')).getPropertyValue(n), name); +} + +const seconds = (v) => parseFloat(v); // "0.15s" → 0.15 + +test.describe('motion — prefers-reduced-motion: no-preference', () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + }); + + test('interactive elements get a non-zero transition (duration = --sf-duration-fast)', async ({ page }) => { + await renderWithBundle(page, ``); + const dur = seconds(await prop(page, 'transition-duration')); + expect(dur).toBeCloseTo(0.15, 2); // 150ms * default scale 1 + }); + + test('--sf-motion-scale scales the duration linearly', async ({ page }) => { + await renderWithBundle(page, ``); + // --sf-duration-* = calc(Nms * --sf-motion-scale); the scale token is + // consumed where --sf-duration-fast is declared (:root), so set it there. + await page.evaluate(() => document.documentElement.style.setProperty('--sf-motion-scale', '2')); + expect(seconds(await prop(page, 'transition-duration'))).toBeCloseTo(0.3, 2); + }); + + test('--sf-motion-scale: 0 disables motion (0s duration)', async ({ page }) => { + await renderWithBundle(page, ``); + await page.evaluate(() => document.documentElement.style.setProperty('--sf-motion-scale', '0')); + expect(seconds(await prop(page, 'transition-duration'))).toBeLessThan(0.001); + }); + + test('.sf-fade-in binds the sf-fade-in keyframes', async ({ page }) => { + await renderWithBundle(page, `
x
`); + expect(await prop(page, 'animation-name')).toBe('sf-fade-in'); + }); + + test('.sf-entrance--fade maps to the fade keyframes', async ({ page }) => { + await renderWithBundle(page, `
x
`); + expect(await prop(page, 'animation-name')).toBe('sf-fade-in'); + }); +}); + +test.describe('motion — prefers-reduced-motion: reduce', () => { + test.beforeEach(async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + }); + + test('interactive transitions are gated out (0s)', async ({ page }) => { + await renderWithBundle(page, ``); + // The 150ms interactive transition lives inside @media no-preference, so + // under reduce it never applies (any residual is engine noise, not motion). + expect(seconds(await prop(page, 'transition-duration'))).toBeLessThan(0.01); + }); + + test('.sf-fade-in animation does not apply under reduce', async ({ page }) => { + await renderWithBundle(page, `
x
`); + expect(await prop(page, 'animation-name')).toBe('none'); + }); +}); diff --git a/tests/version-sync.test.js b/tests/version-sync.test.js new file mode 100644 index 00000000..7dde0020 --- /dev/null +++ b/tests/version-sync.test.js @@ -0,0 +1,119 @@ +/** + * Positive tests for scripts/version-sync.js — the *writer* half of the version + * contract. check-version-sync.test.js proves the checker fails on drift; this + * proves the propagator actually realigns every downstream artifact from the + * package.json source of truth. + * + * The gap this closes: we had a tested checker but an untested writer. A broken + * writer + working checker means `npm run version-sync` silently no-ops and the + * drift is only caught at release time. These tests run the writer against a + * throwaway fixture (SLASHED_ROOT) and assert every target file is rewritten. + */ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const WRITER = path.join(ROOT, 'scripts', 'version-sync.js'); + +const tmpDirs = []; + +/** + * Build a fixture where package.json is the target version but every downstream + * artifact still carries `oldVersion` — i.e. a tree that needs syncing. + */ +function buildFixture(target = '2.0.0', oldVersion = '1.2.3') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-write-')); + tmpDirs.push(dir); + + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: target }, null, 2) + '\n'); + + fs.mkdirSync(path.join(dir, 'docs')); + fs.writeFileSync( + path.join(dir, 'docs', 'roadmap.md'), + `# Roadmap\n\nCurrent version: **${oldVersion}**\n\nMore text.\n`, + ); + + fs.mkdirSync(path.join(dir, 'configurator')); + fs.writeFileSync( + path.join(dir, 'configurator', 'package.json'), + JSON.stringify({ name: 'slashed-configurator', version: oldVersion }, null, 2) + '\n', + ); + fs.writeFileSync( + path.join(dir, 'configurator', 'package-lock.json'), + JSON.stringify({ version: oldVersion, packages: { '': { version: oldVersion } } }, null, 2) + '\n', + ); + + return dir; +} + +function runWriter(dir) { + return spawnSync(process.execPath, [WRITER], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); +} + +const readJson = (p) => JSON.parse(fs.readFileSync(p, 'utf8')); + +describe('version-sync writer', () => { + test('propagates package.json version to every downstream artifact', () => { + const dir = buildFixture('2.0.0', '1.2.3'); + const r = runWriter(dir); + assert.equal(r.status, 0, `writer exited non-zero:\n${r.stderr}`); + + const roadmap = fs.readFileSync(path.join(dir, 'docs', 'roadmap.md'), 'utf8'); + assert.match(roadmap, /Current version: \*\*2\.0\.0\*\*/, 'roadmap.md not updated'); + assert.doesNotMatch(roadmap, /1\.2\.3/, 'roadmap.md still contains the old version'); + + assert.equal(readJson(path.join(dir, 'configurator', 'package.json')).version, '2.0.0'); + + const lock = readJson(path.join(dir, 'configurator', 'package-lock.json')); + assert.equal(lock.version, '2.0.0', 'configurator lock root version not updated'); + assert.equal(lock.packages[''].version, '2.0.0', 'configurator lock packages[""] not updated'); + }); + + test('after syncing, the checker passes on the same tree (writer ↔ checker agree)', () => { + const dir = buildFixture('3.4.5', '0.0.1'); + // Root lock must exist for the checker (the writer does not create it). + fs.writeFileSync( + path.join(dir, 'package-lock.json'), + JSON.stringify({ version: '3.4.5', packages: { '': { version: '3.4.5' } } }), + ); + assert.equal(runWriter(dir).status, 0); + + const checker = path.join(ROOT, 'scripts', 'check-version-sync.js'); + const c = spawnSync(process.execPath, [checker], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); + assert.equal(c.status, 0, `checker rejected the writer's output:\n${c.stderr}`); + }); + + test('preserves a pre-release suffix (e.g. 0.5.0-beta5)', () => { + const dir = buildFixture('0.5.0-beta5', '0.4.0'); + assert.equal(runWriter(dir).status, 0); + assert.match( + fs.readFileSync(path.join(dir, 'docs', 'roadmap.md'), 'utf8'), + /Current version: \*\*0\.5\.0-beta5\*\*/, + ); + assert.equal(readJson(path.join(dir, 'configurator', 'package.json')).version, '0.5.0-beta5'); + }); + + test('is idempotent — a second run reports nothing left to change', () => { + const dir = buildFixture('2.0.0', '1.0.0'); + assert.equal(runWriter(dir).status, 0); + const second = runWriter(dir); + assert.equal(second.status, 0); + assert.match(second.stdout, /already up to date/, 'second run should be a no-op'); + }); +}); + +after(() => { + for (const d of tmpDirs) { + try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); From 8c174da27cffea0f000bf1410872061d6fa8851e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:25:41 +0000 Subject: [PATCH 2/3] fix: normalize SLASHED_ROOT override in root-resolving scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Qodo review on #568: `process.env.SLASHED_ROOT ?? …` accepted an empty string or a relative path, which path.join would resolve against the process CWD instead of an absolute root (parse.js requires an absolute root). Treat empty/whitespace as unset and resolve any override to absolute in version-sync.js and the check-{llm-guide,macro-catalog,token-registry,version-sync}.js gates. Add a guard test asserting an empty SLASHED_ROOT falls back to the repo root. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SETyJ7R12Bxeuux7yF6y7F --- scripts/check-llm-guide.js | 8 ++++++-- scripts/check-macro-catalog.js | 8 ++++++-- scripts/check-token-registry.js | 5 ++++- scripts/check-version-sync.js | 6 +++++- scripts/version-sync.js | 6 +++++- tests/check-macro-catalog.test.js | 10 ++++++++++ 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/check-llm-guide.js b/scripts/check-llm-guide.js index 5d6af15f..201e249b 100644 --- a/scripts/check-llm-guide.js +++ b/scripts/check-llm-guide.js @@ -26,8 +26,12 @@ import fs from 'node:fs'; import path from 'node:path'; -// SLASHED_ROOT lets negative tests run the gate against a fixture tree. -const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); +// SLASHED_ROOT lets negative tests run the gate against a fixture tree. An +// empty/whitespace value counts as unset; a relative override is resolved to +// absolute so path.join below never targets the process CWD. +const ROOT = process.env.SLASHED_ROOT?.trim() + ? path.resolve(process.env.SLASHED_ROOT) + : path.resolve(import.meta.dirname, '..'); const GUIDE = path.join(ROOT, 'docs', 'llm-guide.md'); const REGISTRY = path.join(ROOT, 'token-registry.json'); const API_INDEX = path.join(ROOT, 'docs', 'api-index.json'); diff --git a/scripts/check-macro-catalog.js b/scripts/check-macro-catalog.js index 5136b434..345c74da 100644 --- a/scripts/check-macro-catalog.js +++ b/scripts/check-macro-catalog.js @@ -16,8 +16,12 @@ import fs from 'node:fs'; import path from 'node:path'; import { stripComments, stripStrings } from './lib/parse.js'; -// SLASHED_ROOT lets negative tests run the gate against a fixture tree. -const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); +// SLASHED_ROOT lets negative tests run the gate against a fixture tree. An +// empty/whitespace value counts as unset; a relative override is resolved to +// absolute so path.join below never targets the process CWD. +const ROOT = process.env.SLASHED_ROOT?.trim() + ? path.resolve(process.env.SLASHED_ROOT) + : path.resolve(import.meta.dirname, '..'); // Source files that define the macro layer const CSS_SOURCES = [ diff --git a/scripts/check-token-registry.js b/scripts/check-token-registry.js index 2ce83ba7..1b195aeb 100644 --- a/scripts/check-token-registry.js +++ b/scripts/check-token-registry.js @@ -25,7 +25,10 @@ import { execFileSync } from 'node:child_process'; // SLASHED_ROOT lets negative tests run the gate against a fixture tree (with no // git baseline, invariants 1–2 are skipped; 3–4 and the id guards still run). -const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); +// Empty/whitespace counts as unset; a relative override is resolved to absolute. +const ROOT = process.env.SLASHED_ROOT?.trim() + ? path.resolve(process.env.SLASHED_ROOT) + : path.resolve(import.meta.dirname, '..'); const REGISTRY = path.join(ROOT, 'token-registry.json'); const API_INDEX = path.join(ROOT, 'docs', 'api-index.json'); diff --git a/scripts/check-version-sync.js b/scripts/check-version-sync.js index 8e175192..f42c7ec4 100644 --- a/scripts/check-version-sync.js +++ b/scripts/check-version-sync.js @@ -17,7 +17,11 @@ import fs from 'node:fs'; import path from 'node:path'; -const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); +// Empty/whitespace SLASHED_ROOT counts as unset; a relative override is +// resolved to absolute (the reads below require an absolute repo root). +const ROOT = process.env.SLASHED_ROOT?.trim() + ? path.resolve(process.env.SLASHED_ROOT) + : path.resolve(import.meta.dirname, '..'); const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); const errors = []; diff --git a/scripts/version-sync.js b/scripts/version-sync.js index a5c9d9f1..13790461 100644 --- a/scripts/version-sync.js +++ b/scripts/version-sync.js @@ -17,7 +17,11 @@ import { readFile as readFileLib } from './lib/parse.js'; // SLASHED_ROOT lets tests point the writer at a throwaway fixture tree; falls // back to the repo root in normal use. Mirrors scripts/check-version-sync.js. -const ROOT = process.env.SLASHED_ROOT ?? path.resolve(import.meta.dirname, '..'); +// An empty/whitespace value counts as unset, and a relative override is +// resolved to absolute — the parse.js helpers require an absolute root. +const ROOT = process.env.SLASHED_ROOT?.trim() + ? path.resolve(process.env.SLASHED_ROOT) + : path.resolve(import.meta.dirname, '..'); function readFile(rel) { return readFileLib(rel, ROOT); diff --git a/tests/check-macro-catalog.test.js b/tests/check-macro-catalog.test.js index 32d5198e..3362efa0 100644 --- a/tests/check-macro-catalog.test.js +++ b/tests/check-macro-catalog.test.js @@ -61,6 +61,16 @@ describe('check-macro-catalog failure cases', () => { assert.match(r.stderr, /sf-phantom/); }); + test('an empty SLASHED_ROOT is treated as unset (falls back to the real repo root)', () => { + // Guards the hardened root resolution: '' must not become path.join('', …) + // relative to CWD. Running the read-only gate against the real repo passes. + const r = spawnSync(process.execPath, [GATE], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: ' ' }, + }); + assert.equal(r.status, 0, `empty SLASHED_ROOT should fall back to repo root:\n${r.stderr}`); + }); + test('fails when a required source file is missing', () => { const dir = buildFixture(); fs.rmSync(path.join(dir, 'core', 'motion.css')); From 7971e48eab636416ba35c1e5dc9d6dfb85b30f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 10:33:08 +0000 Subject: [PATCH 3/3] fix: resolve the trimmed SLASHED_ROOT value, not the raw env var Address CodeRabbit review on #568: the hardening checked process.env.SLASHED_ROOT?.trim() for truthiness but passed the *untrimmed* value to path.resolve, so a whitespace-padded override (e.g. " /tmp/x ") would resolve to a padded, wrong path. Capture the trimmed value once and resolve that, across all five root-resolving scripts. Add a test asserting a whitespace-padded SLASHED_ROOT resolves to the intended fixture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SETyJ7R12Bxeuux7yF6y7F --- scripts/check-llm-guide.js | 5 +++-- scripts/check-macro-catalog.js | 5 +++-- scripts/check-token-registry.js | 5 +++-- scripts/check-version-sync.js | 5 +++-- scripts/version-sync.js | 5 +++-- tests/check-macro-catalog.test.js | 11 +++++++++++ 6 files changed, 26 insertions(+), 10 deletions(-) diff --git a/scripts/check-llm-guide.js b/scripts/check-llm-guide.js index 201e249b..e84e3563 100644 --- a/scripts/check-llm-guide.js +++ b/scripts/check-llm-guide.js @@ -29,8 +29,9 @@ import path from 'node:path'; // SLASHED_ROOT lets negative tests run the gate against a fixture tree. An // empty/whitespace value counts as unset; a relative override is resolved to // absolute so path.join below never targets the process CWD. -const ROOT = process.env.SLASHED_ROOT?.trim() - ? path.resolve(process.env.SLASHED_ROOT) +const slashedRoot = process.env.SLASHED_ROOT?.trim(); +const ROOT = slashedRoot + ? path.resolve(slashedRoot) : path.resolve(import.meta.dirname, '..'); const GUIDE = path.join(ROOT, 'docs', 'llm-guide.md'); const REGISTRY = path.join(ROOT, 'token-registry.json'); diff --git a/scripts/check-macro-catalog.js b/scripts/check-macro-catalog.js index 345c74da..a5c089ba 100644 --- a/scripts/check-macro-catalog.js +++ b/scripts/check-macro-catalog.js @@ -19,8 +19,9 @@ import { stripComments, stripStrings } from './lib/parse.js'; // SLASHED_ROOT lets negative tests run the gate against a fixture tree. An // empty/whitespace value counts as unset; a relative override is resolved to // absolute so path.join below never targets the process CWD. -const ROOT = process.env.SLASHED_ROOT?.trim() - ? path.resolve(process.env.SLASHED_ROOT) +const slashedRoot = process.env.SLASHED_ROOT?.trim(); +const ROOT = slashedRoot + ? path.resolve(slashedRoot) : path.resolve(import.meta.dirname, '..'); // Source files that define the macro layer diff --git a/scripts/check-token-registry.js b/scripts/check-token-registry.js index 1b195aeb..4e76b3b3 100644 --- a/scripts/check-token-registry.js +++ b/scripts/check-token-registry.js @@ -26,8 +26,9 @@ import { execFileSync } from 'node:child_process'; // SLASHED_ROOT lets negative tests run the gate against a fixture tree (with no // git baseline, invariants 1–2 are skipped; 3–4 and the id guards still run). // Empty/whitespace counts as unset; a relative override is resolved to absolute. -const ROOT = process.env.SLASHED_ROOT?.trim() - ? path.resolve(process.env.SLASHED_ROOT) +const slashedRoot = process.env.SLASHED_ROOT?.trim(); +const ROOT = slashedRoot + ? path.resolve(slashedRoot) : path.resolve(import.meta.dirname, '..'); const REGISTRY = path.join(ROOT, 'token-registry.json'); const API_INDEX = path.join(ROOT, 'docs', 'api-index.json'); diff --git a/scripts/check-version-sync.js b/scripts/check-version-sync.js index f42c7ec4..c3910329 100644 --- a/scripts/check-version-sync.js +++ b/scripts/check-version-sync.js @@ -19,8 +19,9 @@ import path from 'node:path'; // Empty/whitespace SLASHED_ROOT counts as unset; a relative override is // resolved to absolute (the reads below require an absolute repo root). -const ROOT = process.env.SLASHED_ROOT?.trim() - ? path.resolve(process.env.SLASHED_ROOT) +const slashedRoot = process.env.SLASHED_ROOT?.trim(); +const ROOT = slashedRoot + ? path.resolve(slashedRoot) : path.resolve(import.meta.dirname, '..'); const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); diff --git a/scripts/version-sync.js b/scripts/version-sync.js index 13790461..0b2074a4 100644 --- a/scripts/version-sync.js +++ b/scripts/version-sync.js @@ -19,8 +19,9 @@ import { readFile as readFileLib } from './lib/parse.js'; // back to the repo root in normal use. Mirrors scripts/check-version-sync.js. // An empty/whitespace value counts as unset, and a relative override is // resolved to absolute — the parse.js helpers require an absolute root. -const ROOT = process.env.SLASHED_ROOT?.trim() - ? path.resolve(process.env.SLASHED_ROOT) +const slashedRoot = process.env.SLASHED_ROOT?.trim(); +const ROOT = slashedRoot + ? path.resolve(slashedRoot) : path.resolve(import.meta.dirname, '..'); function readFile(rel) { diff --git a/tests/check-macro-catalog.test.js b/tests/check-macro-catalog.test.js index 3362efa0..b6eef571 100644 --- a/tests/check-macro-catalog.test.js +++ b/tests/check-macro-catalog.test.js @@ -71,6 +71,17 @@ describe('check-macro-catalog failure cases', () => { assert.equal(r.status, 0, `empty SLASHED_ROOT should fall back to repo root:\n${r.stderr}`); }); + test('a whitespace-padded SLASHED_ROOT is trimmed before resolving (not a wrong dir)', () => { + // The truthiness check and path.resolve must use the SAME trimmed value — + // otherwise " " passes the check but resolves to a padded path. + const dir = buildFixture(); + const r = spawnSync(process.execPath, [GATE], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: ` ${dir} ` }, + }); + assert.equal(r.status, 0, `padded SLASHED_ROOT should resolve to the fixture:\n${r.stderr}`); + }); + test('fails when a required source file is missing', () => { const dir = buildFixture(); fs.rmSync(path.join(dir, 'core', 'motion.css'));