@fusion.dev/helix-diagnostics 0.136.0 → 0.138.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/README.md CHANGED
@@ -43,21 +43,36 @@ diagnostic cannot drift from what is true.
43
43
 
44
44
  ## What a report holds
45
45
 
46
+ A report is one run, in one shape for every sink, versioned by `schema`:
47
+
48
+ - **`run`**: a new `id`, when it began (`at`), how long it took, where it ran
49
+ (`environment`, as you declare it), and the `page`, as a pattern by default:
50
+ `/orders/1042` reads `/orders/:id`.
51
+ - **`project`**: the name and tags your configuration gives. The key is never
52
+ written into a report.
53
+ - **`versions`**: the collector's and the rules', the Helix versions loaded on
54
+ the page (two means two copies), and the version each manifest describes.
46
55
  - **`coverage`**: how many stylesheets and shadow roots were read, and which
47
56
  sheets could not be. A cross-origin stylesheet throws on `.cssRules`, so a run
48
57
  that read three sheets of nineteen returns the same empty findings as one that
49
58
  read all nineteen. Read `coverage` before you believe `findings`.
50
- - **`findings`**: each with its kind, a severity, the layer that set the
51
- severity, the tag, and the rule's own text as evidence.
52
- - **`silenced`**: how many findings each override layer turned off.
59
+ - **`findings`**: each with its kind, a severity, the place that set the
60
+ severity, the tag and its family, a `category` (`styling`, `markup`,
61
+ `integrity`, or `guideline`), an `owner` (`consumer`, or `helix` for a fault
62
+ in the library), a `fingerprint` that stays the same from one run to the next
63
+ for the same fault, and the rule's own text as evidence.
53
64
  - **`usage`**: the Helix elements on the page by tag, the axes set on them
54
65
  (`emphasis`, `material`, `separation`, and `tone`), the named slots filled,
55
66
  the elements Helix renders inside other Helix elements, and the parts and
56
- hooks your stylesheets use. With a watcher it counts every element that
57
- rendered since `watch()` began, including ones since removed, such as a
58
- closed dialog. Without one it counts the elements on the page at the moment
59
- of the run. Elements named `helix-*` that no manifest lists, such as your
60
- own, are counted apart as `unlisted`.
67
+ hooks your stylesheets use, with the values the hooks are set to. With a
68
+ watcher it counts every element that rendered since `watch()` began,
69
+ including ones since removed, such as a closed dialog. Without one it counts
70
+ the elements on the page at the moment of the run. Elements named `helix-*`
71
+ that no manifest lists, such as your own, are counted apart as `unlisted`.
72
+ - **`configuration`**: the settings the run was made under, each override in
73
+ effect, and how many findings each override turned off, by kind.
74
+ - **`withheld`**: the categories your configuration turned off for sending,
75
+ and which place turned each off.
61
76
 
62
77
  ## What the console shows
63
78
 
@@ -80,37 +95,100 @@ works on it:
80
95
  | Kind | Severity | Means |
81
96
  | --- | --- | --- |
82
97
  | `dead-part` | error | A `::part()` names a part the element does not publish |
83
- | `dead-hook` | error | A custom property is set where nothing answers it |
98
+ | `dead-hook` | error | A hook is set where nothing answers it, or a name close to a hook is set that nothing reads |
99
+ | `unknown-attribute` | error | A Helix element carries an attribute it does not take |
100
+ | `invalid-value` | error | An attribute with a closed set of values carries one outside it, so the element falls back to its default |
84
101
  | `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
85
102
  | `unknown-tag` | error | A rule names a tag no manifest lists |
86
103
  | `duplicate-registration` | error | Two copies of Helix are on the page |
87
- | `manifest-skew` | error | An element renders a part its manifest does not list, so the manifest is from another version. It replaces every other finding, since each would be judged against the wrong version. Turned off, the page is judged as usual |
104
+ | `manifest-skew` | error | The manifest is from another version than the page runs. It replaces every other finding, since each would be judged against the wrong version. Turned off, the page is judged as usual |
88
105
 
89
- ## Change a severity
106
+ **Hooks are checked wherever they are set**: on a Helix tag, a class, `:root`,
107
+ or any other selector. A hook on a selector naming no Helix tag is judged only
108
+ on what the page holds now. It is a finding when the selector matches elements
109
+ and none of them is, or holds, an element that answers it, while one that does
110
+ is on the page. A selector matching nothing yet is left alone, since it may be
111
+ for another route.
90
112
 
91
- Pass your project's file, parsed, as `file`, and overrides for one run as
92
- `rules`. The `rules` argument wins over the file, and each finding says which
93
- one set its severity. An override is a severity (`error`, `warn`, or `info`),
94
- `off`, or either for some tags only:
113
+ **Attributes are checked on the elements the page wrote.** The global HTML
114
+ attributes, `aria-*`, `data-*`, and the prefixes frameworks write are never
115
+ findings. The markup Helix renders inside its own components is Helix's.
116
+
117
+ ## Configure it
118
+
119
+ Every setting can be given in three places, and a later place wins, field by
120
+ field: the defaults this package ships, your project's file, and the call.
121
+ The page cannot read files, so import your file and pass it as `file`:
95
122
 
96
123
  ```js
