@biffo/design-tokens 0.267.8 → 0.269.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.
- package/bin/scale-guard-lib.mjs +279 -0
- package/bin/scale-guard.mjs +281 -0
- package/package.json +7 -2
|
@@ -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.
|
|
3
|
+
"version": "0.269.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"
|