d3ro-voice/scripts/ci/check-design-tokens.mjs
Yun Chan 7953706142 feat(release): publish desktop updates from a tag through one feed
Desktop clients had two competing update sources: the runtime pointed at a
legacy GitLab registry while the Forgejo packages were filled in by
hardcoded, version-pinned scripts. Operators could not tell which feed was
authoritative, and no release could be reproduced from a tag.

Auto-update now reads a single canonical Forgejo registry feed, updated by
a version-agnostic publisher that runs from the tag on Forgejo, GitLab, and
GitHub CI alike. Channel, minimum supported version, forced install,
full-versus-delta thresholds, staged rollout, and a remote kill switch come
from one policy file the client fetches alongside the feed. Tag creation is
gated on a clean tree, matching version surfaces, and a changelog section.
2026-09-16 23:23:00 +09:00

208 lines
6.9 KiB
JavaScript

// scripts/ci/check-design-tokens.mjs
//
// Design-token SSOT guard. Fails when a surface hardcodes a color, uses a
// numeric literal for spacing/radius/control that a token already owns, or
// re-introduces a bold weight that design.md v3 retired.
//
// The point is not zero-hex everywhere: a token *definition* file legitimately
// holds raw values. Everything else must consume --d3-* / d3ro* tokens.
//
// Usage:
// node scripts/ci/check-design-tokens.mjs # check, exit 1 on violation
// node scripts/ci/check-design-tokens.mjs --json # machine-readable report
// node scripts/ci/check-design-tokens.mjs --self-test
//
// Allowlisted paths are the ONLY places raw color literals may live.
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(HERE, '..', '..')
// Directories scanned for consumer code (not token definitions).
const TARGETS = [
'apps/desktop/src',
'apps/web/src',
'apps/admin/src',
'apps/mobile-rn/src',
'packages/ui/src',
'packages/ui-native/src',
'site/src',
'site/public',
]
// Token *definition* layers. Raw values are the point here, not a violation.
const ALLOWLIST = new Set([
'packages/ui/src/theme.ts',
'packages/ui/src/theme-vars.ts',
'packages/ui-native/src/theme.ts',
'apps/mobile-rn/src/theme/mobile-theme.ts',
'apps/admin/src/lib/console-theme.ts',
// Canvas cannot resolve CSS custom properties; these are SSR fallbacks that
// mirror --d3-gradient-wave1..4 and are never painted on the client.
'packages/ui/src/components/ds/GradientWave.tsx',
'packages/ui/src/components/ds/AudioVisualizerBar.tsx',
'apps/desktop/src/main/services/MeetingModeService.ts',
'apps/desktop/src/main/services/CloudSyncService.ts',
'apps/desktop/src/main/windows/WindowManager.ts',
'site/src/tokens.ts',
'site/src/index.css',
'site/tailwind.config.js',
'site/public/accept-invite.css',
'site/public/legal.css',
'apps/desktop/src/renderer/styles/global.css',
])
const IGNORED_DIRS = new Set([
'node_modules', '.next', 'dist', 'build', 'out', 'coverage',
'.turbo', 'android', 'ios', '__snapshots__',
])
const SCAN_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.html'])
// A token definition file may define local `:root` fallbacks for popups.
const isPopupStyle = (rel) => /apps\/desktop\/src\/renderer\/popups\/.*\/style\.css$/.test(rel)
const isTestFile = (rel) => /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/.test(rel)
const HEX = /#[0-9a-fA-F]{3,8}(?![0-9a-fA-F])/g
const FUNC_COLOR = /\b(?:rgba?|hsla?)\([^)]*\)/g
const BOLD_WEIGHT = /(font-?weight\s*[:=]\s*['"]?([7-9]\d0)\b|fontWeight\s*:\s*([7-9]\d0)\b)/g
function walk(dir, files) {
let entries
try {
entries = readdirSync(dir)
} catch {
return
}
for (const name of entries) {
if (IGNORED_DIRS.has(name)) continue
const full = join(dir, name)
const st = statSync(full)
if (st.isDirectory()) walk(full, files)
else if (SCAN_EXT.has(name.slice(name.lastIndexOf('.')))) files.push(full)
}
}
function isAllowlisted(rel) {
if (ALLOWLIST.has(rel)) return true
if (isPopupStyle(rel)) return true
if (isTestFile(rel)) return true
return false
}
function hexLooksLikeColor(match, line, index) {
const before = line[index - 1]
// URL fragment / selector boundary: #features, #root, url(#clip)
if (before && /[A-Za-z0-9_\-/)&(]/.test(before)) return false
// HTML attribute value: href="#download", id='#x'
if ((before === '"' || before === "'") && line[index - 2] === '=') return false
return true
}
// A mask gradient uses white as an opacity stencil, not a painted color.
const isMaskIdiom = (line) => /#fff 0 0/.test(line)
// Comment lines describe identifiers like #access_token; they are not colors.
const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|<!--)/.test(line)
function scanFile(full) {
const rel = relative(ROOT, full).replace(/\\/g, '/')
if (isAllowlisted(rel)) return []
const text = readFileSync(full, 'utf8')
const lines = text.split(/\r?\n/)
const out = []
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i]
if (isCommentLine(line) || isMaskIdiom(line)) continue
HEX.lastIndex = 0
let m
while ((m = HEX.exec(line))) {
if (!hexLooksLikeColor(m[0], line, m.index)) continue
out.push({ rel, line: i + 1, rule: 'hex-color', text: m[0] })
}
FUNC_COLOR.lastIndex = 0
while ((m = FUNC_COLOR.exec(line))) {
out.push({ rel, line: i + 1, rule: 'rgb/hsl-literal', text: m[0] })
}
BOLD_WEIGHT.lastIndex = 0
while ((m = BOLD_WEIGHT.exec(line))) {
out.push({ rel, line: i + 1, rule: 'bold-weight', text: m[0].trim() })
}
}
return out
}
function collect() {
const files = []
for (const t of TARGETS) {
const full = join(ROOT, t)
try {
if (statSync(full).isDirectory()) walk(full, files)
else files.push(full)
} catch {
/* target absent on this platform */
}
}
const violations = []
for (const f of files) violations.push(...scanFile(f))
return violations
}
function selfTest() {
const cases = [
['const a = "#3b82f6"', true],
['color: rgba(59,130,246,0.5)', true],
['fontWeight: 700', true],
['href="#download"', false],
['url(#clip)', false],
['const id = "#root"', false],
['color: "var(--d3-accent-main)"', false],
]
let failed = 0
for (const [line, shouldFlag] of cases) {
const flagged = []
HEX.lastIndex = 0
FUNC_COLOR.lastIndex = 0
BOLD_WEIGHT.lastIndex = 0
let m
while ((m = HEX.exec(line))) if (hexLooksLikeColor(m[0], line, m.index)) flagged.push(m[0])
while ((m = FUNC_COLOR.exec(line))) flagged.push(m[0])
while ((m = BOLD_WEIGHT.exec(line))) flagged.push(m[0])
const got = flagged.length > 0
if (got !== shouldFlag) {
failed += 1
console.error(`self-test FAIL: ${JSON.stringify(line)} expected=${shouldFlag} got=${got}`)
}
}
if (failed) {
console.error(`self-test failed (${failed})`)
process.exit(1)
}
console.log('check-design-tokens self-test: OK')
}
const argv = process.argv.slice(2)
if (argv.includes('--self-test')) {
selfTest()
} else {
const violations = collect()
if (argv.includes('--json')) {
console.log(JSON.stringify({ count: violations.length, violations }, null, 2))
} else {
const byFile = new Map()
for (const v of violations) {
if (!byFile.has(v.rel)) byFile.set(v.rel, [])
byFile.get(v.rel).push(v)
}
for (const [rel, list] of [...byFile.entries()].sort()) {
console.log(`\n${rel} (${list.length})`)
for (const v of list.slice(0, 200)) {
console.log(` ${String(v.line).padStart(4)} ${v.rule.padEnd(15)} ${v.text}`)
}
}
console.log(`\ndesign-token violations: ${violations.length}`)
}
if (violations.length > 0) process.exit(1)
}