From 8f60361fb282ea5e0bb7fe5af410042083a1ced8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 09:15:46 +0000 Subject: [PATCH] Reapply "Merge pull request #565 from codeslash-dev/claude/repo-security-audit-dlbw5f" This reverts commit 9445ffaa045594d3aba4681b7849e4fb35c886f3. --- CLAUDE.md | 22 +++-- analysis/gap-analysis.md | 5 + demos/full-api-demo-with-overrides.html | 2 +- demos/full-api-demo.html | 2 +- demos/ultimate-override.css | 2 +- optional/components.css | 16 +++- package-lock.json | 16 +--- package.json | 1 - reports/full-api-audit/REPORT.md | 11 +++ scripts/artifacts.json | 12 +++ scripts/check-artifacts.js | 13 +++ scripts/gen-class-reference.js | 56 ++++++----- scripts/lib/api-index/extract.js | 22 ++++- scripts/lib/parse.js | 14 ++- tests/configurator-data-contract.test.js | 66 +++++++++++++ tests/supports-helpers.js | 113 +++++++++++++++++++++++ tests/supports-helpers.test.js | 62 +++++++++++++ tests/tier1-p2-coverage.test.js | 59 ++++++------ tests/tier1-p7-oldengine.test.js | 43 ++++----- 19 files changed, 422 insertions(+), 115 deletions(-) create mode 100644 tests/configurator-data-contract.test.js create mode 100644 tests/supports-helpers.js create mode 100644 tests/supports-helpers.test.js diff --git a/CLAUDE.md b/CLAUDE.md index a77907c1..38f57782 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,15 +6,19 @@ SLASHED has **one version number**. It must be identical in every artifact. If you touch anything version-related, you are responsible for keeping all of these in sync: -| File | Field | -|------|-------| -| `package.json` | `.version` ← **source of truth** | -| `package-lock.json` | `.version` + `.packages[""].version` | -| `docs/roadmap.md` | `Current version: **X.Y.Z**` line | -| `configurator/package.json` | `.version` | -| `configurator/package-lock.json` | `.version` + `.packages[""].version` | -| `dist/*.css` (unminified) | `/*! SLASHED vX.Y.Z */` comment header | -| Configurator UI version pill | baked in via Vite `__SLASHED_VERSION__` at build time | +| File | Field | How it's kept correct | +|------|-------|-----------------------| +| `package.json` | `.version` ← **source of truth** | you bump it (`npm version`) | +| `package-lock.json` | `.version` + `.packages[""].version` | `npm version`; checked by `check:version` (CI) | +| `docs/roadmap.md` | `Current version: **X.Y.Z**` line | `version-sync`; checked by `check:version` (CI) | +| `configurator/package.json` | `.version` | `version-sync`; checked by `check:version` (CI) | +| `configurator/package-lock.json` | `.version` + `.packages[""].version` | `version-sync`; checked by `check:version` (CI) | +| `dist/*.css` (unminified) | `/*! SLASHED vX.Y.Z */` comment header | **build-derived** — stamped from `package.json`/tag by `bundle.js`; `dist/*.css` is git-ignored, so it cannot drift. `release.yml` re-verifies the stamp before publishing | +| Configurator UI version pill | baked in via Vite `__SLASHED_VERSION__` at build time | **build-derived** — injected from root `package.json` at Vite build; cannot drift | + +The first five rows are the ones you ever sync; the last two are **build-derived** +(regenerated from `package.json` at build time and not committed as text you edit), +so `version-sync`/`check:version` intentionally don't touch them — never hand-edit them. **Never edit version numbers by hand.** Use `npm version` then the sync script: diff --git a/analysis/gap-analysis.md b/analysis/gap-analysis.md index e11a134d..5e18c5da 100644 --- a/analysis/gap-analysis.md +++ b/analysis/gap-analysis.md @@ -1,3 +1,8 @@ + + # SLASHED — Competitive Gap Analysis (v0.5.28) > **Question:** what is still missing from SLASHED that holds it back versus diff --git a/demos/full-api-demo-with-overrides.html b/demos/full-api-demo-with-overrides.html index 4cca298f..ac4ccb47 100644 --- a/demos/full-api-demo-with-overrides.html +++ b/demos/full-api-demo-with-overrides.html @@ -122,7 +122,7 @@

