@biffo/design-tokens 0.267.8 → 0.268.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ // Pure functions for `biffo-scale-guard` (see scale-guard.mjs for the CLI
2
+ // shell). Kept dependency-free and side-effect-free on purpose: every
3
+ // consuming repo installs this transitively via @biffo/design-tokens, so it
4
+ // adds no new packages to anyone's tree, and it is unit-testable without
5
+ // touching the filesystem or a real tokens.css.
6
+ //
7
+ // ## Where this came from
8
+ //
9
+ // Relocated from `@tabsii-com/ui`'s `bin/scale-guard.mjs` (tabsii-platform#377
10
+ // Phase 2), where it was built and proved against a real, shipped Material-3
11
+ // scale. That version resolved its own package's `dist/tokens.css` by walking
12
+ // up from itself -- correct for a repo with exactly one scale to check
13
+ // against, wrong for a template meant to sit under every Biffo instance, each
14
+ // of which may define (or not yet define) its own scale. THIS file is the
15
+ // generic relocation: every function below is unchanged in behaviour, but the
16
+ // CLI shell (scale-guard.mjs) takes the token source as a `--tokens <path>`
17
+ // argument instead of assuming it. See that file's header for the mechanism/
18
+ // values split this enforces.
19
+ //
20
+ // Design note (tabsii-platform#377 Phase 2, carried forward unchanged): this
21
+ // checker reads the target repo's *hardcoded* CSS/TSX declarations and
22
+ // compares them against the scale -- not the other way around. A guard that
23
+ // only asserts "the scale in tokens.css is well-formed" would be worthless:
24
+ // tabsii-marketplace's ~15 off-scale sizes proved consumers ignore an
25
+ // existing scale regardless. Only reading call sites catches that class.
26
+
27
+ const IGNORED_DIR_NAMES = new Set([
28
+ 'node_modules',
29
+ '.git',
30
+ 'dist',
31
+ 'build',
32
+ '.next',
33
+ 'out',
34
+ 'coverage',
35
+ '.turbo',
36
+ '.worktrees',
37
+ '.vercel',
38
+ ])
39
+
40
+ const SCANNABLE_EXTENSIONS = new Set(['.css', '.tsx', '.jsx'])
41
+
42
+ /**
43
+ * Recursively lists files under `rootDir` matching SCANNABLE_EXTENSIONS,
44
+ * skipping IGNORED_DIR_NAMES. Takes a `readDirFn`/`isDirFn` pair so tests can
45
+ * supply an in-memory tree instead of touching disk.
46
+ */
47
+ export function collectScannableFiles(rootDir, { readDirEntries, joinPath = defaultJoin } = {}) {
48
+ const results = []
49
+ const stack = [rootDir]
50
+
51
+ while (stack.length > 0) {
52
+ const dir = stack.pop()
53
+ const entries = readDirEntries(dir)
54
+ for (const entry of entries) {
55
+ if (entry.isDirectory) {
56
+ if (!IGNORED_DIR_NAMES.has(entry.name)) {
57
+ stack.push(joinPath(dir, entry.name))
58
+ }
59
+ continue
60
+ }
61
+ const ext = extname(entry.name)
62
+ if (SCANNABLE_EXTENSIONS.has(ext)) {
63
+ results.push(joinPath(dir, entry.name))
64
+ }
65
+ }
66
+ }
67
+ return results.sort()
68
+ }
69
+
70
+ function extname(name) {
71
+ const i = name.lastIndexOf('.')
72
+ return i === -1 ? '' : name.slice(i)
73
+ }
74
+
75
+ function defaultJoin(a, b) {
76
+ return `${a}/${b}`
77
+ }
78
+
79
+ /** Converts a `{value, unit}` pair to px. Returns null for an unhandled unit. */
80
+ export function toPx(value, unit) {
81
+ if (unit === 'px') return value
82
+ if (unit === 'rem') return value * 16 // documented assumption: 1rem == 16px root font-size
83
+ return null
84
+ }
85
+
86
+ // The numeric group below is deliberately NOT `[0-9]*\.?[0-9]+` -- that shape
87
+ // has two quantifiers (`*` and `+`) running over the SAME character class
88
+ // with nothing but an OPTIONAL literal between them, so a run of digits with
89
+ // no unit suffix (e.g. a hand-crafted "gap:00000000...") has O(n) equally
90
+ // valid ways to split between the two, and the engine tries all of them
91
+ // before giving up when `(px|rem)` fails to follow -- polynomial blowup on
92
+ // attacker- or generator-controlled CSS, flagged by CodeQL's
93
+ // js/polynomial-redos on this exact shape (high severity, all three
94
+ // instances of it in this file, tabsii-platform#377 follow-up). The fix
95
+ // removes the ambiguity rather than papering over it: `[0-9]+(?:\.[0-9]+)?`
96
+ // greedily consumes every digit in one pass with a SINGLE quantifier, and
97
+ // the trailing `(?:\.[0-9]+)?` is only ever entered by matching a literal
98
+ // "." first -- so on a dot-free digit run it resolves to "no match" in one
99
+ // O(1) check with nothing left to backtrack into. `|\.[0-9]+` keeps
100
+ // `.5rem`-style CSS (a leading-dot decimal with no integer part) matching,
101
+ // as an alternative that starts on a DIFFERENT character (".") to the first
102
+ // branch (a digit) -- so the two branches never compete over the same
103
+ // characters either.
104
+ const NUMBER_RE_SOURCE = '(?:[0-9]+(?:\\.[0-9]+)?|\\.[0-9]+)'
105
+
106
+ const CSS_PROPERTY_RE = new RegExp(
107
+ `(font-size|line-height|gap|row-gap|column-gap)\\s*:\\s*(${NUMBER_RE_SOURCE})(px|rem)\\b`,
108
+ 'g',
109
+ )
110
+
111
+ const TAILWIND_ARBITRARY_RE = new RegExp(
112
+ `\\b(text|leading|gap|row-gap|column-gap)-\\[(${NUMBER_RE_SOURCE})(px|rem)\\]`,
113
+ 'g',
114
+ )
115
+
116
+ const CATEGORY_BY_CSS_PROPERTY = {
117
+ 'font-size': 'fontSize',
118
+ 'line-height': 'lineHeight',
119
+ gap: 'spacing',
120
+ 'row-gap': 'spacing',
121
+ 'column-gap': 'spacing',
122
+ }
123
+
124
+ const CATEGORY_BY_TAILWIND_PREFIX = {
125
+ text: 'fontSize',
126
+ leading: 'lineHeight',
127
+ gap: 'spacing',
128
+ 'row-gap': 'spacing',
129
+ 'column-gap': 'spacing',
130
+ }
131
+
132
+ /**
133
+ * Scans one file's text for hardcoded font-size/line-height/spacing
134
+ * declarations -- plain CSS properties and Tailwind arbitrary-value classes
135
+ * alike -- and returns one entry per match with its resolved px value.
136
+ * Unitless and keyword values (`line-height: 1.5`, `gap: normal`) are not
137
+ * comparable to a px scale and are deliberately not matched.
138
+ */
139
+ export function extractDeclarations(filePath, fileText) {
140
+ const declarations = []
141
+ const lines = fileText.split('\n')
142
+
143
+ lines.forEach((line, idx) => {
144
+ const lineNumber = idx + 1
145
+
146
+ CSS_PROPERTY_RE.lastIndex = 0
147
+ let match
148
+ while ((match = CSS_PROPERTY_RE.exec(line))) {
149
+ const [, property, rawValue, unit] = match
150
+ const px = toPx(parseFloat(rawValue), unit)
151
+ declarations.push({
152
+ file: filePath,
153
+ line: lineNumber,
154
+ category: CATEGORY_BY_CSS_PROPERTY[property],
155
+ source: `${property}: ${rawValue}${unit}`,
156
+ px,
157
+ })
158
+ }
159
+
160
+ TAILWIND_ARBITRARY_RE.lastIndex = 0
161
+ while ((match = TAILWIND_ARBITRARY_RE.exec(line))) {
162
+ const [, prefix, rawValue, unit] = match
163
+ const px = toPx(parseFloat(rawValue), unit)
164
+ declarations.push({
165
+ file: filePath,
166
+ line: lineNumber,
167
+ category: CATEGORY_BY_TAILWIND_PREFIX[prefix],
168
+ source: `${prefix}-[${rawValue}${unit}]`,
169
+ px,
170
+ })
171
+ }
172
+ })
173
+
174
+ return declarations
175
+ }
176
+
177
+ // Originally `([a-z0-9-]+?)(-line-height)?:` -- a LAZY key-name quantifier
178
+ // immediately followed by an OPTIONAL literal that starts with a character
179
+ // ("-") already inside the lazy group's own class. CodeQL flagged this too
180
+ // ("may run slow on strings starting with '--text--:' and with many
181
+ // repetitions"): a run of hyphens gives the engine many equally-valid points
182
+ // to stop growing the lazy group and try the optional suffix, all of which
183
+ // fail identically when no ":" ever follows (a malformed/attacker line).
184
+ // Fixed the same way as the numeric patterns above -- remove the ambiguity
185
+ // rather than bound it: capture the WHOLE key with a single greedy
186
+ // quantifier bounded by the mandatory ":", then peel the "-line-height"
187
+ // suffix off in plain JS below, where a `String.endsWith` is O(1) against
188
+ // the engine, not a second regex quantifier competing for the same text.
189
+ const TOKEN_LINE_RE = new RegExp(`^\\s*--(text|space)-([a-z0-9-]+):\\s*(${NUMBER_RE_SOURCE})px;`)
190
+
191
+ const LINE_HEIGHT_KEY_SUFFIX = '-line-height'
192
+
193
+ /**
194
+ * Parses a tokens.css file into the three allowed-value sets a scanned
195
+ * declaration is checked against, using the fixed `--text-<key>` /
196
+ * `--text-<key>-line-height` / `--space-<key>` naming convention. This
197
+ * convention -- not any particular set of values -- is the generic contract
198
+ * between the guard and whatever token source an instance configures: the
199
+ * MECHANISM (this file) is generic, and lives in biffo-template; the VALUES
200
+ * are per-instance, and live in whatever `--tokens` points at. See
201
+ * scale-guard.mjs's header for why nothing Tabsii- or instance-specific
202
+ * belongs in this file.
203
+ *
204
+ * Reading the shipped CSS rather than re-declaring the scale here is what
205
+ * keeps this checker from drifting from the source of truth the way the
206
+ * consumers it checks already have.
207
+ */
208
+ export function parseAllowedScale(tokensCssText) {
209
+ const fontSize = new Set()
210
+ const lineHeight = new Set()
211
+ const spacing = new Set()
212
+
213
+ for (const rawLine of tokensCssText.split('\n')) {
214
+ const match = TOKEN_LINE_RE.exec(rawLine)
215
+ if (!match) continue
216
+ const [, kind, key, value] = match
217
+ const px = parseFloat(value)
218
+ if (kind === 'text') {
219
+ if (key.endsWith(LINE_HEIGHT_KEY_SUFFIX)) lineHeight.add(px)
220
+ else fontSize.add(px)
221
+ } else if (kind === 'space') {
222
+ spacing.add(px)
223
+ }
224
+ }
225
+
226
+ return { fontSize, lineHeight, spacing }
227
+ }
228
+
229
+ /**
230
+ * True when `allowedScale` declares no tokens in any of the three
231
+ * categories -- i.e. the token source this guard was pointed at exists and
232
+ * parses, but simply has not adopted a type scale yet. This is the "no token
233
+ * source configured" case scale-guard.mjs's header discusses: deliberately
234
+ * NOT the same fact as a missing/unreadable tokens file (that stays a hard,
235
+ * blocking "cannot tell" -- see loadTokensCss in scale-guard.mjs), because a
236
+ * brand-new Biffo sibling is expected to be in this state, and a guard that
237
+ * cannot tell "broken" from "not adopted yet" cannot treat them differently.
238
+ */
239
+ export function isScaleConfigured(allowedScale) {
240
+ return allowedScale.fontSize.size + allowedScale.lineHeight.size + allowedScale.spacing.size > 0
241
+ }
242
+
243
+ const EPSILON = 0.01
244
+
245
+ function isAllowed(px, allowedSet) {
246
+ if (px === null) return true // unhandled unit -- not this checker's job to judge
247
+ for (const allowedPx of allowedSet) {
248
+ if (Math.abs(allowedPx - px) < EPSILON) return true
249
+ }
250
+ return false
251
+ }
252
+
253
+ /**
254
+ * Filters `declarations` down to those whose px value is outside
255
+ * `allowedScale`. When a category's allowed set is EMPTY (no scale adopted
256
+ * yet for that category), every declaration in that category is flagged --
257
+ * correct, not a bug: with no scale to conform to, "declare nothing
258
+ * hardcoded" is the only rule available, and this is exactly what turns the
259
+ * ratchet from inert into a real guard the moment code starts appearing. See
260
+ * isScaleConfigured() for the label this state gets in the CLI's output.
261
+ */
262
+ export function findViolations(declarations, allowedScale) {
263
+ return declarations.filter((d) => !isAllowed(d.px, allowedScale[d.category]))
264
+ }
265
+
266
+ /**
267
+ * Compares a current violation count against a recorded baseline. This is
268
+ * the ratchet: a rise fails, a fall or a hold passes, and a fall says so
269
+ * explicitly rather than silently banking the improvement.
270
+ */
271
+ export function compareToBaseline(currentCount, baselineCount) {
272
+ if (currentCount > baselineCount) {
273
+ return { verdict: 'fail', currentCount, baselineCount }
274
+ }
275
+ if (currentCount < baselineCount) {
276
+ return { verdict: 'improved', currentCount, baselineCount }
277
+ }
278
+ return { verdict: 'pass', currentCount, baselineCount }
279
+ }
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ // biffo-scale-guard -- fails a consuming repo's CI when it declares a
3
+ // font-size, line-height or spacing value outside its configured type scale.
4
+ //
5
+ // ## Where this came from, and why it moved
6
+ //
7
+ // Built and proved first in `@tabsii-com/ui` as `tabsii-scale-guard`
8
+ // (tabsii-platform#377 Phase 2) against a real, shipped Material-3 scale. That
9
+ // version hardcoded its own package's `dist/tokens.css` as the thing being
10
+ // checked against -- correct for a repo with exactly one scale, wrong for a
11
+ // mechanism meant to sit under every Biffo instance. tabsii-platform#377's own
12
+ // text is explicit that the recurrence this exists to close -- "a new sibling
13
+ // is born with no scale and no guard" -- can only be fixed upstream, in
14
+ // biffo-template, because that is the only place a change reaches every future
15
+ // sibling rather than one already-built Tabsii app.
16
+ //
17
+ // So the MECHANISM relocated here, generalised to take its token source as an
18
+ // argument (`--tokens`) instead of assuming it. The VALUES did not move, and
19
+ // must not: `@tabsii-com/ui`'s Material-3 scale is Tabsii's, and shipping it
20
+ // into biffo-template would be the ownership boundary in reverse -- every
21
+ // other Biffo instance would inherit Tabsii's design opinions along with the
22
+ // mechanism meant to be instance-agnostic. `@biffo/design-tokens` (this
23
+ // package) currently ships colour/radius/shadow tokens only and no type
24
+ // scale at all -- seeding one is a design decision for whoever builds Biffo's
25
+ // own visual system, not something this guard's relocation should smuggle in.
26
+ //
27
+ // **There is now exactly one implementation.** `@tabsii-com/ui`'s copy is the
28
+ // one to either delete in favour of consuming this package, or reduce to a
29
+ // thin wrapper -- tracked as a separate PR in that repo (it is a different
30
+ // repo with its own release cycle; this one cannot make that change). Until
31
+ // that PR lands, two copies exist in the estate for one release cycle, same
32
+ // as any upstream-then-distribute change; the point is that nobody should
33
+ // extend tabsii-ui's copy independently in the meantime, and the follow-up
34
+ // PR is what prevents a second, silent fork the way `_extract_detail` became
35
+ // one (tabsii-platform#1107/#1108).
36
+ //
37
+ // ## What happens with no type scale configured yet (the design decision)
38
+ //
39
+ // A brand-new Biffo sibling has `@biffo/design-tokens` as a dependency, but
40
+ // that package declares no `--text-*`/`--space-*` tokens today -- so a fresh
41
+ // scaffold's token source parses to three EMPTY allowed-value sets. Two
42
+ // tempting answers were rejected:
43
+ //
44
+ // - Treating this as "cannot tell" (like a missing tokens file) would fail
45
+ // every new sibling's CI from day one, on a check that never observed
46
+ // anything real. A guard that is red on day-one residue trains people to
47
+ // stop reading it (this is the same argument `scripts/protection-audit.sh`
48
+ // makes about ratchets generally).
49
+ // - Silently skipping the check when the scale is empty would be fail-open
50
+ // -- this estate's dominant defect class -- and would make the guard inert
51
+ // exactly when a founder starts hardcoding the sizes it exists to catch.
52
+ //
53
+ // Neither is needed. `findViolations` (scale-guard-lib.mjs) already does the
54
+ // right thing with an empty allowed set: nothing is exempted, so any hardcoded
55
+ // font-size/line-height/gap is flagged, and a fresh scaffold's real source
56
+ // (checked at relocation time) declares none, so a freshly-baselined sibling
57
+ // starts at 0 and passes for real, not by construction. The guard therefore
58
+ // needs no third "not configured" exit code and no special CI-side handling:
59
+ // it runs the SAME ratchet either way, and the moment code with a hardcoded
60
+ // size lands, it fails -- correctly, because with no scale adopted yet
61
+ // "nothing hardcoded" is the only rule there is to enforce. What changes is
62
+ // only the MESSAGE: every run -- pass or fail -- states plainly how many
63
+ // scale tokens it found for each category, so "0/0/0, this guard is
64
+ // currently enforcing 'nothing hardcoded' rather than 'on an established
65
+ // scale'" is never left implicit. See isScaleConfigured() in
66
+ // scale-guard-lib.mjs.
67
+ //
68
+ // ## The rest of the design (unchanged from the relocated version)
69
+ //
70
+ // - Reads the token source passed via `--tokens` (default: this package's
71
+ // own tokens.css) so the checker cannot drift from the scale it is
72
+ // checking against.
73
+ // - Fails loudly, never silently, when it genuinely cannot do its job: no
74
+ // files found to scan, no tokens file to read, no baseline recorded. Each
75
+ // of those is a distinct exit-2 "cannot tell", following the convention
76
+ // `scripts/claim.sh`/`wait-for-checks.sh` already use estate-wide --
77
+ // never a pass. This is different from "scale not configured yet" above:
78
+ // those are environment/setup defects, this is an expected, valid state.
79
+ // - Ratchets rather than blocks on day-one residue: a repo's current
80
+ // violation count is recorded in a committed baseline file, the guard
81
+ // fails only when that count *rises*, and a fall is reported (not
82
+ // silently banked) with an instruction to lower the baseline.
83
+ //
84
+ // Exit codes: 0 pass, 1 fail (violations rose above baseline), 2 cannot tell.
85
+
86
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
87
+ import { join, dirname, resolve } from 'node:path'
88
+ import { fileURLToPath } from 'node:url'
89
+ import {
90
+ collectScannableFiles,
91
+ extractDeclarations,
92
+ parseAllowedScale,
93
+ isScaleConfigured,
94
+ findViolations,
95
+ compareToBaseline,
96
+ } from './scale-guard-lib.mjs'
97
+
98
+ function parseArgs(argv) {
99
+ const args = { dir: process.cwd(), tokens: null, baseline: null, init: false }
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const arg = argv[i]
102
+ if (arg === '--dir') args.dir = resolve(argv[++i])
103
+ else if (arg === '--tokens') args.tokens = resolve(argv[++i])
104
+ else if (arg === '--baseline') args.baseline = resolve(argv[++i])
105
+ else if (arg === '--init') args.init = true
106
+ else if (arg === '--help' || arg === '-h') args.help = true
107
+ }
108
+ if (!args.baseline) args.baseline = join(args.dir, 'scale-guard-baseline.json')
109
+ if (!args.tokens) {
110
+ // Default: this package's own tokens.css, sitting one level up from bin/.
111
+ // Any instance pointing --tokens elsewhere is exactly the "guard reads
112
+ // whatever token source an instance configures" mechanism/values split --
113
+ // this default is just the common case (a sibling that depends on
114
+ // @biffo/design-tokens and never overrides it).
115
+ const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
116
+ args.tokens = join(pkgRoot, 'tokens.css')
117
+ }
118
+ return args
119
+ }
120
+
121
+ function readDirEntries(dir) {
122
+ return readdirSync(dir).map((name) => {
123
+ const full = join(dir, name)
124
+ return { name, isDirectory: statSync(full).isDirectory() }
125
+ })
126
+ }
127
+
128
+ function loadTokensCss(tokensPath) {
129
+ if (!existsSync(tokensPath)) {
130
+ console.error(
131
+ `biffo-scale-guard: cannot tell -- no tokens file at ${tokensPath}. Pass --tokens ` +
132
+ `<path> to point at the CSS file declaring your --text-*/--space-* scale, or check ` +
133
+ `that @biffo/design-tokens (or whatever ships this bin) installed correctly.`,
134
+ )
135
+ process.exit(2)
136
+ }
137
+ return readFileSync(tokensPath, 'utf8')
138
+ }
139
+
140
+ function main() {
141
+ const args = parseArgs(process.argv.slice(2))
142
+
143
+ if (args.help) {
144
+ console.log(
145
+ [
146
+ 'biffo-scale-guard [--dir <path>] [--tokens <path>] [--baseline <path>] [--init]',
147
+ '',
148
+ ' --dir Repo root to scan for .css/.tsx/.jsx files (default: cwd)',
149
+ ' --tokens Path to the tokens.css declaring the --text-*/--space-* scale',
150
+ " (default: this package's own tokens.css)",
151
+ ' --baseline Path to the baseline JSON file (default: <dir>/scale-guard-baseline.json)',
152
+ ' --init Write the baseline file from the current violation count and exit',
153
+ ].join('\n'),
154
+ )
155
+ process.exit(0)
156
+ }
157
+
158
+ const tokensCssText = loadTokensCss(args.tokens)
159
+ const allowedScale = parseAllowedScale(tokensCssText)
160
+ const configured = isScaleConfigured(allowedScale)
161
+
162
+ // Printed on EVERY run, pass or fail -- the observability half of the
163
+ // "no token source configured" design decision above. A reader must never
164
+ // have to infer this from a bare PASS.
165
+ console.log(
166
+ `biffo-scale-guard: scale source ${args.tokens} -- ${allowedScale.fontSize.size} ` +
167
+ `fontSize / ${allowedScale.lineHeight.size} lineHeight / ${allowedScale.spacing.size} ` +
168
+ `spacing token(s).` +
169
+ (configured
170
+ ? ''
171
+ : ' No scale adopted yet in any category -- this guard is currently enforcing ' +
172
+ '"nothing hardcoded" rather than "on an established scale". That is expected for ' +
173
+ 'a freshly-scaffolded sibling; it stops being the whole story the moment a scale ' +
174
+ 'is added to the token source above.'),
175
+ )
176
+
177
+ if (!existsSync(args.dir)) {
178
+ console.error(`biffo-scale-guard: cannot tell -- scan directory does not exist: ${args.dir}`)
179
+ process.exit(2)
180
+ }
181
+
182
+ const files = collectScannableFiles(args.dir, { readDirEntries })
183
+
184
+ if (files.length === 0) {
185
+ console.error(
186
+ `biffo-scale-guard: cannot tell -- found 0 .css/.tsx/.jsx files under ${args.dir} ` +
187
+ `(node_modules, dist, build, .next, out, coverage, .turbo, .worktrees and .vercel ` +
188
+ `are skipped). Either this repo has no scannable frontend source at that path, or ` +
189
+ `--dir points at the wrong place -- either way, a guard that "passes" here would be ` +
190
+ `passing because it never ran.`,
191
+ )
192
+ process.exit(2)
193
+ }
194
+
195
+ const declarations = files.flatMap((file) =>
196
+ extractDeclarations(file, readFileSync(file, 'utf8')),
197
+ )
198
+ const violations = findViolations(declarations, allowedScale)
199
+
200
+ if (args.init) {
201
+ const baseline = {
202
+ count: violations.length,
203
+ recordedAt: new Date().toISOString(),
204
+ note:
205
+ 'Baseline for the biffo-scale-guard ratchet (tabsii-platform#377 Phase 2 systemic ' +
206
+ 'half). Lower this only when real violations are fixed -- never raise it to silence ' +
207
+ 'a new one.',
208
+ }
209
+ writeFileSync(args.baseline, JSON.stringify(baseline, null, 2) + '\n')
210
+ console.log(
211
+ `biffo-scale-guard: wrote ${args.baseline} with count=${violations.length}. Commit this file.`,
212
+ )
213
+ process.exit(0)
214
+ }
215
+
216
+ if (!existsSync(args.baseline)) {
217
+ console.error(
218
+ `biffo-scale-guard: cannot tell -- no baseline file at ${args.baseline}. Current scan ` +
219
+ `found ${violations.length} violation(s) across ${files.length} file(s). Run with ` +
220
+ `--init to record that count and commit the baseline file, then re-run without --init.`,
221
+ )
222
+ process.exit(2)
223
+ }
224
+
225
+ let baseline
226
+ try {
227
+ baseline = JSON.parse(readFileSync(args.baseline, 'utf8'))
228
+ } catch (err) {
229
+ console.error(
230
+ `biffo-scale-guard: cannot tell -- ${args.baseline} is not valid JSON: ${err.message}`,
231
+ )
232
+ process.exit(2)
233
+ }
234
+
235
+ if (typeof baseline.count !== 'number') {
236
+ console.error(
237
+ `biffo-scale-guard: cannot tell -- ${args.baseline} has no numeric "count" field.`,
238
+ )
239
+ process.exit(2)
240
+ }
241
+
242
+ const result = compareToBaseline(violations.length, baseline.count)
243
+
244
+ if (result.verdict === 'fail') {
245
+ console.error(
246
+ `biffo-scale-guard: FAIL -- ${result.currentCount} off-scale declaration(s), ` +
247
+ `above the baseline of ${result.baselineCount}:\n`,
248
+ )
249
+ for (const v of violations) {
250
+ console.error(` ${v.file}:${v.line} ${v.source} (category: ${v.category})`)
251
+ }
252
+ console.error(
253
+ configured
254
+ ? '\nEither bring these back onto the configured scale, or -- if they are genuinely ' +
255
+ 'new, deliberate departures -- that is a design conversation, not something to ' +
256
+ 'fix by raising the baseline.'
257
+ : '\nNo scale is adopted yet for at least one of these categories (see the scale ' +
258
+ 'source line above), so each is flagged as hardcoded-with-nothing-to-check-it-' +
259
+ 'against rather than off-an-established-scale. Either add these values as ' +
260
+ 'tokens in the token source and reference them, or -- if the count is accepted ' +
261
+ 'debt -- raise the baseline deliberately, in the open, rather than silently.',
262
+ )
263
+ process.exit(1)
264
+ }
265
+
266
+ if (result.verdict === 'improved') {
267
+ console.log(
268
+ `biffo-scale-guard: PASS -- ${result.currentCount} off-scale declaration(s), down from ` +
269
+ `a baseline of ${result.baselineCount}. Lower the baseline: set "count" to ` +
270
+ `${result.currentCount} in ${args.baseline} and commit it.`,
271
+ )
272
+ process.exit(0)
273
+ }
274
+
275
+ console.log(
276
+ `biffo-scale-guard: PASS -- ${result.currentCount} off-scale declaration(s), at baseline.`,
277
+ )
278
+ process.exit(0)
279
+ }
280
+
281
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/design-tokens",
3
- "version": "0.267.8",
3
+ "version": "0.268.0",
4
4
  "description": "Biffo's design tokens — one definition of the platform's visual language, for the portal, sibling apps and plugin frontends.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,8 +15,13 @@
15
15
  "./tokens.css": "./tokens.css",
16
16
  "./package.json": "./package.json"
17
17
  },
18
+ "bin": {
19
+ "biffo-scale-guard": "./bin/scale-guard.mjs"
20
+ },
18
21
  "files": [
19
- "tokens.css"
22
+ "tokens.css",
23
+ "bin/scale-guard.mjs",
24
+ "bin/scale-guard-lib.mjs"
20
25
  ],
21
26
  "publishConfig": {
22
27
  "access": "public"