@nanisoft/prism-ui 0.7.0 → 0.8.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.
Files changed (43) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +30 -0
  4. package/dist/components/index.d.ts +6 -0
  5. package/dist/components/index.d.ts.map +1 -1
  6. package/dist/components/index.js +3 -0
  7. package/dist/components/ui/breadcrumb.d.ts +6 -2
  8. package/dist/components/ui/breadcrumb.d.ts.map +1 -1
  9. package/dist/components/ui/breadcrumb.js +5 -3
  10. package/dist/components/ui/dialog.d.ts +12 -1
  11. package/dist/components/ui/dialog.d.ts.map +1 -1
  12. package/dist/components/ui/dialog.js +2 -2
  13. package/dist/components/ui/live-region.d.ts +78 -0
  14. package/dist/components/ui/live-region.d.ts.map +1 -0
  15. package/dist/components/ui/live-region.js +48 -0
  16. package/dist/components/ui/mark.d.ts +44 -0
  17. package/dist/components/ui/mark.d.ts.map +1 -0
  18. package/dist/components/ui/mark.js +79 -0
  19. package/dist/components/ui/pagination.d.ts +24 -6
  20. package/dist/components/ui/pagination.d.ts.map +1 -1
  21. package/dist/components/ui/pagination.js +23 -9
  22. package/dist/components/ui/product-switcher.d.ts +7 -0
  23. package/dist/components/ui/product-switcher.d.ts.map +1 -1
  24. package/dist/components/ui/product-switcher.js +2 -2
  25. package/dist/components/ui/slider.d.ts +1 -1
  26. package/dist/components/ui/slider.d.ts.map +1 -1
  27. package/dist/components/ui/tree.d.ts +115 -0
  28. package/dist/components/ui/tree.d.ts.map +1 -0
  29. package/dist/components/ui/tree.js +137 -0
  30. package/dist/styles.css +31 -0
  31. package/gates/README.md +98 -0
  32. package/gates/cli.mjs +133 -0
  33. package/gates/hidden-state.mjs +243 -0
  34. package/gates/index.mjs +81 -0
  35. package/gates/laws.mjs +170 -0
  36. package/gates/links.mjs +187 -0
  37. package/gates/pack-boundary.mjs +421 -0
  38. package/gates/pin.mjs +130 -0
  39. package/gates/retired-line.mjs +314 -0
  40. package/gates/run.mjs +320 -0
  41. package/gates/runtime-token-read.mjs +129 -0
  42. package/gates/stylesheet-ownership.mjs +207 -0
  43. package/package.json +14 -2
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The retired-line law: no trace of the old component library survives.
3
+ *
4
+ * Four repositories shipped the retired line together, each discovered it had to
5
+ * come off separately, and the rule that governed the removal lived in prose in
6
+ * four files. A rule in a document decays; this one already had. One repository's
7
+ * own instructions said never import the library directly while three files did,
8
+ * the build baked 126 KB of generated variables from it on every run, and that
9
+ * generated file was the only definition site for every custom property the
10
+ * repository's own stylesheet read.
11
+ *
12
+ * **The lockfile is read as a graph and never grepped.** Two failures make that
13
+ * non-negotiable. A base64 integrity hash contains the characters a
14
+ * package-specifier pattern admits, so a grep reports hits that are not packages.
15
+ * And deleting a dependency line does not empty a lockfile while another package
16
+ * declares the library, which is exactly what happened here: the retired line
17
+ * declared it, so ten packages stayed reachable after the line was dropped and
18
+ * only the move of the pin emptied the graph. The parser reads `importers:` and
19
+ * `snapshots:` and never reads a `resolution:` value, which is where the hashes
20
+ * are.
21
+ *
22
+ * **There is no carve-out for this gate's own prose.** The four copies each
23
+ * excluded one file by path, because each named the library it was keeping out.
24
+ * A rule that has to except itself is a rule whose exception is a file a later
25
+ * commit renames, and the file it excepted was inside a consumer's repository
26
+ * where a reader would come to change a gate. The gate now lives in the package,
27
+ * so the text that names the library is not in the tree being scanned.
28
+ *
29
+ * Coverage is asserted: the run reads the whole repository below the declared
30
+ * roots, every skipped directory is printed, and a run that read fewer files than
31
+ * its floor fails rather than reporting a clean tree.
32
+ */
33
+ import { existsSync } from 'node:fs'
34
+ import path from 'node:path'
35
+
36
+ import { law } from './laws.mjs'
37
+ import { SKIP_DIRECTORIES, blankComments, finding, floor, read, walk } from './run.mjs'
38
+
39
+ /** The old line was built on Ant Design. */
40
+ const isVendorPackage = (name) => name === 'antd' || name.startsWith('@ant-design/')
41
+
42
+ /** Text files a trace could be in. The lockfile is a graph input, not a text input. */
43
+ const TEXT = /\.(ts|tsx|js|jsx|mjs|cjs|css|json|md|mdx|svg|ya?ml|toml)$/
44
+
45
+ /**
46
+ * A module specifier naming the old line's packages, in every position a
47
+ * specifier can take. The bare root matters: `from 'antd'` is reachable with no
48
+ * subpath at all, and a pattern anchored on `from 'antd/` misses it.
49
+ */
50
+ const MODULE_SPECIFIER =
51
+ /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"](?:antd(?:\/[^'"]*)?|@ant-design\/[^'"]*)['"]/
52
+
53
+ /** The files the old line shipped, by name. A `.gitignore` line naming one is a finding. */
54
+ const ARTEFACTS = ['antd-vars.css', 'bake-antd-css.mjs']
55
+
56
+ /** The old line's own theming symbols, which a half-finished removal leaves behind. */
57
+ const THEMING_SYMBOLS = /prismCssVarKey|prismBrandPacks|PrismThemeModeProvider/
58
+
59
+ /** The old line's custom-property namespace. The current contract is unprefixed. */
60
+ const OLD_NAMESPACE = /--prism-[a-z0-9-]*/
61
+
62
+ /** The old line's ruleset class. A pack and a mode are `[data-pack]` and `.dark` today. */
63
+ const OLD_RULESET = /\bprism-[a-z0-9]+(?:-[a-z0-9]+)*-(?:light|dark)\b/
64
+
65
+ export function run({ root, config }) {
66
+ const l = law('retired-line')
67
+ const findings = []
68
+ const notes = []
69
+ const manifestPath = config.manifest ?? 'package.json'
70
+ const lockfilePath = config.lockfile ?? 'pnpm-lock.yaml'
71
+ const documents = new Set(config.documents ?? ['README.md', 'AGENTS.md', 'CLAUDE.md'])
72
+ const minFiles = config.minFiles ?? 12
73
+
74
+ const files = walk(root, { skip: SKIP_DIRECTORIES, extensions: [TEXT] })
75
+ floor('this run read', files.length, minFiles)
76
+
77
+ let bytes = 0
78
+ let commentOnly = 0
79
+ for (const relative of files) {
80
+ const source = read(root, relative)
81
+ bytes += source.length
82
+ const lines = source.split('\n')
83
+ /*
84
+ * Comments are blanked before the two families that name things rather than
85
+ * use them: the custom-property namespace and the theming symbols. A comment
86
+ * that says which property the old sheet declared is a historical record, and
87
+ * the design system's own gate excludes its rule table for the same reason.
88
+ * A *declaration* or a *read* of one is not excused, and blanking rather than
89
+ * skipping the file is what keeps that distinction: a stylesheet whose rule is
90
+ * commented out stops being a read and starts being a note, which is correct.
91
+ *
92
+ * The module specifier, the artefact name and the ruleset class are NOT
93
+ * comment-blanked. A comment that tells a later implementer to import the old
94
+ * package is an instruction rather than a record, and that is the family the
95
+ * whole programme lost a reader to.
96
+ */
97
+ const codeLines = blankComments(source).split('\n')
98
+
99
+ lines.forEach((line, index) => {
100
+ const at = `${relative}:${index + 1}`
101
+ const code = codeLines[index] ?? ''
102
+ if (code.trim() === '' && line.trim() !== '') commentOnly += 1
103
+
104
+ const specifier = line.match(MODULE_SPECIFIER)
105
+ if (specifier) {
106
+ findings.push(
107
+ finding(at, 'module-imported', `"${specifier[0].trim()}" imports the retired line. A consumer installs ${'`@nanisoft/prism-ui`'}, which needs no part of it.`),
108
+ )
109
+ }
110
+ for (const artefact of ARTEFACTS) {
111
+ if (line.includes(artefact)) {
112
+ findings.push(
113
+ finding(at, 'generated-artefact', `this line names the retired line's artefact \`${artefact}\`, and the artefact and its generator are the same removal.`),
114
+ )
115
+ }
116
+ }
117
+ if (THEMING_SYMBOLS.test(code)) {
118
+ findings.push(
119
+ finding(at, 'theming-symbol', `this line names the retired line's theming symbol. The theme is two attributes on the document element and a blocking script the design system ships.`),
120
+ )
121
+ }
122
+ const namespace = code.match(OLD_NAMESPACE)
123
+ if (namespace) {
124
+ findings.push(
125
+ finding(at, 'custom-property', `\`${namespace[0]}\` is the retired line's custom-property namespace. The current contract is unprefixed (\`--background\`), so this read resolves to nothing.`),
126
+ )
127
+ }
128
+ const ruleset = code.match(OLD_RULESET)
129
+ if (ruleset) {
130
+ findings.push(
131
+ finding(at, 'ruleset-class', `\`${ruleset[0]}\` is the retired line's ruleset class. A pack and a mode are \`[data-pack]\` and \`.dark\` today.`),
132
+ )
133
+ }
134
+ // The lockfile is read as a graph below and never as text, because a base64
135
+ // integrity hash contains the characters every specifier pattern admits.
136
+ if (relative === lockfilePath) {
137
+ const parsed = parseLockfile(source)
138
+ for (const [name, chain] of reachableVendors(parsed)) {
139
+ findings.push(
140
+ finding(
141
+ lockfilePath,
142
+ 'reachable-package',
143
+ `\`${name}\` is still reachable from importer \`${chain[0]}\`: ${chain.join(' -> ')}. Deleting a dependency line does not empty a lockfile while another package declares it.`,
144
+ ),
145
+ )
146
+ }
147
+ return
148
+ }
149
+ if (relative === manifestPath) {
150
+ for (const name of vendorDependencies(JSON.parse(source))) {
151
+ findings.push(
152
+ finding(manifestPath, 'dependency-declared', `\`${name}\` is a live dependency. A consumer installs the design system, which brings its own foundation.`),
153
+ )
154
+ }
155
+ }
156
+ if (documents.has(relative) || relative.startsWith('docs/')) {
157
+ const prose = line.match(/\bant[\s-]design\b/i) ?? line.match(/\bantd\b/)
158
+ if (prose) {
159
+ findings.push(
160
+ finding(at, 'living-instruction', `"${prose[0]}" names the retired line in an instruction a later implementer will follow. A document that needs to say what the old line was belongs in a historical record.`),
161
+ )
162
+ }
163
+ }
164
+ })
165
+ }
166
+
167
+ if (!existsSync(path.join(root, lockfilePath))) {
168
+ findings.push(
169
+ finding(lockfilePath, 'missing', 'the lockfile does not resolve, so reachability cannot be read at all and the graph half of this law is unrun.'),
170
+ )
171
+ }
172
+
173
+ notes.push(`${l.id}: ${findings.length} finding(s) across ${files.length} file(s) read, ${bytes} byte(s)`)
174
+ notes.push(`${l.id}: ${commentOnly} comment-only line(s) were exempt from the namespace and theming-symbol families, and were not exempt from the specifier, artefact or ruleset families. A comment naming a property the old sheet declared is a record; a comment telling a later implementer which package to import is an instruction.`)
175
+ notes.push(`${l.id}: ${lockfilePath} was read as a dependency graph and never text-scanned, because a base64 integrity hash contains the characters a specifier pattern admits.`)
176
+ notes.push(`${l.id}: every directory this run did not read, printed so an exclusion is arguable:`)
177
+ for (const directory of SKIP_DIRECTORIES) notes.push(`${l.id}: ${directory}/`)
178
+ notes.push(
179
+ `${l.id}: this gate does not except itself, because it does not live in the tree it scans. The four copies each excluded one file by path for exactly that reason.`,
180
+ )
181
+ notes.push(
182
+ `${l.id}: the honest limit: this is a text and dependency-graph scan. It cannot see a competitor reached through a package that renames it, and it cannot see a runtime that resolves one by string.`,
183
+ )
184
+
185
+ return { law: l, findings, notes }
186
+ }
187
+
188
+ /** Every dependency key naming the retired line, in any block that declares one. */
189
+ export function vendorDependencies(manifest) {
190
+ const found = []
191
+ for (const block of [
192
+ 'dependencies',
193
+ 'devDependencies',
194
+ 'peerDependencies',
195
+ 'optionalDependencies',
196
+ 'overrides',
197
+ 'resolutions',
198
+ 'pnpm.overrides',
199
+ 'pnpm.resolutions',
200
+ ]) {
201
+ const declared = manifest?.[block]
202
+ if (!declared || typeof declared !== 'object') continue
203
+ for (const name of Object.keys(declared)) if (isVendorPackage(name)) found.push(name)
204
+ }
205
+ return found
206
+ }
207
+
208
+ const indentOf = (line) => line.length - line.trimStart().length
209
+ const unquote = (value) => value.trim().replace(/^'(.*)'$/, '$1').trim()
210
+
211
+ /**
212
+ * Parse the lockfile into the two things reachability needs and nothing else.
213
+ *
214
+ * The line ending is normalised first, because a CRLF checkout leaves a carriage
215
+ * return on the end of every line, so trimming a section name yields a name with
216
+ * a character at the end of it. No section then matches, the graph comes back
217
+ * empty, and an empty graph finds nothing and reports a clean pass. That is the
218
+ * difference between a gate and a gate that is switched off.
219
+ */
220
+ export function parseLockfile(text) {
221
+ const importers = new Map()
222
+ const snapshots = new Map()
223
+ let section = null
224
+ let importer = null
225
+ let snapshot = null
226
+ let depBlock = null
227
+ let pending = null
228
+
229
+ for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
230
+ if (line.trim() === '') continue
231
+ const indent = indentOf(line)
232
+
233
+ if (indent === 0) {
234
+ section = line.replace(/:.*/, '')
235
+ importer = null
236
+ snapshot = null
237
+ depBlock = null
238
+ pending = null
239
+ continue
240
+ }
241
+
242
+ if (section === 'importers') {
243
+ if (indent === 2) {
244
+ importer = unquote(line.trim().replace(/:$/, ''))
245
+ importers.set(importer, new Map())
246
+ } else if (indent === 4) {
247
+ depBlock = line.trim().replace(/:$/, '')
248
+ } else if (indent === 6) {
249
+ pending = unquote(line.trim().replace(/:$/, ''))
250
+ } else if (indent === 8) {
251
+ // The resolved version is the seed of the walk. A `link:` version is a
252
+ // workspace edge with no snapshot, so it seeds nothing.
253
+ if (!line.trim().startsWith('version:') || pending == null) continue
254
+ const version = unquote(line.trim().slice('version:'.length))
255
+ if (version.startsWith('link:')) continue
256
+ importers.get(importer)?.set(`${depBlock ?? 'dependencies'}/${pending}`, `${pending}@${version}`)
257
+ }
258
+ continue
259
+ }
260
+
261
+ if (section === 'snapshots') {
262
+ if (indent === 2) {
263
+ snapshot = unquote(line.trim().replace(/:(\s*\{\})?$/, ''))
264
+ if (!snapshots.has(snapshot)) snapshots.set(snapshot, new Map())
265
+ } else if (indent === 4) {
266
+ depBlock = line.trim().replace(/:$/, '')
267
+ } else if (indent === 6) {
268
+ const colon = line.indexOf(':')
269
+ const name = unquote(line.slice(0, colon))
270
+ const version = unquote(line.slice(colon + 1))
271
+ snapshots.get(snapshot)?.set(`${depBlock ?? 'dependencies'}/${name}`, `${name}@${version}`)
272
+ }
273
+ }
274
+ }
275
+
276
+ return { importers, snapshots }
277
+ }
278
+
279
+ /**
280
+ * Split a `name@version(peer@1)` key at the `@` outside every parenthesised
281
+ * suffix. `lastIndexOf` does not work: the suffixes are full of `@`.
282
+ */
283
+ export function splitPackageKey(key) {
284
+ let depth = 0
285
+ for (let i = 1; i < key.length; i += 1) {
286
+ const char = key[i]
287
+ if (char === '(') depth += 1
288
+ else if (char === ')') depth -= 1
289
+ else if (char === '@' && depth === 0) return [key.slice(0, i), key.slice(i + 1)]
290
+ }
291
+ return [key, '']
292
+ }
293
+
294
+ /** Every retired-line package reachable from an importer, with the chain that reaches it. */
295
+ export function reachableVendors(lock) {
296
+ const found = new Map()
297
+ for (const [importer, direct] of lock.importers) {
298
+ const queue = [...direct.values()].map((key) => ({ key, parent: null }))
299
+ const seen = new Set()
300
+ while (queue.length > 0) {
301
+ const node = queue.shift()
302
+ if (seen.has(node.key)) continue
303
+ seen.add(node.key)
304
+ const [name] = splitPackageKey(node.key)
305
+ if (isVendorPackage(name) && !found.has(name)) {
306
+ const chain = [importer]
307
+ for (let cursor = node; cursor; cursor = cursor.parent) chain.push(splitPackageKey(cursor.key)[0])
308
+ found.set(name, chain.reverse())
309
+ }
310
+ for (const target of lock.snapshots.get(node.key)?.values() ?? []) queue.push({ key: target, parent: node })
311
+ }
312
+ }
313
+ return found
314
+ }
package/gates/run.mjs ADDED
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The kit's shared machinery, and the one place a consumer's design system is
3
+ * resolved.
4
+ *
5
+ * Three things live here because all six gates need them and a consumer must
6
+ * only write them once:
7
+ *
8
+ * 1. **Resolution of the design system.** A gate reads the token contract and
9
+ * the emitted base rules, and it reads them *through the component
10
+ * package's own dependency*, not through the consumer's. That is the whole
11
+ * reason a consumer no longer declares `@nanisoft/prism-tokens`: the file
12
+ * belongs to the package that owns it, and the package that owns it is the
13
+ * one the consumer pinned exactly. A second declaration of the token
14
+ * version in a consumer is a second fact to keep in step with a release.
15
+ * 2. **The CSS scanner**, shared by the ownership, token-read, hidden-state
16
+ * and pack-boundary gates, so four gates cannot disagree about what a
17
+ * declaration is. Comments are blanked rather than removed, so a finding
18
+ * names the line a reader has to edit.
19
+ * 3. **The report shape**, so every gate says what it read, names every
20
+ * exclusion, and fails when it read less than its floor.
21
+ *
22
+ * A gate that can read nothing must fail. `floor()` is that: it is called by
23
+ * every gate on the number it is about to be judged on, and it throws rather
24
+ * than warning, because a run that scanned an empty export directory and
25
+ * reported zero findings is indistinguishable from a clean repository.
26
+ */
27
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
28
+ import { createRequire } from 'node:module'
29
+ import path from 'node:path'
30
+
31
+ /** The component package's name. The one package a consumer of the kit pins. */
32
+ export const PACKAGE = '@nanisoft/prism-ui'
33
+
34
+ /** The token package's name, and the fact that it is the component package's dependency. */
35
+ export const TOKEN_PACKAGE = '@nanisoft/prism-tokens'
36
+
37
+ /**
38
+ * A gate read less than it needed to read.
39
+ *
40
+ * Its own class rather than a generic `Error`, so the report can name it as a
41
+ * coverage failure instead of a crash: the difference matters, because a crash
42
+ * reads as "the gate is broken" and a coverage failure reads as "the gate saw
43
+ * nothing", and only the second is true.
44
+ */
45
+ export class CoverageError extends Error {
46
+ constructor(message) {
47
+ super(message)
48
+ this.name = 'CoverageError'
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Fail the run when `actual` is below `floor`.
54
+ *
55
+ * `what` is the unit, because "read 0" is meaningless and "read 0 routes" is a
56
+ * diagnosis. The message says what a reader has to do, which is the whole
57
+ * reason this is a function and not a number in each gate.
58
+ */
59
+ export function floor(what, actual, minimum) {
60
+ if (actual >= minimum) return actual
61
+ throw new CoverageError(
62
+ `${what} ${actual} and this gate needs at least ${minimum}. A gate that read nothing reports\n` +
63
+ ` zero findings, and a zero-finding report is indistinguishable from a clean repository.`,
64
+ )
65
+ }
66
+
67
+ /* ------------------------------------------------------------------ reading */
68
+
69
+ /** Directories no gate ever reads. Printed on every run, because an exclusion is arguable. */
70
+ export const SKIP_DIRECTORIES = [
71
+ '.git',
72
+ '.next',
73
+ '.source',
74
+ '.wrangler',
75
+ 'coverage',
76
+ 'node_modules',
77
+ 'out',
78
+ ]
79
+
80
+ /**
81
+ * Every file under `root`, as forward-slashed relative paths, depth first and
82
+ * sorted, with `skip` pruned rather than filtered afterwards.
83
+ *
84
+ * Sorted because a gate's output order is a reader's reading order and a
85
+ * filesystem's order is not one.
86
+ */
87
+ export function walk(root, { skip = SKIP_DIRECTORIES, extensions = null } = {}) {
88
+ const pruned = new Set(skip)
89
+ const found = []
90
+ const visit = (dir) => {
91
+ let entries
92
+ try {
93
+ entries = readdirSync(dir, { withFileTypes: true })
94
+ } catch {
95
+ return
96
+ }
97
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
98
+ if (pruned.has(entry.name)) continue
99
+ const full = path.join(dir, entry.name)
100
+ if (entry.isDirectory()) visit(full)
101
+ else if (extensions === null || extensions.some((pattern) => pattern.test(entry.name))) {
102
+ found.push(path.relative(root, full).split(path.sep).join('/'))
103
+ }
104
+ }
105
+ }
106
+ visit(root)
107
+ return found
108
+ }
109
+
110
+ /** Read a file under `root`, or return null when it is absent. */
111
+ export function read(root, relative) {
112
+ try {
113
+ return readFileSync(path.join(root, relative), 'utf8')
114
+ } catch {
115
+ return null
116
+ }
117
+ }
118
+
119
+ /** Read a JSON file under `root`, or return null when it is absent or malformed. */
120
+ export function readJson(root, relative) {
121
+ const text = read(root, relative)
122
+ if (text === null) return null
123
+ try {
124
+ return JSON.parse(text)
125
+ } catch (cause) {
126
+ throw new Error(`${relative} is not valid JSON: ${cause.message}`)
127
+ }
128
+ }
129
+
130
+ /** Fail the run when a declared root does not resolve, naming the file. */
131
+ export function requireRoot(root, relative) {
132
+ const full = path.join(root, relative)
133
+ if (!existsSync(full) || !statSync(full).isFile()) {
134
+ throw new CoverageError(
135
+ `${relative} does not resolve, so this gate cannot read what it was pointed at. ` +
136
+ `A renamed file empties a run and an emptied run reports a clean repository.`,
137
+ )
138
+ }
139
+ return full
140
+ }
141
+
142
+ /* ------------------------------------------------- the design system, resolved */
143
+
144
+ /**
145
+ * The consumer's installed design system, reached through its own export map.
146
+ *
147
+ * Everything a gate knows about tokens comes from here, so a gate never has to
148
+ * be told where the token package is and a consumer never has to declare it.
149
+ * `createRequire` is seeded with the *component* package's `package.json`, which
150
+ * is itself a published export, so the token package is resolved as a dependency
151
+ * of the thing that owns it rather than as a top-level lookup in the consumer.
152
+ */
153
+ export function designSystem(root) {
154
+ const fromConsumer = createRequire(path.join(root, 'package.json'))
155
+ let manifestPath
156
+ try {
157
+ manifestPath = fromConsumer.resolve(`${PACKAGE}/package.json`)
158
+ } catch (cause) {
159
+ throw new CoverageError(
160
+ `the pinned design system does not resolve from ${PACKAGE}/package.json (${cause.message}).\n` +
161
+ ` Every judgement about tokens and base rules is a guess without it. Run pnpm install first.`,
162
+ )
163
+ }
164
+ const packageRoot = path.dirname(manifestPath)
165
+ const require = createRequire(manifestPath)
166
+ const version = JSON.parse(readFileSync(manifestPath, 'utf8')).version
167
+
168
+ return {
169
+ version,
170
+ packageRoot,
171
+ /** The one emitted stylesheet, which carries the token blocks and the base layer. */
172
+ styles() {
173
+ try {
174
+ return readFileSync(require.resolve(`${PACKAGE}/styles.css`), 'utf8')
175
+ } catch (cause) {
176
+ throw new CoverageError(
177
+ `${PACKAGE}/styles.css does not resolve (${cause.message}), so the token contract and the\n` +
178
+ ' base rules cannot be read and every judgement about them would be a guess.',
179
+ )
180
+ }
181
+ },
182
+ /**
183
+ * The published per-pack block for one pack and mode, as the token package
184
+ * emits it. Resolved through the component package's dependency, which is
185
+ * why a consumer does not declare the token package itself.
186
+ */
187
+ tokenTheme(pack, mode) {
188
+ try {
189
+ const file = require.resolve(`${TOKEN_PACKAGE}/dist/themes/${pack}/${mode}.css`)
190
+ return properties(readFileSync(file, 'utf8'))
191
+ } catch (cause) {
192
+ throw new CoverageError(
193
+ `the token package's ${pack} ${mode} block does not resolve through ${PACKAGE} ` +
194
+ `(${cause.message}).\n A boundary resolves this file's values in both modes, so without it the\n` +
195
+ ' both-modes check cannot run at all.',
196
+ )
197
+ }
198
+ },
199
+ }
200
+ }
201
+
202
+ /* ------------------------------------------------------------------- the css */
203
+
204
+ /**
205
+ * Blank comments rather than removing them, so every line number a finding
206
+ * prints is still the line a reader has to edit.
207
+ */
208
+ export function blankComments(source) {
209
+ const out = source.split('')
210
+ for (let i = 0; i < out.length; i += 1) {
211
+ if (out[i] === '/' && out[i + 1] === '*') {
212
+ const close = source.indexOf('*/', i + 2)
213
+ const end = close === -1 ? out.length : close + 2
214
+ for (let k = i; k < end; k += 1) if (out[k] !== '\n') out[k] = ' '
215
+ i = end - 1
216
+ } else if (out[i] === '/' && out[i + 1] === '/') {
217
+ let end = source.indexOf('\n', i)
218
+ if (end === -1) end = source.length
219
+ for (let k = i; k < end; k += 1) out[k] = ' '
220
+ i = end - 1
221
+ }
222
+ }
223
+ return out.join('')
224
+ }
225
+
226
+ /**
227
+ * Split a stylesheet into `selector { declarations }`, keeping the line each
228
+ * block starts on. A declaration parser rather than a cascade resolution, and
229
+ * the difference is printed on every run: this cannot see a class-scoped rule
230
+ * that competes, an inline style prop, or a sheet it was not pointed at.
231
+ */
232
+ export function cssBlocks(source) {
233
+ const blanked = blankComments(source)
234
+ const blocks = []
235
+ for (const match of blanked.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
236
+ blocks.push({
237
+ selectors: splitSelectors(match[1]),
238
+ body: match[2],
239
+ line: blanked.slice(0, match.index).split('\n').length,
240
+ })
241
+ }
242
+ return blocks
243
+ }
244
+
245
+ /**
246
+ * A selector list, split on the commas that separate selectors.
247
+ *
248
+ * Not `split(',')`, because a comma inside `:where(a, b)` or `:is()` separates
249
+ * arguments rather than selectors, and splitting there makes the tail look like a
250
+ * bare-element selector: `.site :where(a, b)` would report `b)` as an unclassed
251
+ * element rule that declares a property the design system owns. A gate that
252
+ * reports a false positive on ordinary modern CSS is a gate that gets switched
253
+ * off, so the parentheses are tracked and the split only happens at depth zero.
254
+ */
255
+ export function splitSelectors(text) {
256
+ const parts = []
257
+ let depth = 0
258
+ let current = ''
259
+ for (const character of text) {
260
+ if (character === '(') depth += 1
261
+ else if (character === ')') depth = Math.max(0, depth - 1)
262
+ if (character === ',' && depth === 0) {
263
+ parts.push(current)
264
+ current = ''
265
+ continue
266
+ }
267
+ current += character
268
+ }
269
+ parts.push(current)
270
+ return parts.map((part) => part.trim()).filter(Boolean)
271
+ }
272
+
273
+ /** The custom properties a declaration list declares, `--name` included. */
274
+ export function properties(body) {
275
+ return new Map([...body.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]))
276
+ }
277
+
278
+ /** Every custom property name a stylesheet declares. */
279
+ export function declaredProperties(css) {
280
+ return new Set([...css.matchAll(/(--[a-z0-9-]+)\s*:/g)].map((m) => m[1]))
281
+ }
282
+
283
+ /**
284
+ * Two colours are the same colour however they are written.
285
+ *
286
+ * A minifier shortens `#ffffff` to `#fff`, and a value read from an emitted
287
+ * sheet compared byte for byte against the contract reports a difference that is
288
+ * not one. Normalising is what lets a gate compare values rather than spellings.
289
+ */
290
+ export function sameColour(left, right) {
291
+ const expand = (value) => {
292
+ const hex = /^#([0-9a-f]{3,8})$/i.exec(String(value).trim())
293
+ if (!hex) return String(value).trim().toLowerCase()
294
+ const digits = hex[1]
295
+ return digits.length === 3 || digits.length === 4
296
+ ? `#${[...digits].map((digit) => digit + digit).join('')}`.toLowerCase()
297
+ : `#${digits}`.toLowerCase()
298
+ }
299
+ return expand(left) === expand(right)
300
+ }
301
+
302
+ /* ---------------------------------------------------------------- reporting */
303
+
304
+ /**
305
+ * One finding, formatted the way every gate formats one, so a reader who has
306
+ * seen one finding has seen every finding.
307
+ *
308
+ * `where` is the file and line a reader has to edit, `tag` is the class of the
309
+ * defect, and `body` is what it is. The law is not repeated per finding: it is
310
+ * printed once at the end, because a message repeated per finding is a message
311
+ * a reader learns to skip.
312
+ */
313
+ export function finding(where, tag, body) {
314
+ return `error ${where} [${tag}] ${body}`
315
+ }
316
+
317
+ /** A note the run prints, so coverage and exclusions are on every run. */
318
+ export function note(text) {
319
+ return text
320
+ }