SLASHED Full API Demo

-

v0.7.0 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

+

v0.7.3 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

ultimate-override.css is ACTIVE. Every value below is recomputed from perturbed knob tokens — toggle it off in the toolbar, or compare against the un-overridden page.
diff --git a/demos/full-api-demo.html b/demos/full-api-demo.html index ec73973c..9c35ce5c 100644 --- a/demos/full-api-demo.html +++ b/demos/full-api-demo.html @@ -121,7 +121,7 @@

SLASHED Full API Demo

-

v0.7.0 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

+

v0.7.3 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

Baseline render with default tokens. Use the toolbar to switch theme, toggle the ultimate override live, or replay motion. The always-on override variant is full-api-demo-with-overrides.html.
diff --git a/demos/ultimate-override.css b/demos/ultimate-override.css index 041aea18..fabf3279 100644 --- a/demos/ultimate-override.css +++ b/demos/ultimate-override.css @@ -1,6 +1,6 @@ /* ============================================================================ ULTIMATE OVERRIDE — generated by demos/generate.mjs (do not edit by hand) - SLASHED v0.7.0 + SLASHED v0.7.3 Perturbs every CONFIGURABLE (role: "knob") token to a valid, visibly-distinct value so full-api-demo.html can prove each one is wired end to end. diff --git a/optional/components.css b/optional/components.css index d1c4a0cf..7b94349f 100644 --- a/optional/components.css +++ b/optional/components.css @@ -111,7 +111,6 @@ border-color: var(--sf-btn-color); } .sf-btn--secondary:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { - background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); border-color: var(--sf-btn-color); } @@ -120,8 +119,19 @@ color: var(--sf-btn-color); border-color: transparent; } - .sf-btn--ghost:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { - background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); + + /* The subtle hover tint uses color-mix() — a modern colour function. Gate it + with @supports so engines without color-mix() skip the tint (the border / + foreground hover cues still apply) instead of dropping the background + declaration mid-rule. Mirrors the framework-wide gating policy enforced by + tests/tier1-p2-coverage.test.js + tests/tier1-p7-oldengine.test.js. */ + @supports (background: color-mix(in oklab, red, red)) { + .sf-btn--secondary:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { + background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); + } + .sf-btn--ghost:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { + background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); + } } /* Outline: transparent to start, fills with the family colour on hover. */ diff --git a/package-lock.json b/package-lock.json index 44581a3d..3913ee4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@release-it/conventional-changelog": "^11.0.1", "culori": "^4.0.2", "fast-check": "^4.8.0", - "lightningcss": "^1.32.0", "release-it": "^20.2.1", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0" @@ -2121,8 +2120,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -3155,8 +3154,8 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, "license": "MPL-2.0", + "optional": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3188,7 +3187,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3209,7 +3207,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3230,7 +3227,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3251,7 +3247,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3272,7 +3267,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3293,7 +3287,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3314,7 +3307,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3335,7 +3327,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3356,7 +3347,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3377,7 +3367,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3398,7 +3387,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 6e47119b..44b92648 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@release-it/conventional-changelog": "^11.0.1", "culori": "^4.0.2", "fast-check": "^4.8.0", - "lightningcss": "^1.32.0", "release-it": "^20.2.1", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0" diff --git a/reports/full-api-audit/REPORT.md b/reports/full-api-audit/REPORT.md index d63d8504..9875f8cd 100644 --- a/reports/full-api-audit/REPORT.md +++ b/reports/full-api-audit/REPORT.md @@ -1,3 +1,14 @@ + + # SLASHED Full-API Testing Operation — Report **Framework:** SLASHED v0.6.25 · **Date:** 2026-06-30 diff --git a/scripts/artifacts.json b/scripts/artifacts.json index 2169a057..45c8c9c2 100644 --- a/scripts/artifacts.json +++ b/scripts/artifacts.json @@ -13,6 +13,7 @@ "token-registry.json", "dist/css-custom-data.json", "configurator/src/data/api-index.generated.json", + "configurator/src/data/classes.generated.json", "configurator/src/data/bundles.generated.json", "configurator/src/data/token-registry.generated.json" ] @@ -23,8 +24,19 @@ "buildCmd": "npm run configurator:sync", "outputs": [ "configurator/src/data/api-index.generated.json", + "configurator/src/data/classes.generated.json", "configurator/src/data/bundles.generated.json", "configurator/src/data/token-registry.generated.json" ] + }, + { + "name": "full-api demos", + "srcPrefixes": ["docs/api-index.json", "package.json", "core/", "optional/"], + "buildCmd": "node demos/generate.mjs", + "outputs": [ + "demos/full-api-demo.html", + "demos/full-api-demo-with-overrides.html", + "demos/ultimate-override.css" + ] } ] diff --git a/scripts/check-artifacts.js b/scripts/check-artifacts.js index 2d1f0ebf..09bbb3bb 100644 --- a/scripts/check-artifacts.js +++ b/scripts/check-artifacts.js @@ -60,6 +60,19 @@ if (mode === 'fix') { run(cmd, artifact.cwd ? resolve(root, artifact.cwd) : root); for (const out of artifact.outputs) { + // A declared output that isn't tracked can never fail the diff check + // below (`git diff` ignores untracked/ignored paths), so it would be + // silently unguarded — exactly the drift class this gate exists to catch. + // Fail loudly instead. + if (!isTracked(out)) { + console.error( + `::error::${out} is a declared artifact of "${artifact.name}" but is not git-tracked ` + + `(untracked or .gitignore'd) — the freshness check cannot guard it. ` + + `Commit the file, or remove it from scripts/artifacts.json.`, + ); + failed = true; + continue; + } try { gitFile('diff', '--exit-code', '--', out); } catch { diff --git a/scripts/gen-class-reference.js b/scripts/gen-class-reference.js index b34d8238..587f2c32 100644 --- a/scripts/gen-class-reference.js +++ b/scripts/gen-class-reference.js @@ -9,37 +9,45 @@ import fs from 'node:fs'; import path from 'node:path'; import { CLASS_FILES } from './registry-sources.js'; +import { stripComments, stripStrings, requireFile } from './lib/parse.js'; const ROOT = path.resolve(import.meta.dirname, '..'); -const FILE_META = { - 'core/layout.css': { title: 'Layout primitives', prefix: 'sf-' }, - 'core/macros.css': { title: 'Macro classes', prefix: 'sf-' }, - 'core/states.css': { title: 'State classes', prefix: 'sf-is-' }, - 'core/accessibility.css': { title: 'Accessibility', prefix: 'sf-' }, - 'core/motion.css': { title: 'Motion / entrances', prefix: 'sf-' }, - 'core/print.css': { title: 'Print utilities', prefix: '' }, - 'core/themes.css': { title: 'Theme utilities', prefix: 'sf-' }, - 'optional/forms.css': { title: 'Forms', prefix: 'sf-' }, - 'optional/components.css':{ title: 'Components', prefix: 'sf-' }, - 'optional/theme-example.css': { title: 'Theme example', prefix: '' }, - 'optional/utilities.css': { title: 'Utilities', prefix: 'sf-' }, +// Human-facing section title per class-source file. Keyed by the same paths +// as registry-sources.js CLASS_FILES — a file added there without a title here +// fails loudly below rather than emitting an "undefined" section. +const FILE_TITLES = { + 'core/layout.css': 'Layout primitives', + 'core/macros.css': 'Macro classes', + 'core/states.css': 'State classes', + 'core/accessibility.css': 'Accessibility', + 'core/motion.css': 'Motion / entrances', + 'core/print.css': 'Print utilities', + 'core/themes.css': 'Theme utilities', + 'optional/forms.css': 'Forms', + 'optional/components.css': 'Components', + 'optional/theme-example.css': 'Theme example', + 'optional/utilities.css': 'Utilities', }; -const SOURCES = CLASS_FILES.map(f => ({ - file: f, - title: `${FILE_META[f].title} (\`${f}\`)`, - prefix: FILE_META[f].prefix, -})); +const SOURCES = CLASS_FILES.map(f => { + const title = FILE_TITLES[f]; + if (!title) { + throw new Error( + `[docs:classes] ${f} is in registry-sources.js CLASS_FILES but has no title ` + + `in FILE_TITLES (scripts/gen-class-reference.js). Add one.`, + ); + } + return { file: f, title: `${title} (\`${f}\`)` }; +}); function extract(file) { - const abs = path.join(ROOT, file); - if (!fs.existsSync(abs)) { - throw new Error(`[docs:classes] Missing canonical class source file: ${abs}`); - } - const css = fs.readFileSync(abs, 'utf8') - .replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments - .replace(/"[^"]*"|'[^']*'/g, '""'); // strip string literals + // Same parsing contract as scripts/audit.js (the authoritative counter): + // strip comments then string literals so content:"…" can't yield false + // `.sf-*` matches. Shared via scripts/lib/parse.js so the two never diverge. + const css = stripStrings(stripComments( + requireFile(file, ROOT, `[docs:classes] Missing canonical class source file: ${file}`), + )); const names = new Set(); for (const m of css.matchAll(/\.(sf-[\w-]+)/g)) names.add(m[1]); return [...names].sort(); diff --git a/scripts/lib/api-index/extract.js b/scripts/lib/api-index/extract.js index 0c2f3a96..f48d8cf7 100644 --- a/scripts/lib/api-index/extract.js +++ b/scripts/lib/api-index/extract.js @@ -85,6 +85,24 @@ export const FILE_META = { 'optional/utilities.css': { category: 'Utilities', area: 'utilities', kind: 'utility' }, }; +/** + * FILE_META lookup that fails loudly for a source file added to + * registry-sources.js (TOKEN_FILES/CLASS_FILES) but not described here — instead + * of the opaque `Cannot read properties of undefined (reading 'kind')` crash. + * @param {string} rel source path relative to repo root + * @returns {{category:string, area:string, kind:string}} + */ +function metaFor(rel) { + const meta = FILE_META[rel]; + if (!meta) { + throw new Error( + `[api-index] ${rel} is in registry-sources.js but has no FILE_META entry ` + + `(scripts/lib/api-index/extract.js). Add its { category, area, kind }.`, + ); + } + return meta; +} + // ── Source masking (length-preserving) ───────────────────────────────────────── // Replacing comment/string bodies with spaces (NOT removing them) keeps every // character index stable, so a token/class match found in the masked text can @@ -420,7 +438,7 @@ export function extractClassesFromFile(rel, root, bundlesFor) { const masked = maskStrings(maskComments(original)); const comments = collectComments(original); const layer = layerOf(masked); - const meta = FILE_META[rel]; + const meta = metaFor(rel); const rows = new Map(); // name -> entry /** @@ -488,7 +506,7 @@ export function buildTokenEntries(root, bundlesFor, tokenAnnotations = {}, exist for (const rel of TOKEN_FILES) { const rows = extractTokensFromFile(rel, root); - const meta = FILE_META[rel]; + const meta = metaFor(rel); for (const [name, data] of rows) { const existing = merged.get(name); if (!existing) { diff --git a/scripts/lib/parse.js b/scripts/lib/parse.js index ba429977..ffd93074 100644 --- a/scripts/lib/parse.js +++ b/scripts/lib/parse.js @@ -2,8 +2,8 @@ * Shared CSS/file-reading helpers for the scripts/*.js generators and checks. * * Before this module, `stripComments`/`maskComments`, `readValue`, and - * `readFile` were each reimplemented independently across 4+ scripts — see - * docs/technical-debt-audit.md SL-007. `stripComments` and `maskComments` + * `readFile` were each reimplemented independently across 4+ scripts (the + * SL-007 consolidation; see scripts/README.md). `stripComments` and `maskComments` * are NOT interchangeable and must stay separate exports: * - stripComments REMOVES comment bodies, shortening the string. Fine for * scripts that only count/extract values and don't need offsets to line @@ -22,9 +22,13 @@ export function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ''); } -/** Remove quoted string literals, shortening the string. Not offset-safe. */ +/** + * Remove quoted string literals, shortening the string. Not offset-safe. + * Honours backslash escapes so a literal quote inside a string + * (e.g. `content: "\""`) doesn't end the match early. + */ export function stripStrings(css) { - return css.replace(/"[^"]*"|'[^']*'/g, '""'); + return css.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); } /** @@ -44,7 +48,7 @@ export function maskComments(css) { * @returns {string} CSS with string bodies blanked */ export function maskStrings(css) { - return css.replace(/"[^"]*"|'[^']*'/g, (m) => m.replace(/[^\n]/g, ' ')); + return css.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, (m) => m.replace(/[^\n]/g, ' ')); } /** diff --git a/tests/configurator-data-contract.test.js b/tests/configurator-data-contract.test.js new file mode 100644 index 00000000..593971e9 --- /dev/null +++ b/tests/configurator-data-contract.test.js @@ -0,0 +1,66 @@ +/** + * Contract test for the generated configurator data files. + * + * configurator/src/data/*.generated.json is emitted by + * configurator/scripts/sync-api.mjs and *vendored into SLASHED-Plugins*. A + * silent shape change there (a renamed/dropped column in `projectToken`, or an + * empty catalogue) breaks the plugin with no signal at the framework boundary. + * This pins the shape the downstream consumer relies on: required keys must be + * present on every row, and the catalogues must be non-empty. It runs in the + * root unit suite (node --test → CI), independent of the configurator's own + * Vitest suite. + * + * Run: node --test tests/configurator-data-contract.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const DATA = path.join(ROOT, 'configurator', 'src', 'data'); + +const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(DATA, rel), 'utf8')); + +// The columns the configurator UI + vendored plugin read off each row. Adding a +// field is safe; renaming/removing one is a breaking change and must update the +// consumer — this list is the tripwire that forces that decision. +const TOKEN_KEYS = [ + 'name', 'tier', 'role', 'namespace', 'category', 'group', 'description', + 'note', 'value', 'aliasOf', 'registered', 'syntax', 'fallbackOnly', + 'optional', 'layer', 'bundles', +]; +const CLASS_KEYS = ['name', 'selector', 'kind', 'category', 'group', 'description', 'optional', 'layer']; +const BUNDLE_KEYS = ['id', 'output', 'min', 'cdn', 'fileCount', 'optionalModules']; + +function assertRowShape(rows, requiredKeys, label) { + assert.ok(Array.isArray(rows) && rows.length > 0, `${label}: expected a non-empty array`); + for (const row of rows) { + for (const key of requiredKeys) { + assert.ok( + Object.prototype.hasOwnProperty.call(row, key), + `${label}: row "${row.name ?? row.id ?? JSON.stringify(row)}" is missing required key "${key}"`, + ); + } + } +} + +describe('generated configurator data honours its consumer contract', () => { + test('api-index.generated.json: every token row has the required columns', () => { + assertRowShape(readJson('api-index.generated.json').tokens, TOKEN_KEYS, 'tokens'); + }); + + test('classes.generated.json: every class row has the required columns', () => { + assertRowShape(readJson('classes.generated.json').classes, CLASS_KEYS, 'classes'); + }); + + test('bundles.generated.json: every bundle row has the required columns', () => { + assertRowShape(readJson('bundles.generated.json').bundles, BUNDLE_KEYS, 'bundles'); + }); + + test('token-registry.generated.json is a verbatim mirror of the root registry', () => { + const source = JSON.parse(fs.readFileSync(path.join(ROOT, 'token-registry.json'), 'utf8')); + const mirror = readJson('token-registry.generated.json'); + assert.deepEqual(mirror, source, 'configurator registry mirror has drifted from token-registry.json'); + }); +}); diff --git a/tests/supports-helpers.js b/tests/supports-helpers.js new file mode 100644 index 00000000..7b30a35d --- /dev/null +++ b/tests/supports-helpers.js @@ -0,0 +1,113 @@ +/** + * Shared helpers for the @supports-gating property tests (P2 source-level and + * P7 build-level). Keeping one implementation here stops the two tests from + * drifting apart — historically P7 used a line-based scanner that miscounted + * braces inside an @supports *prelude* (the `@property` feature-query form) and + * only passed by accident. + */ + +/** + * Remove every @supports block (prelude + body) so only ungated declarations + * remain. Character-based and parenthesis-aware: the block body is opened by + * the first `{` seen at parenthesis depth 0, so prelude braces — as in + * `@supports (@property --x { syntax: ""; }) { … }` — never fool it. + * Comments are stripped up front so commented-out braces or `@supports` text + * cannot steer the scan. + * @param {string} css + * @returns {string} CSS with all @supports blocks removed + */ +export function stripSupports(css) { + const src = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const n = src.length; + + // Advance past a quoted string that starts at `pos` (src[pos] is the opening + // quote), honouring backslash escapes. Returns the index just after the + // closing quote (or n if unterminated). Braces/parens inside strings — e.g. + // `content: "}"` or a `url("data:…(…)…")` — must never perturb the brace/paren + // counting, mirroring findMatchingBrace() in scripts/bundle.js. + const skipString = (pos) => { + const quote = src[pos]; + let k = pos + 1; + while (k < n) { + if (src[k] === '\\') { k += 2; continue; } + if (src[k] === quote) return k + 1; + k++; + } + return n; + }; + + let out = ''; + let i = 0; + while (i < n) { + const ch = src[i]; + // Copy string literals verbatim so their contents can neither trigger + // @supports detection nor be re-scanned as braces/parens later. + if (ch === '"' || ch === "'") { + const end = skipString(i); + out += src.slice(i, end); + i = end; + continue; + } + // Match `@supports` only at a token boundary so it is never matched inside + // a selector or value. + if (src.startsWith('@supports', i) && (i === 0 || /[\s{}();,]/.test(src[i - 1]))) { + // Skip the prelude to the body-opening `{` at parenthesis depth 0. + let j = i + '@supports'.length; + let paren = 0; + while (j < n) { + const c = src[j]; + if (c === '"' || c === "'") { j = skipString(j); continue; } + if (c === '(') paren++; + else if (c === ')') paren--; + else if (c === '{' && paren === 0) break; + j++; + } + if (j >= n) break; // malformed tail — nothing sensible left to keep + // Walk the body to its matching close brace (nested at-rules included) + // and drop the whole block. + let depth = 1; + j++; + while (j < n && depth > 0) { + const c = src[j]; + if (c === '"' || c === "'") { j = skipString(j); continue; } + if (c === '{') depth++; + else if (c === '}') depth--; + j++; + } + i = j; // resume after the dropped block + continue; + } + out += ch; + i++; + } + return out; +} + +// Modern colour expressions that MUST live behind an @supports gate: an engine +// without them drops the declaration, so an ungated one is a real regression. +export const MODERN_EXPRESSIONS = [ + { name: 'light-dark()', re: /light-dark\(/ }, + { name: 'oklch(from …)', re: /oklch\(from\b/ }, + { name: 'color-mix()', re: /color-mix\(/ }, +]; + +/** + * Every ungated occurrence of a modern colour expression in `css` — custom + * properties AND ordinary declarations (e.g. `background: color-mix(...)` on a + * component class). Strips @supports blocks, then string literals, so a match + * can only be a live, ungated declaration. + * @param {string} css + * @returns {string[]} human-readable "expr → line" descriptions (empty = clean) + */ +export function findUngatedModernExpressions(css) { + const text = stripSupports(css) + .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); // neutralise strings + const hits = []; + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + for (const { name, re } of MODERN_EXPRESSIONS) { + if (re.test(line)) hits.push(`${name} → ${line.slice(0, 100)}`); + } + } + return hits; +} diff --git a/tests/supports-helpers.test.js b/tests/supports-helpers.test.js new file mode 100644 index 00000000..9969a527 --- /dev/null +++ b/tests/supports-helpers.test.js @@ -0,0 +1,62 @@ +/** + * Unit tests for the shared @supports scanner (tests/supports-helpers.js), + * which backs both P2 (source-level) and P7 (build-level) gating tests. The + * cases below pin the tricky invariants: braces in the @supports *prelude* + * (the @property feature-query form) and delimiter characters inside string + * literals must never perturb block matching. + * + * Run: node --test tests/supports-helpers.test.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { stripSupports, findUngatedModernExpressions } from './supports-helpers.js'; + +describe('stripSupports', () => { + test('removes a plain @supports block', () => { + const css = 'a{color:red}@supports (display:grid){b{color:blue}}c{color:green}'; + assert.equal(stripSupports(css), 'a{color:red}c{color:green}'); + }); + + test('handles an @property feature-query prelude that contains braces', () => { + const css = + 'a{color:red}@supports (@property --x { syntax: ""; inherits: false; }) {' + + ' @keyframes k { 50% { --x: oklch(from var(--x) 0.9 c h); } } } d{color:green}'; + const out = stripSupports(css); + assert.ok(!out.includes('oklch(from'), 'gated keyframe must be removed'); + assert.ok(out.includes('a{color:red}') && out.includes('d{color:green}'), 'surrounding rules kept'); + }); + + test('braces inside string literals do not end the block early', () => { + // The `}` inside content:"}" must not be read as the block close, or the + // trailing gated declaration would leak out. + const css = '@supports (display:grid){ x{content:"}"} y{background:color-mix(in oklab,red,blue)} } z{color:green}'; + const out = stripSupports(css); + assert.ok(!out.includes('color-mix('), 'gated color-mix must stay removed despite the "}" in a string'); + assert.ok(out.includes('z{color:green}'), 'rule after the block is preserved'); + }); + + test('parens inside a prelude string do not mis-locate the body brace', () => { + const css = '@supports (background: url("data:image/svg+xml;utf8,(){}")) { p{background:light-dark(red,blue)} } q{color:green}'; + const out = stripSupports(css); + assert.ok(!out.includes('light-dark('), 'gated declaration removed'); + assert.ok(out.includes('q{color:green}'), 'trailing rule preserved'); + }); + + test('does not match @supports appearing inside a string value', () => { + const css = 'a{content:"@supports (x) {"}b{color:green}'; + assert.equal(stripSupports(css), css, 'string-embedded @supports text is left untouched'); + }); +}); + +describe('findUngatedModernExpressions', () => { + test('flags an ungated modern expression in an ordinary declaration', () => { + const hits = findUngatedModernExpressions('.x:hover{background:color-mix(in oklab,var(--c) 8%,transparent)}'); + assert.equal(hits.length, 1); + assert.match(hits[0], /color-mix\(\)/); + }); + + test('is clean when the expression is properly gated', () => { + const css = '@supports (background: color-mix(in oklab, red, red)) { .x:hover{background:color-mix(in oklab,red,blue)} }'; + assert.deepEqual(findUngatedModernExpressions(css), []); + }); +}); diff --git a/tests/tier1-p2-coverage.test.js b/tests/tier1-p2-coverage.test.js index 7754227f..fc9aca4c 100644 --- a/tests/tier1-p2-coverage.test.js +++ b/tests/tier1-p2-coverage.test.js @@ -1,6 +1,16 @@ /** - * Property 2: tokens.css uses @supports gating — no bare light-dark() or - * oklch(from…)/color-mix() declarations outside a @supports block. + * Property 2: every CSS source file @supports-gates its modern colour + * expressions — no bare light-dark(), oklch(from…) or color-mix() + * declaration sits outside an @supports block. + * + * This is the source-level complement to tier1-p7-oldengine.test.js (which + * proves the same invariant on the built, bundled+minified artifact). P2 gives + * the early, per-file signal; both share one robust scanner + * (tests/supports-helpers.js) so they can never drift apart. + * + * Coverage: ALL of core/*.css and optional/*.css — not just tokens.css. A + * modern expression used directly in a component/utility rule (e.g. + * `background: color-mix(...)`) is caught here, not only in colour tokens. * * The P2/P7/P8/P10 numbering gaps (no P1, P3-P6, P9 test files) are * explained in tests/README.md — not dead history, don't renumber. @@ -11,33 +21,28 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { findUngatedModernExpressions } from './supports-helpers.js'; const ROOT = path.resolve(import.meta.dirname, '..'); -describe('P2: tokens.css modern expressions are @supports-gated', () => { - test('tokens.css has no ungated light-dark() declarations', () => { - const tokensCss = fs.readFileSync(path.join(ROOT, 'core/tokens.css'), 'utf8'); - const stripped = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ''); - let inSupports = 0; - for (const line of stripped.split('\n')) { - if (line.includes('@supports')) inSupports++; - if (line.trim() === '}') { if (inSupports > 0) inSupports--; } - if (line.includes('light-dark(') && inSupports === 0) { - assert.fail(`Ungated light-dark() found in tokens.css: ${line.trim()}`); - } - } - }); +const SOURCE_FILES = ['core', 'optional'].flatMap((dir) => + fs + .readdirSync(path.join(ROOT, dir)) + .filter((f) => f.endsWith('.css')) + .map((f) => `${dir}/${f}`), +); - test('tokens.css has no ungated oklch(from…) declarations', () => { - const tokensCss = fs.readFileSync(path.join(ROOT, 'core/tokens.css'), 'utf8'); - const stripped = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ''); - let inSupports = 0; - for (const line of stripped.split('\n')) { - if (line.includes('@supports')) inSupports++; - if (line.trim() === '}') { if (inSupports > 0) inSupports--; } - if ((line.includes('oklch(from') || line.includes('color-mix(')) && inSupports === 0) { - assert.fail(`Ungated oklch(from)/color-mix() found in tokens.css: ${line.trim()}`); - } - } - }); +describe('P2: source CSS modern expressions are @supports-gated', () => { + for (const rel of SOURCE_FILES) { + test(`${rel} has no ungated modern colour expression`, () => { + const css = fs.readFileSync(path.join(ROOT, rel), 'utf8'); + const hits = findUngatedModernExpressions(css); + assert.deepEqual( + hits, + [], + `Ungated modern colour expression(s) in ${rel} — wrap them in ` + + `@supports:\n ${hits.join('\n ')}`, + ); + }); + } }); diff --git a/tests/tier1-p7-oldengine.test.js b/tests/tier1-p7-oldengine.test.js index 398681f7..23380904 100644 --- a/tests/tier1-p7-oldengine.test.js +++ b/tests/tier1-p7-oldengine.test.js @@ -16,37 +16,11 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import fc from 'fast-check'; +import { stripSupports, findUngatedModernExpressions } from './supports-helpers.js'; const ROOT = path.resolve(import.meta.dirname, '..'); const DIST = path.join(ROOT, 'dist/slashed.full.css'); -/** - * Simulate an engine with no @supports by stripping all @supports blocks. - * Returns only declarations that survive (ungated ones). - */ -function stripSupports(css) { - let result = ''; - let depth = 0; - let inSupports = false; - let supportsDepth = 0; - for (const line of css.split('\n')) { - const trimmed = line.trim(); - if (trimmed.startsWith('@supports')) { - inSupports = true; - supportsDepth = depth; - } - if (!inSupports) result += line + '\n'; - for (const ch of line) { - if (ch === '{') depth++; - else if (ch === '}') { - depth--; - if (inSupports && depth <= supportsDepth) inSupports = false; - } - } - } - return result; -} - function extractCustomDecls(css) { const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); const decls = new Map(); @@ -99,6 +73,21 @@ describe('P7: @supports gating — no modern expressions in ungated bundle decla } }); + // Whole-bundle guard: the checks above only inspect `--sf-*` custom-property + // declarations, so a modern expression used directly in an ordinary rule + // (e.g. `background: color-mix(...)` on a component class) slipped through. + // This scans every declaration — custom props and plain properties alike. + test('no ungated modern colour expression anywhere in the bundle (all declarations)', () => { + const css = fs.readFileSync(DIST, 'utf8'); + const hits = findUngatedModernExpressions(css); + assert.deepEqual( + hits, + [], + `Ungated modern colour expression(s) found in dist/slashed.full.css — ` + + `wrap them in @supports:\n ${hits.join('\n ')}`, + ); + }); + // fast-check: random sample from the checked set — none contain modern expressions test('fast-check: sampled core tokens have no modern expressions when ungated (100 iterations)', () => { fc.assert(