97
- import overrides from './helix-diagnostics.json' with { type: 'json' }
124
+ import project from './helix-diagnostics.json' with { type: 'json' }
98
125
 
99
126
  diagnose({
100
- file: overrides,
101
- rules: {
102
- 'dead-hook': { severity: 'warn', tags: ['helix-card'] },
103
- 'unforwarded-part': 'off',
104
- },
127
+ environment: 'development',
128
+ file: project,
129
+ rules: { 'unforwarded-part': 'off' },
105
130
  tiers,
106
131
  })
107
132
  ```
108
133
 
109
- An override for a kind that does not exist throws, and names the closest kind.
134
+ ```json
135
+ {
136
+ "project": { "name": "storefront", "tags": ["web"] },
137
+ "rules": { "dead-hook": { "severity": "warn", "tags": ["helix-card"] } },
138
+ "console": { "findings": "warn", "usage": "off" },
139
+ "send": { "performance": false },
140
+ "detail": { "evidence": "full", "hookValues": true, "pages": "patterns" },
141
+ "environments": { "development": true, "ci": true, "production": false }
142
+ }
143
+ ```
144
+
145
+ | Setting | Takes | Default |
146
+ | --- | --- | --- |
147
+ | `rules.<kind>` | `error`, `warn`, `info`, `off`, or `{ severity, tags }` | see Finding kinds |
148
+ | `console.findings` | `error`, `warn`, `info`, or `off`: the lowest severity printed | `info` |
149
+ | `console.coverage` | `info` or `off` | `info` |
150
+ | `console.usage` | `debug` or `off` | `debug` |
151
+ | `send.<category>` | `true` or `false`, for `findings`, `usage`, `coverage`, `configuration`, `runtime`, `performance`, and `assist` | all on but `performance` |
152
+ | `detail.evidence` | `full`, `selector`, or `none` | `full` |
153
+ | `detail.hookValues` | `true` or `false` | `true` |
154
+ | `detail.pages` | `patterns`, `full` (the path, never the query), or `none` | `patterns` |
155
+ | `environments.<name>` | `true` or `false`; `production` takes `{ sample }` from 0 to 1 | development and CI on |
156
+ | `project` | `key`, `name`, and `tags` | none |
157
+
158
+ `send`, `detail`, and `environments` decide what a sending sink sends, and
159
+ nothing here sends yet: they are recorded in every report, so a dashboard
160
+ knows what a project withheld. A setting or a value that does not exist
161
+ throws, and names the closest that does.
110
162
 
111
163
  Development only. It walks every stylesheet in the page, and it sends nothing
112
164
  anywhere.
113
165
 
166
+ ## In CI
167
+
168
+ `@fusion.dev/helix-diagnostics/ci` gathers the runs of a CI job into one JSON
169
+ file, in the shape every sink shares, and says whether the job should fail:
170
+
171
+ ```js
172
+ import { ciSink } from '@fusion.dev/helix-diagnostics/ci'
173
+
174
+ // `runs` are the reports `diagnose` returned in each page your job opened.
175
+ const { code, summary } = ciSink(runs, { file: 'reports/helix-diagnostics.json' })
176
+ console.log(summary)
177
+ process.exitCode = code
178
+ ```
179
+
180
+ A fault found on many pages is one line, with the pages it was on. `error`
181
+ findings fail the job by default; pass `failOn: 'warn'` to fail on warnings
182
+ too, or `'never'` to report without failing.
183
+
184
+ ## Versions
185
+
186
+ `@fusion.dev/helix-core` lists its version on the page as it loads, and every
187
+ manifest built from 0.137 names the package and version it describes. Where
188
+ both sides say, a manifest from another version is one `manifest-skew`
189
+ finding in place of all others. Where either is silent, the check falls back
190
+ to a part rendering that the manifest does not list.
191
+
114
192
  [Documentation](https://fusion.dev/helix)
115
193
 
116
194
  MIT
package/dist/analyze.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * consumer's own `--my-app-gap` out of the findings without a list of
17
17
  * exemptions, and what lets a finding say which tag it is wrong for.
18
18
  */
19
- import type { Answered, Located } from './collect.js';
19
+ import type { Answered, Located, Readers } from './collect.js';
20
20
  import { type Published } from './manifest.js';
21
21
  import type { Raw } from './report.js';
22
22
  /** Every `helix-*` tag a selector mentions, in source order. */
@@ -33,7 +33,7 @@ export declare const parts: (selector: string) => Array<{
33
33
  * unforwarded part is phrased: the part exists, another tier publishes it, and
34
34
  * the one being styled did not forward it.
35
35
  */
36
- export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) => Raw[];
36
+ export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered, used?: Set<string>, readers?: Readers) => Raw[];
37
37
  /**
38
38
  * Two copies of Helix on one page, which is not a CSS fault and is the most
39
39
  * expensive one on the list.
@@ -68,3 +68,20 @@ export declare const registrations: (from?: Document, known?: () => string[]) =>
68
68
  * findings that are each the same mistake are worse than one that names it.
69
69
  */
70
70
  export declare const skew: (tags: Map<string, Published>, from: Document) => Raw[];
71
+ /**
72
+ * A manifest from another version, read from the numbers where both sides
73
+ * have one: the version a manifest describes, against the versions of core
74
+ * loaded on the page. Definite where `skew` above infers, and the check
75
+ * `diagnose` makes first.
76
+ */
77
+ export declare const versionSkew: (manifests: Record<string, string>, seen: string[]) => Raw[];
78
+ /**
79
+ * The attributes on the page's Helix elements that do nothing: a name the
80
+ * element does not take, and a value outside the ones a closed set allows.
81
+ * Both are valid HTML and silent, since the element falls back to its default.
82
+ *
83
+ * **Only what the page wrote.** An element Helix renders inside another is
84
+ * Helix's own markup. One finding per tag, attribute, and value, however many
85
+ * elements carry it.
86
+ */
87
+ export declare const markup: (tags: Map<string, Published>, from: Document) => Raw[];
package/dist/analyze.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { nearest } from './manifest.js';
2
2
  export const targets = (selector) => {
3
3
  const found = new Set();
4
- for (const match of selector.matchAll(/\bhelix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
4
+ for (const match of selector.matchAll(/(?<![\w.#-])helix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
5
5
  found.add(match[0]);
6
6
  }
7
7
  return [...found];
@@ -16,38 +16,159 @@ export const parts = (selector) => {
16
16
  }
17
17
  return found;
18
18
  };
19
- const reaches = (host, wanted, depth = 0) => {
19
+ const matchable = (selector) => {
20
+ if (selector.includes(':host')) {
21
+ return null;
22
+ }
23
+ const plain = selector
24
+ .replace(/::[\w-]+(\([^)]*\))?/g, '')
25
+ .replace(/:(focus-visible|focus-within|hover|focus|active|visited|target)(?![\w-])/g, '')
26
+ .trim();
27
+ return plain === '' ? null : plain;
28
+ };
29
+ const answersAt = (node, property, answering, readers) => {
30
+ if (answering.has(node.localName)) {
31
+ return true;
32
+ }
33
+ for (const { scope, selector } of readers.get(property) ?? []) {
34
+ if (scope !== undefined && node.getRootNode() !== scope) {
35
+ continue;
36
+ }
37
+ const plain = matchable(selector);
38
+ try {
39
+ if (plain !== null && node.matches(plain)) {
40
+ return true;
41
+ }
42
+ }
43
+ catch {
44
+ }
45
+ }
46
+ return false;
47
+ };
48
+ const reachesAnswer = (host, property, answering, readers, depth = 0) => {
20
49
  if (depth > 16) {
21
50
  return false;
22
51
  }
23
52
  for (const node of [host, ...host.querySelectorAll('*')]) {
24
- if (node !== host && wanted.has(node.localName)) {
53
+ if (answersAt(node, property, answering, readers)) {
25
54
  return true;
26
55
  }
27
- const root = node.shadowRoot;
28
- if (root) {
29
- for (const child of root.children) {
30
- if (wanted.has(child.localName) || reaches(child, wanted, depth + 1)) {
31
- return true;
32
- }
56
+ for (const child of node.shadowRoot?.children ?? []) {
57
+ if (reachesAnswer(child, property, answering, readers, depth + 1)) {
58
+ return true;
33
59
  }
34
60
  }
35
61
  }
36
62
  return false;
37
63
  };
64
+ const loose = (located, answerers, from, used, onPage, listedBy, readers) => {
65
+ const findings = [];
66
+ if (located.scope instanceof ShadowRoot && located.scope.host.localName.startsWith('helix-')) {
67
+ return findings;
68
+ }
69
+ const selector = located.rule.selectorText;
70
+ for (const property of Array.from(located.rule.style ?? [])) {
71
+ if (!property.startsWith('--') ||
72
+ property.startsWith('--helix-') ||
73
+ property.startsWith('--_')) {
74
+ continue;
75
+ }
76
+ const answering = answerers.get(property);
77
+ if (!answering) {
78
+ if (used.has(property)) {
79
+ continue;
80
+ }
81
+ const suggestion = nearest(property, answerers.keys(), HOOK_DISTANCE);
82
+ if (!suggestion) {
83
+ continue;
84
+ }
85
+ const tag = [...(listedBy.get(suggestion) ?? answerers.get(suggestion) ?? [])].sort()[0] ?? '';
86
+ findings.push({
87
+ expected: suggestion,
88
+ kind: 'dead-hook',
89
+ message: `\`${selector}\` sets \`${property}\`, which no Helix element answers and nothing on the page reads. \`${suggestion}\` is a hook \`${tag}\` answers, two edits or fewer away, and almost certainly what was meant.`,
90
+ origin: origin(located),
91
+ subject: `${selector} { ${property} }`,
92
+ tag,
93
+ });
94
+ continue;
95
+ }
96
+ const scope = located.scope ?? from;
97
+ const plain = matchable(selector);
98
+ if (!scope || !from?.documentElement || plain === null) {
99
+ continue;
100
+ }
101
+ let matched;
102
+ try {
103
+ matched = [...scope.querySelectorAll(plain)];
104
+ }
105
+ catch {
106
+ continue;
107
+ }
108
+ if (matched.length === 0 || !onPage(property, answering)) {
109
+ continue;
110
+ }
111
+ if (matched.some((host) => reachesAnswer(host, property, answering, readers))) {
112
+ continue;
113
+ }
114
+ const tags = [...answering].sort();
115
+ findings.push({
116
+ kind: 'dead-hook',
117
+ message: `\`${selector}\` sets \`${property}\`, and nothing under the ${matched.length === 1 ? 'element' : `${matched.length} elements`} it matches answers it, though something elsewhere on this page does. ${tags
118
+ .slice(0, 3)
119
+ .map((one) => `\`${one}\``)
120
+ .join(', ')} ${tags.length === 1 ? 'answers' : 'answer'} it. It targets the wrong element, or expects a descendant that is not there.`,
121
+ origin: origin(located),
122
+ subject: `${selector} { ${property} }`,
123
+ tag: tags[0] ?? '',
124
+ });
125
+ }
126
+ return findings;
127
+ };
38
128
  const origin = (located) => ({
39
129
  cssText: located.rule.cssText,
40
130
  index: located.index,
41
131
  sheet: located.sheet,
42
132
  });
43
- export const analyze = (rules, tags, from, answered = new Map()) => {
133
+ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(), readers = new Map()) => {
44
134
  const findings = [];
135
+ const answeredBy = new Map();
136
+ const answers = (property, tag) => {
137
+ const set = answeredBy.get(property) ?? new Set();
138
+ set.add(tag);
139
+ answeredBy.set(property, set);
140
+ };
141
+ const listedBy = new Map();
142
+ for (const [tag, element] of tags) {
143
+ for (const property of element.hooks.keys()) {
144
+ answers(property, tag);
145
+ listedBy.set(property, new Set([...(listedBy.get(property) ?? []), tag]));
146
+ }
147
+ }
148
+ for (const [tag, reads] of answered) {
149
+ for (const property of reads) {
150
+ if (tags.has(tag)) {
151
+ answers(property, tag);
152
+ }
153
+ }
154
+ }
155
+ const present = new Map();
156
+ const onPage = (property, answering) => {
157
+ let found = present.get(property);
158
+ if (found === undefined) {
159
+ found = from?.documentElement
160
+ ? reachesAnswer(from.documentElement, property, answering, readers)
161
+ : false;
162
+ present.set(property, found);
163
+ }
164
+ return found;
165
+ };
45
166
  for (const located of rules) {
46
167
  const selector = located.rule.selectorText;
47
- if (!selector?.includes('helix-')) {
168
+ if (!selector) {
48
169
  continue;
49
170
  }
50
- for (const { names, tag } of parts(selector)) {
171
+ for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
51
172
  const element = tags.get(tag);
52
173
  if (!element) {
53
174
  findings.push({
@@ -92,6 +213,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
92
213
  const mentioned = targets(selector).map((tag) => [tag, tags.get(tag)]);
93
214
  const known = mentioned.filter(([, element]) => element);
94
215
  if (known.length === 0) {
216
+ findings.push(...loose(located, answeredBy, from, used, onPage, listedBy, readers));
95
217
  continue;
96
218
  }
97
219
  for (const property of Array.from(located.rule.style ?? [])) {
@@ -106,22 +228,27 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
106
228
  }
107
229
  const answerers = new Set([...tags].filter(([, element]) => element.hooks.has(property)).map(([tag]) => tag));
108
230
  const candidates = known.flatMap(([, element]) => [...(element?.hooks.keys() ?? [])]);
109
- const suggestion = nearest(property, candidates);
110
- if (answerers.size === 0 && !suggestion) {
231
+ const suggestion = nearest(property, candidates, HOOK_DISTANCE);
232
+ if ((answerers.size === 0 && !suggestion) || (answerers.size === 0 && used.has(property))) {
111
233
  continue;
112
234
  }
113
235
  if (answerers.size > 0 && !suggestion) {
114
236
  if (!from) {
115
237
  continue;
116
238
  }
117
- let inherits = false;
118
- for (const host of from.querySelectorAll(selector.replaceAll(/::part\([^)]*\)/g, ''))) {
119
- if (reaches(host, answerers)) {
120
- inherits = true;
121
- break;
122
- }
239
+ const plain = matchable(selector);
240
+ if (plain === null) {
241
+ continue;
242
+ }
243
+ let matched;
244
+ try {
245
+ matched = [...(located.scope ?? from).querySelectorAll(plain)];
123
246
  }
124
- if (inherits) {
247
+ catch {
248
+ continue;
249
+ }
250
+ if (matched.length === 0 ||
251
+ matched.some((host) => reachesAnswer(host, property, answeredBy.get(property) ?? answerers, readers))) {
125
252
  continue;
126
253
  }
127
254
  }
@@ -130,7 +257,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
130
257
  expected: suggestion,
131
258
  kind: 'dead-hook',
132
259
  message: suggestion
133
- ? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`, which is one character class away and is almost certainly what was meant.`
260
+ ? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`, two edits or fewer away, which is almost certainly what was meant.`
134
261
  : `\`${tag}\` does not answer \`${property}\`, and nothing under it that does was found in the page. ${[...answerers].sort().slice(0, 3).join(', ')} answer it, so this either targets the wrong element or expects a descendant that is not there.`,
