@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,421 @@
1
+ /**
2
+ * The pack-boundary law: a boundary lands on a mark, and a declared region set is
3
+ * the whole of what may carry a second pack.
4
+ *
5
+ * **A screenshot cannot check this, in either mode, and that is the finding.** A
6
+ * boundary is correct only if it resolves to its own pack in the mode the document
7
+ * is in, which is two facts multiplied. A boundary carrying `data-pack="mint"` on a
8
+ * dark document with no `dark` class of its own matches the light block, so the
9
+ * mark paints the light pack on a dark page: pale marks on a dark page, which looks
10
+ * like a design decision rather than a bug. A screenshot in the wrong mode looks
11
+ * correct, so a single-mode screenshot is not evidence and this gate does not accept
12
+ * one.
13
+ *
14
+ * **Both modes are read from the emitted CSS, not from a browser.** The failure
15
+ * being guarded is a selector shape, and a selector shape is knowable by reading
16
+ * the stylesheet the build emitted against the markup the build emitted. For every
17
+ * pack the page uses, the gate resolves the pack's light and dark blocks out of the
18
+ * emitted stylesheet, asserts that the light block is a bare `[data-pack=x]` and
19
+ * that the dark block carries **both** the compound and the descendant form, and
20
+ * then matches every boundary in the document against both. A dark block that is
21
+ * compound-only is the defect this catches, and it is invisible to a review and to
22
+ * a screenshot, because the page it produces is pack-correct and mode-inverted.
23
+ *
24
+ * The values are then compared against the token package's own published per-pack
25
+ * files, resolved through the component package's dependency, so the gate is not
26
+ * merely self-consistent: a boundary resolves the contract's light values in light
27
+ * mode and its dark values in dark mode, and the two are asserted to differ, which
28
+ * is what makes a both-modes check worth running.
29
+ *
30
+ * **The document element is not a boundary.** `<html data-pack="sky">` is the
31
+ * page's ground, which is the page's theme and the one attribute the whole document
32
+ * is built around; it is the axis the scoped boundary law talks about *beneath*. So
33
+ * the gate reads it, confirms it names the declared ground, and then judges every
34
+ * other element by the scoped law.
35
+ *
36
+ * **Which half is the law and which half is the site's data.** The law is the set
37
+ * of judgements below: where a boundary may land, what a region set means, that
38
+ * both modes resolve, and that the values agree with the contract. All of it is
39
+ * here. What belongs to the site is the map (`pack-map.json`, with the reason each
40
+ * region exists), the ground, the route the map describes, the slot a boundary is
41
+ * allowed to land on, and the resolver that names a region from a DOM node, because
42
+ * naming a region means knowing this site's own structure. That resolver is a
43
+ * module the site declares rather than a table in this file, for the same reason
44
+ * `pack-map.json` is data: it is a fact about one page, and a fact about one page
45
+ * in a file about all pages would be wrong the moment the page changes.
46
+ *
47
+ * The honest limit, printed on every run: this is a selector match over emitted CSS
48
+ * and emitted markup, not a layout engine. It cannot see a runtime that sets
49
+ * `data-pack` after paint, and it cannot see a token a boundary reads that the
50
+ * stylesheet does not declare.
51
+ */
52
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
53
+ import { createRequire } from 'node:module'
54
+ import path from 'node:path'
55
+ import { pathToFileURL } from 'node:url'
56
+
57
+ import { law } from './laws.mjs'
58
+ import {
59
+ CoverageError,
60
+ cssBlocks,
61
+ designSystem,
62
+ finding,
63
+ floor,
64
+ properties,
65
+ readJson,
66
+ sameColour,
67
+ } from './run.mjs'
68
+
69
+ /** The roles a boundary must move, and the two a reader would notice it not moving. */
70
+ const ROLES = ['background', 'card', 'foreground', 'border']
71
+
72
+ /** Elements whose corner radius a reader can see change when the pack moves. */
73
+ const SHAPES = ['rect', 'section', 'article', 'a', 'div', 'li', 'svg', 'g', 'path', 'circle']
74
+
75
+ export async function run({ root, config }) {
76
+ const l = law('pack-boundary')
77
+ const findings = []
78
+ const notes = []
79
+ const outDir = config.outDir ?? 'out'
80
+ const mapPath = config.map ?? 'scripts/pack-map.json'
81
+ const landing = config.landing ?? '/'
82
+ const markSlot = config.markSlot ?? 'product-mark'
83
+
84
+ const map = readJson(root, mapPath)
85
+ if (!map) {
86
+ throw new CoverageError(
87
+ `${mapPath} does not resolve or is not JSON, so there is no map and every judgement about what a page\n` +
88
+ ' may carry would be a guess. A map that says zero is a decision; a page with no map is a drift.',
89
+ )
90
+ }
91
+
92
+ const regionOf = await regionResolver(root, config, mapPath)
93
+ const out = path.join(root, outDir)
94
+ if (!existsSync(path.join(out, '_next'))) {
95
+ throw new CoverageError(
96
+ `the export has no ${outDir}/_next, so the emitted stylesheet cannot be read and every judgement about\n` +
97
+ ' which mode a boundary resolves in would be a guess. Run the build first.',
98
+ )
99
+ }
100
+
101
+ const cssFiles = []
102
+ const walk = (dir) => {
103
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
104
+ const full = path.join(dir, entry.name)
105
+ if (entry.isDirectory()) walk(full)
106
+ else if (entry.name.endsWith('.css')) cssFiles.push(full)
107
+ }
108
+ }
109
+ walk(path.join(out, '_next'))
110
+ floor('emitted stylesheet(s)', cssFiles.length, 1)
111
+ const css = cssFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
112
+
113
+ /** Every rule whose selector list mentions a boundary, keyed `pack:mode`. */
114
+ const rules = new Map()
115
+ for (const block of cssBlocks(css)) {
116
+ for (const selector of block.selectors) {
117
+ const boundary = /\[data-pack="?([a-z]+)"?\]/.exec(selector)
118
+ if (!boundary) continue
119
+ const pack = boundary[1]
120
+ const key = `${pack}:${selector.includes('.dark') ? 'dark' : 'light'}`
121
+ if (!rules.has(key)) rules.set(key, { selectors: [], values: properties(block.body) })
122
+ const rule = rules.get(key)
123
+ if (!rule.selectors.includes(selector)) rule.selectors.push(selector)
124
+ for (const [property, value] of properties(block.body)) rule.values.set(property, value)
125
+ }
126
+ }
127
+
128
+ const system = designSystem(root)
129
+ const JSDOM = createRequire(path.join(root, 'package.json'))('jsdom').JSDOM
130
+ const documents = []
131
+ const walkHtml = (dir) => {
132
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
133
+ const full = path.join(dir, entry.name)
134
+ if (entry.isDirectory()) walkHtml(full)
135
+ else if (entry.name.endsWith('.html')) {
136
+ const relative = path.relative(out, full).split(path.sep).join('/')
137
+ documents.push([full, relative === 'index.html' ? '/' : `/${relative.replace(/\.html$/, '')}`])
138
+ }
139
+ }
140
+ }
141
+ walkHtml(out)
142
+ floor('emitted document(s)', documents.length, 1)
143
+
144
+ const byRoute = new Map()
145
+ const actual = new Map()
146
+ const reported = new Set()
147
+ let boundaries = 0
148
+ let marks = 0
149
+
150
+ const report = (key, where, tag, body) => {
151
+ if (reported.has(key)) return
152
+ reported.add(key)
153
+ findings.push(finding(where, tag, body))
154
+ }
155
+
156
+ for (const [file, route] of documents) {
157
+ const document = new JSDOM(readFileSync(file, 'utf8')).window.document
158
+
159
+ const ground = document.documentElement.getAttribute('data-pack')
160
+ if (ground !== map.ground) {
161
+ findings.push(
162
+ finding(
163
+ route,
164
+ 'ground',
165
+ `the document element carries data-pack="${ground}" and the map declares "${map.ground}".\n` +
166
+ ' The ground is one fact with one owner.',
167
+ ),
168
+ )
169
+ }
170
+ if (map.defaultMode === 'dark' && !document.documentElement.classList.contains('dark')) {
171
+ findings.push(
172
+ finding(
173
+ route,
174
+ 'mode',
175
+ 'the document element carries no dark class, so a reader with no stored theme gets light mode,\n' +
176
+ ' which is not this site\'s default.',
177
+ ),
178
+ )
179
+ }
180
+
181
+ const regions = new Map()
182
+ for (const element of document.querySelectorAll('[data-pack]')) {
183
+ if (element === document.documentElement) continue
184
+ boundaries += 1
185
+ const pack = element.getAttribute('data-pack') ?? ''
186
+ const region = regionOf(element, document)
187
+ if (region === null || region === undefined) {
188
+ report(
189
+ `unnameable:${element.tagName}`,
190
+ route,
191
+ 'unnamed-region',
192
+ `a data-pack="${pack}" boundary in a band the site's own resolver cannot name cannot be held to\n` +
193
+ ' the map. A region this gate cannot name is a region it cannot hold to the map.',
194
+ )
195
+ continue
196
+ }
197
+ if (!regions.has(region)) regions.set(region, [])
198
+ regions.get(region).push(pack)
199
+
200
+ const tag = element.tagName.toLowerCase()
201
+ if (element.getAttribute('data-slot') === markSlot) marks += 1
202
+ else {
203
+ report(
204
+ `off-mark:${element.tagName}`,
205
+ route,
206
+ 'boundary-off-a-mark',
207
+ `a data-pack="${pack}" boundary in ${region} sits on a <${tag}>, not on a mark. A boundary\n` +
208
+ ' re-points --radius as well as colour, so anything that is not a fully rounded shape changes\n' +
209
+ ' shape with its pack.',
210
+ )
211
+ }
212
+ if (SHAPES.includes(tag)) {
213
+ report(
214
+ `shape:${tag}`,
215
+ route,
216
+ 'boundary-shape',
217
+ `a <${tag}> carries data-pack="${pack}" in ${region}.`,
218
+ )
219
+ }
220
+ if (/\brounded-(?!full\b)[a-z0-9-]+/.test(element.getAttribute('class') ?? '')) {
221
+ report(
222
+ `radius:${region}`,
223
+ route,
224
+ 'boundary-radius',
225
+ `the boundary in ${region} carries a radius utility other than rounded-full, so its corner radius\n` +
226
+ ' is computed from the pack it carries.',
227
+ )
228
+ }
229
+ if (/\bdark\b/.test(element.getAttribute('class') ?? '')) {
230
+ report(
231
+ `hard-mode:${region}`,
232
+ route,
233
+ 'hard-coded-mode',
234
+ `a boundary in ${region} carries a dark class of its own, so it is mode-correct for half the\n` +
235
+ ' readers and inverted for the other half. A boundary wears the mode of the nearest ancestor\n' +
236
+ ' carrying it, and the server is the only thing that can know it.',
237
+ )
238
+ }
239
+
240
+ for (const mode of ['light', 'dark']) {
241
+ const rule = rules.get(`${pack}:${mode}`)
242
+ if (!rule) {
243
+ report(`no-block:${pack}:${mode}`, route, `no-${mode}-block`, `the emitted stylesheet declares no [data-pack=${pack}] block for ${mode}.`)
244
+ continue
245
+ }
246
+ if (mode === 'dark') {
247
+ if (!rule.selectors.some((selector) => selector === `.dark [data-pack=${pack}]`)) {
248
+ report(
249
+ `descendant:${pack}`,
250
+ route,
251
+ 'mode-inverted',
252
+ `the dark block for "${pack}" is ${JSON.stringify(rule.selectors)}, which has no descendant\n` +
253
+ ' form. A boundary is an attribute on an element with no mode class of its own, so without\n' +
254
+ ' `.dark [data-pack=x]` a server-rendered boundary on a dark page matches the light block:\n' +
255
+ ' pack-correct, mode-inverted, and it looks like a design decision.',
256
+ )
257
+ }
258
+ if (!rule.selectors.some((selector) => selector === `[data-pack=${pack}].dark`)) {
259
+ report(
260
+ `compound:${pack}`,
261
+ route,
262
+ 'compound-form-missing',
263
+ `the dark block for "${pack}" drops the compound form, which is published for a boundary that must\n` +
264
+ ' hold a fixed mode.',
265
+ )
266
+ }
267
+ }
268
+ const published = system.tokenTheme(pack, mode)
269
+ for (const role of ROLES) {
270
+ const emittedValue = rule.values.get(`--${role}`)
271
+ const expected = published.get(`--${role}`)
272
+ if (emittedValue === undefined) {
273
+ report(
274
+ `unresolved:${pack}:${role}`,
275
+ route,
276
+ 'unresolved-role',
277
+ `the ${mode} block for "${pack}" does not declare --${role}.`,
278
+ )
279
+ } else if (expected !== undefined && !sameColour(emittedValue, expected)) {
280
+ report(
281
+ `contract:${pack}:${mode}:${role}`,
282
+ route,
283
+ 'contract-mismatch',
284
+ `the ${mode} block for "${pack}" declares --${role}: ${emittedValue}, and the published token contract\n` +
285
+ ` declares ${expected}.`,
286
+ )
287
+ }
288
+ }
289
+ if (mode === 'dark') {
290
+ const light = rules.get(`${pack}:light`)?.values
291
+ if (light && light.get('--card') === rule.values.get('--card')) {
292
+ report(
293
+ `identical:${pack}`,
294
+ route,
295
+ 'modes-identical',
296
+ `the light and dark blocks for "${pack}" declare the same --card, so a boundary resolves to the same\n` +
297
+ ' values in both modes and this check proves nothing about modes.',
298
+ )
299
+ }
300
+ }
301
+ }
302
+ }
303
+ byRoute.set(route, regions)
304
+ for (const [region, packs] of regions) {
305
+ if (!actual.has(region)) actual.set(region, { packs: new Set(), routes: [] })
306
+ actual.get(region).packs = new Set([...actual.get(region).packs, ...packs])
307
+ actual.get(region).routes.push(route)
308
+ }
309
+ }
310
+
311
+ const multiset = (packs) => JSON.stringify([...packs].sort())
312
+ for (const [route, regions] of [...byRoute.entries()].sort()) {
313
+ for (const [region, packs] of [...regions.entries()].sort()) {
314
+ const expected = map.regions?.[region]
315
+ if (!expected) {
316
+ findings.push(
317
+ finding(
318
+ route,
319
+ 'undeclared-region',
320
+ `${region} carries ${multiset(packs)} and the map does not declare it. A map that says zero is a\n` +
321
+ ' decision; a map that says nothing is a drift.',
322
+ ),
323
+ )
324
+ continue
325
+ }
326
+ if (multiset(packs) !== multiset(expected.packs)) {
327
+ findings.push(
328
+ finding(
329
+ route,
330
+ 'map-mismatch',
331
+ `${region} carries ${multiset(packs)} and the map declares ${multiset(expected.packs)}.`,
332
+ ),
333
+ )
334
+ }
335
+ }
336
+ }
337
+
338
+ const onLanding = byRoute.get(landing) ?? new Map()
339
+ for (const region of Object.keys(map.regions ?? {})) {
340
+ if (onLanding.has(region)) continue
341
+ findings.push(
342
+ finding(region, 'missing-on-landing', 'the map declares this region and the landing does not carry it.'),
343
+ )
344
+ }
345
+ if (!byRoute.has(landing)) {
346
+ findings.push(finding(landing, 'missing-landing', `this export has no ${landing} document, so the map could not be checked.`))
347
+ }
348
+
349
+ // Both halves: the derived set is what the page does, the declared set is what
350
+ // the page may do. A page that grows a third region carrying a second pack is
351
+ // the difference between them.
352
+ const derivedSecond = [...onLanding]
353
+ .filter(([, packs]) => packs.some((pack) => pack !== map.ground))
354
+ .map(([region]) => region)
355
+ .sort()
356
+ const allowedSecond = [...(map.secondPackRegions ?? [])].sort()
357
+ if (JSON.stringify(derivedSecond) !== JSON.stringify(allowedSecond)) {
358
+ findings.push(
359
+ finding(
360
+ 'pack-map',
361
+ 'second-pack-regions',
362
+ `the regions carrying a pack that is not the ground are ${JSON.stringify(derivedSecond)}, and the map\n` +
363
+ ` allows ${JSON.stringify(allowedSecond)}. A boundary moves the corner radius beneath it, so a third\n` +
364
+ ' region carrying a second pack is a third set of corners that mean something other than radius.',
365
+ ),
366
+ )
367
+ }
368
+
369
+ notes.push(
370
+ `${l.id}: ${findings.length} finding(s) across ${documents.length} document(s) and ${boundaries} boundary/boundaries read`,
371
+ )
372
+ notes.push(`${l.id}: the ground is "${map.ground}"; ${marks} of ${boundaries} boundary/boundaries sit on a mark`)
373
+ notes.push(`${l.id}: regions read, with the packs each carries and how many routes carry it:`)
374
+ for (const [region, entry] of [...actual.entries()].sort()) {
375
+ notes.push(`${l.id}: ${region} ${multiset([...entry.packs])} on ${entry.routes.length} route(s)`)
376
+ }
377
+ notes.push(
378
+ `${l.id}: ${derivedSecond.length} region(s) carry a pack that is not the ground: ${derivedSecond.join(', ')}; the map allows ${allowedSecond.join(', ')}`,
379
+ )
380
+ notes.push(
381
+ `${l.id}: both modes were resolved for every boundary, from ${cssFiles.length} emitted stylesheet(s) and from the\n` +
382
+ ' published token contract resolved through the component package, and each boundary was matched against both.',
383
+ )
384
+ notes.push(
385
+ `${l.id}: the region resolver is this repository's own (${config.regionResolver ?? 'none declared'}), because naming a\n` +
386
+ " region means knowing this site's structure. The law above is not per site.",
387
+ )
388
+ notes.push(
389
+ `${l.id}: the honest limit: this is a selector match over emitted CSS and emitted markup, not a layout engine.\n` +
390
+ ' It cannot see a runtime that sets data-pack after paint, and a screenshot in one mode is not evidence.',
391
+ )
392
+
393
+ return { law: l, findings, notes }
394
+ }
395
+
396
+ /**
397
+ * The site's own region resolver.
398
+ *
399
+ * Required rather than defaulted, because a default would be this file's guess at
400
+ * one page's structure, and a guess that silently named nothing would turn every
401
+ * boundary on every site into an unnamed region and read as a broken map rather
402
+ * than as a missing declaration.
403
+ */
404
+ async function regionResolver(root, config, mapPath) {
405
+ const declared = config.regionResolver
406
+ if (!declared) {
407
+ throw new CoverageError(
408
+ `no region resolver is declared, so a boundary cannot be named and the map cannot be checked.\n` +
409
+ ` Add "regionResolver" to the gate's configuration: a module beside ${mapPath} that exports regionOf(element, document).`,
410
+ )
411
+ }
412
+ const file = path.join(root, declared)
413
+ if (!existsSync(file)) {
414
+ throw new CoverageError(`${declared} does not resolve, so no boundary on any route can be named.`)
415
+ }
416
+ const module = await import(pathToFileURL(file).href)
417
+ if (typeof module.regionOf !== 'function') {
418
+ throw new CoverageError(`${declared} does not export regionOf, so no boundary on any route can be named.`)
419
+ }
420
+ return module.regionOf
421
+ }
package/gates/pin.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The pin law: the design system is a version, and the token package is not
3
+ * this repository's dependency.
4
+ *
5
+ * The contract these four repositories coordinated through had a version line,
6
+ * and it drifted. One site spent a release on a different component line from
7
+ * its three siblings and nothing failed, because the gate that read the pin only
8
+ * asked whether the pin was exact, which it was. Exactness was necessary and not
9
+ * sufficient, and a rule that checks the easy half and calls itself the rule is
10
+ * the failure this gate exists to end.
11
+ *
12
+ * So three things are asserted, and the third is the one this law is actually
13
+ * about:
14
+ *
15
+ * 1. the component package is pinned to an exact version;
16
+ * 2. the token package is not declared in any dependency block;
17
+ * 3. the token package's name appears nowhere in the workspace configuration.
18
+ *
19
+ * (2) and (3) are the same fact held twice in the four repositories, in a
20
+ * manifest and in a `minimumReleaseAgeExclude` list, and both copies were
21
+ * invisible to each other. The component package declares the token package at
22
+ * an exact version, so the consumer cannot be handed a mismatched pair and a
23
+ * second declaration of that number is a second fact to keep in step with a
24
+ * release. The token version is the component package's business. It reaches
25
+ * this repository because the component package requires it, not because this
26
+ * repository named it.
27
+ *
28
+ * Coverage is asserted: both files must resolve and the design system must
29
+ * actually load, so a run that read a manifest with nothing in it fails rather
30
+ * than reports a repository with no pin.
31
+ */
32
+ import { law } from './laws.mjs'
33
+ import { CoverageError, PACKAGE, TOKEN_PACKAGE, designSystem, finding, floor, read, requireRoot } from './run.mjs'
34
+
35
+ /** The blocks a manifest can declare a dependency in. `overrides` is where a line gets pinned back. */
36
+ const DEPENDENCY_BLOCKS = [
37
+ 'dependencies',
38
+ 'devDependencies',
39
+ 'peerDependencies',
40
+ 'optionalDependencies',
41
+ 'overrides',
42
+ 'resolutions',
43
+ 'pnpm.overrides',
44
+ 'pnpm.resolutions',
45
+ ]
46
+
47
+ const at = (object, dotted) =>
48
+ dotted.split('.').reduce((value, key) => (value == null ? undefined : value[key]), object)
49
+
50
+ /** An exact version, and nothing else. A range is not a version anybody chose. */
51
+ const EXACT = /^\d+\.\d+\.\d+$/
52
+
53
+ export function run({ root, config }) {
54
+ const l = law('pin')
55
+ const manifestPath = config.manifest ?? 'package.json'
56
+ const workspacePath = config.workspace ?? 'pnpm-workspace.yaml'
57
+ const findings = []
58
+ const notes = []
59
+
60
+ requireRoot(root, manifestPath)
61
+ const manifest = JSON.parse(read(root, manifestPath))
62
+
63
+ const pin = manifest.dependencies?.[PACKAGE] ?? manifest.devDependencies?.[PACKAGE]
64
+ if (typeof pin !== 'string' || !EXACT.test(pin)) {
65
+ findings.push(
66
+ finding(
67
+ manifestPath,
68
+ 'range-pin',
69
+ `${PACKAGE} is pinned as ${JSON.stringify(pin ?? null)}, and a range lets this repository move onto a\n` +
70
+ ' design-system line nobody chose for it.',
71
+ ),
72
+ )
73
+ }
74
+
75
+ for (const block of DEPENDENCY_BLOCKS) {
76
+ const declared = at(manifest, block)
77
+ if (!declared || typeof declared !== 'object') continue
78
+ for (const name of Object.keys(declared)) {
79
+ if (name !== TOKEN_PACKAGE) continue
80
+ findings.push(
81
+ finding(
82
+ manifestPath,
83
+ 'second-declaration',
84
+ `\`${block}\` declares ${TOKEN_PACKAGE}, and ${PACKAGE} declares it at an exact version itself.\n` +
85
+ ` Two repositories holding one number is two facts to keep in step, and this is the one that\n` +
86
+ ` already drifted once: the token package's version is the component package's decision.`,
87
+ ),
88
+ )
89
+ }
90
+ }
91
+
92
+ const workspace = read(root, workspacePath)
93
+ if (workspace === null) {
94
+ throw new CoverageError(
95
+ `${workspacePath} does not resolve, so the second copy of the token version cannot be read at\n` +
96
+ ' all. A workspace configuration that does not exist is the cleanest version of this defect.',
97
+ )
98
+ }
99
+ workspace.split('\n').forEach((line, index) => {
100
+ if (!line.includes(TOKEN_PACKAGE)) return
101
+ findings.push(
102
+ finding(
103
+ `${workspacePath}:${index + 1}`,
104
+ 'second-declaration',
105
+ 'the token package\'s name is in the workspace configuration. A release-age exclusion is a\n' +
106
+ ' second declaration of the version the component package already declares, and it is the\n' +
107
+ ' copy nothing else can see.',
108
+ ),
109
+ )
110
+ })
111
+
112
+ // Coverage: the installed design system has to load, because a pin nobody can
113
+ // resolve is a pin that states an intention.
114
+ const installed = designSystem(root)
115
+ floor('the design system resolved to a version', installed.version ? 1 : 0, 1)
116
+
117
+ notes.push(
118
+ `${l.id}: ${PACKAGE}@${pin ?? 'unpinned'} is pinned exactly, and ${TOKEN_PACKAGE} is declared by` +
119
+ ` ${PACKAGE}@${installed.version} rather than by this repository.`,
120
+ )
121
+ notes.push(
122
+ `${l.id}: read ${manifestPath} and ${workspacePath}; ${DEPENDENCY_BLOCKS.length} dependency blocks and both`,
123
+ )
124
+ notes.push(
125
+ `${l.id}: the installed package is read through its own export map, so a renamed subpath is an error`,
126
+ )
127
+ notes.push(`${l.id}: here rather than a run that reports every property in this repository as undeclared.`)
128
+
129
+ return { law: l, findings, notes }
130
+ }