135
262
  origin: origin(located),
136
263
  subject: `${selector} { ${property} }`,
@@ -207,3 +334,114 @@ export const skew = (tags, from) => {
207
334
  visit(from, 0);
208
335
  return findings;
209
336
  };
337
+ export const versionSkew = (manifests, seen) => seen.length === 0
338
+ ? []
339
+ : Object.entries(manifests)
340
+ .filter(([, version]) => !seen.includes(version))
341
+ .map(([name, version]) => ({
342
+ kind: 'manifest-skew',
343
+ message: `The manifest passed in for \`${name}\` describes ${version}, and the page runs Helix ${seen.join(' and ')}. Every other finding would be judged against the wrong version, so none are reported. Pass the \`custom-elements.json\` from the installed packages.`,
344
+ subject: `${name}@${version}`,
345
+ tag: '',
346
+ }));
347
+ const GLOBAL = new Set([
348
+ 'accesskey',
349
+ 'autocapitalize',
350
+ 'autofocus',
351
+ 'class',
352
+ 'contenteditable',
353
+ 'defer-hydration',
354
+ 'dir',
355
+ 'draggable',
356
+ 'enterkeyhint',
357
+ 'exportparts',
358
+ 'hidden',
359
+ 'id',
360
+ 'inert',
361
+ 'inputmode',
362
+ 'is',
363
+ 'itemid',
364
+ 'itemprop',
365
+ 'itemref',
366
+ 'itemscope',
367
+ 'itemtype',
368
+ 'lang',
369
+ 'nonce',
370
+ 'part',
371
+ 'popover',
372
+ 'role',
373
+ 'slot',
374
+ 'spellcheck',
375
+ 'style',
376
+ 'tabindex',
377
+ 'title',
378
+ 'translate',
379
+ 'writingsuggestions',
380
+ ]);
381
+ const PREFIXES = ['aria-', 'data-', 'on', '_ngcontent-', '_nghost-', 'ng-', 'v-', 'x-', ':', '@'];
382
+ const HOOK_DISTANCE = 2;
383
+ export const markup = (tags, from) => {
384
+ const found = new Map();
385
+ const visit = (root, depth) => {
386
+ if (depth > 32) {
387
+ return;
388
+ }
389
+ for (const element of root.querySelectorAll('*')) {
390
+ const host = element.getRootNode();
391
+ const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
392
+ const published = composed ? undefined : tags.get(element.localName);
393
+ if (published) {
394
+ for (const { name, value } of Array.from(element.attributes)) {
395
+ if (GLOBAL.has(name) ||
396
+ PREFIXES.some((prefix) => name.startsWith(prefix)) ||
397
+ published.written.has(name)) {
398
+ continue;
399
+ }
400
+ const tag = element.localName;
401
+ const allows = published.attributes.get(name);
402
+ let raw;
403
+ if (allows === undefined) {
404
+ const suggestion = nearest(name, published.attributes.keys());
405
+ raw = {
406
+ expected: suggestion,
407
+ kind: 'unknown-attribute',
408
+ message: suggestion
409
+ ? `\`${tag}\` takes no attribute \`${name}\`, so it does nothing. The closest it takes is \`${suggestion}\`.`
410
+ : `\`${tag}\` takes no attribute \`${name}\`, so it does nothing.`,
411
+ subject: `<${tag} ${name}>`,
412
+ tag,
413
+ };
414
+ }
415
+ else if (allows && value !== '' && !allows.has(value)) {
416
+ const suggestion = nearest(value, allows);
417
+ raw = {
418
+ expected: suggestion,
419
+ kind: 'invalid-value',
420
+ message: `\`${tag}\`'s \`${name}\` takes ${[...allows].map((one) => `\`${one}\``).join(', ')}, so \`${value}\` falls back to the default.${suggestion ? ` The closest is \`${suggestion}\`.` : ''}`,
421
+ subject: `<${tag} ${name}="${value}">`,
422
+ tag,
423
+ };
424
+ }
425
+ if (raw) {
426
+ const key = `${raw.kind} ${raw.subject}`;
427
+ const seen = found.get(key);
428
+ if (seen) {
429
+ seen.count += 1;
430
+ }
431
+ else {
432
+ found.set(key, { ...raw, count: 1 });
433
+ }
434
+ }
435
+ }
436
+ }
437
+ if (element.shadowRoot) {
438
+ visit(element.shadowRoot, depth + 1);
439
+ }
440
+ }
441
+ };
442
+ visit(from, 0);
443
+ return [...found.values()].map(({ count, ...raw }) => ({
444
+ ...raw,
445
+ message: count > 1 ? `${raw.message} On ${count} elements.` : raw.message,
446
+ }));
447
+ };
package/dist/ci.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Report, Severity } from './report.js';
2
+ /** What the CI sink writes, and what fails the job. */
3
+ export interface CiOptions {
4
+ /** Where the runs are written, as JSON. */
5
+ file: string;
6
+ /** The lowest severity that fails the job, or `never`. `error` by default. */
7
+ failOn?: Severity | 'never';
8
+ }
9
+ /**
10
+ * Writes the runs, and says what failed.
11
+ *
12
+ * Returns the exit code the job should end with and the summary to print;
13
+ * the caller prints and exits, so a script can add its own lines first.
14
+ */
15
+ export declare const ciSink: (runs: Report[], { file, failOn }: CiOptions) => {
16
+ code: 0 | 1;
17
+ summary: string;
18
+ };
package/dist/ci.js ADDED
@@ -0,0 +1,30 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const RANK = { error: 3, info: 1, warn: 2 };
4
+ const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
5
+ export const ciSink = (runs, { file, failOn = 'error' }) => {
6
+ mkdirSync(dirname(file), { recursive: true });
7
+ writeFileSync(file, `${JSON.stringify({ runs, schema: 1 }, null, 2)}\n`);
8
+ const faults = new Map();
9
+ for (const run of runs) {
10
+ for (const finding of run.findings) {
11
+ const fault = faults.get(finding.fingerprint) ?? { finding, pages: new Set() };
12
+ fault.pages.add(run.run.page ?? '(no page)');
13
+ faults.set(finding.fingerprint, fault);
14
+ }
15
+ }
16
+ const all = [...faults.values()];
17
+ const of = (severity) => all.filter(({ finding }) => finding.severity === severity).length;
18
+ const failing = failOn === 'never' ? [] : all.filter(({ finding }) => RANK[finding.severity] >= RANK[failOn]);
19
+ const unread = runs.reduce((total, run) => total + run.coverage.unreadable.length, 0);
20
+ const lines = [
21
+ `helix diagnostics: ${counted(runs.length, 'run')}, ${counted(of('error'), 'error')}, ${counted(of('warn'), 'warning')}, ${of('info')} info` +
22
+ (unread > 0 ? `, ${counted(unread, 'unreadable stylesheet')}` : '') +
23
+ `. Written to ${file}.`,
24
+ ];
25
+ for (const { finding, pages } of failing) {
26
+ const where = [...pages].sort();
27
+ lines.push(` ${finding.severity} ${finding.kind}: ${finding.subject}`, ` ${finding.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
28
+ }
29
+ return { code: failing.length > 0 ? 1 : 0, summary: lines.join('\n') };
30
+ };
package/dist/collect.d.ts CHANGED
@@ -20,6 +20,11 @@ import type { Coverage } from './report.js';
20
20
  export interface Located {
21
21
  index: number;
22
22
  rule: CSSStyleRule;
23
+ /**
24
+ * Where the rule's selector matches: the document for a page's own sheet,
25
+ * and the shadow root of the first host to adopt it for a component's.
26
+ */
27
+ scope?: Document | ShadowRoot | undefined;
23
28
  sheet: string;
24
29
  }
25
30
  /**
@@ -40,6 +45,17 @@ export interface Located {
40
45
  * the whole truth.
41
46
  */
42
47
  export type Answered = Map<string, Set<string>>;
48
+ /**
49
+ * The rules outside Helix's components that read each custom property with
50
+ * `var()`, by selector and the root they apply in. An element one of them
51
+ * matches answers the property as surely as a Helix tag does: Helix's own
52
+ * base sheet reads every button hook for a plain `[data-button]` link, and an
53
+ * example reads `--chart-series-1` in its own `::part()` rules.
54
+ */
55
+ export type Readers = Map<string, Array<{
56
+ scope: Document | ShadowRoot | undefined;
57
+ selector: string;
58
+ }>>;
43
59
  /**
44
60
  * Every readable rule in the page, and what was missed getting them.
45
61
  *
@@ -54,5 +70,7 @@ export type Answered = Map<string, Set<string>>;
54
70
  export declare const collect: (from?: Document) => {
55
71
  answered: Answered;
56
72
  coverage: Coverage;
73
+ readers: Readers;
57
74
  rules: Located[];
75
+ used: Set<string>;
58
76
  };