@fusion.dev/helix-diagnostics 0.137.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
@@ -95,11 +95,24 @@ works on it:
95
95
  | Kind | Severity | Means |
96
96
  | --- | --- | --- |
97
97
  | `dead-part` | error | A `::part()` names a part the element does not publish |
98
- | `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 |
99
101
  | `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
100
102
  | `unknown-tag` | error | A rule names a tag no manifest lists |
101
103
  | `duplicate-registration` | error | Two copies of Helix are on the page |
102
- | `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 |
105
+
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.
112
+
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.
103
116
 
104
117
  ## Configure it
105
118
 
@@ -150,6 +163,24 @@ throws, and names the closest that does.
150
163
  Development only. It walks every stylesheet in the page, and it sends nothing
151
164
  anywhere.
152
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
+
153
184
  ## Versions
154
185
 
155
186
  `@fusion.dev/helix-core` lists its version on the page as it loads, and every
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.
@@ -75,3 +75,13 @@ export declare const skew: (tags: Map<string, Published>, from: Document) => Raw
75
75
  * `diagnose` makes first.
76
76
  */
77
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;
123
242
  }
124
- if (inherits) {
243
+ let matched;
244
+ try {
245
+ matched = [...(located.scope ?? from).querySelectorAll(plain)];
246
+ }
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} }`,
@@ -217,3 +344,104 @@ export const versionSkew = (manifests, seen) => seen.length === 0
217
344
  subject: `${name}@${version}`,
218
345
  tag: '',
219
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
  };
package/dist/collect.js CHANGED
@@ -23,7 +23,7 @@ const rules = (list, sheet, into, depth = 0, reads) => {
23
23
  if ('selectorText' in rule) {
24
24
  into.push({ index, rule: rule, sheet });
25
25
  if (reads) {
26
- for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)) {
26
+ for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[\w-]+)/g)) {
27
27
  if (match[1]) {
28
28
  reads.add(match[1]);
29
29
  }
@@ -80,20 +80,38 @@ export const collect = (from = document) => {
80
80
  const seen = new Set();
81
81
  const labels = new Map();
82
82
  const answered = new Map();
83
+ const used = new Set();
84
+ const readers = new Map();
83
85
  let readable = 0;
84
86
  const take = (sheet, host) => {
85
87
  if (!sheet || seen.has(sheet)) {
86
88
  return;
87
89
  }
88
90
  seen.add(sheet);
89
- let reads;
91
+ let reads = used;
90
92
  if (host) {
91
93
  reads = answered.get(host.localName) ?? new Set();
92
94
  answered.set(host.localName, reads);
93
95
  }
96
+ const before = located.length;
94
97
  if (read(sheet, located, unreadable, labels, 0, host, reads)) {
95
98
  readable += 1;
96
99
  }
100
+ const scope = host ? (host.shadowRoot ?? undefined) : from;
101
+ const ours = !host?.localName.startsWith('helix-');
102
+ for (let at = before; at < located.length; at += 1) {
103
+ const one = located[at];
104
+ one.scope = scope;
105
+ if (ours && one.rule.selectorText) {
106
+ for (const match of (one.rule.cssText ?? '').matchAll(/var\(\s*(--[\w-]+)/g)) {
107
+ const property = match[1];
108
+ readers.set(property, [
109
+ ...(readers.get(property) ?? []),
110
+ { scope, selector: one.rule.selectorText },
111
+ ]);
112
+ }
113
+ }
114
+ }
97
115
  };
98
116
  for (const sheet of Array.from(from.styleSheets)) {
99
117
  take(sheet);
@@ -114,9 +132,18 @@ export const collect = (from = document) => {
114
132
  take(owner.sheet, root.host);
115
133
  }
116
134
  }
135
+ for (const [tag, reads] of answered) {
136
+ if (!tag.startsWith('helix-')) {
137
+ for (const property of reads) {
138
+ used.add(property);
139
+ }
140
+ }
141
+ }
117
142
  return {
118
143
  answered,
119
144
  coverage: { read: readable, roots: shadows.length, sheets: seen.size, unreadable },
145
+ readers,
120
146
  rules: located,
147
+ used,
121
148
  };
122
149
  };
package/dist/identity.js CHANGED
@@ -2,16 +2,20 @@ const CATEGORY = {
2
2
  'dead-hook': 'styling',
3
3
  'dead-part': 'styling',
4
4
  'duplicate-registration': 'integrity',
5
+ 'invalid-value': 'markup',
5
6
  'manifest-skew': 'integrity',
6
7
  'unforwarded-part': 'styling',
8
+ 'unknown-attribute': 'markup',
7
9
  'unknown-tag': 'integrity',
8
10
  };
9
11
  const OWNER = {
10
12
  'dead-hook': 'consumer',
11
13
  'dead-part': 'consumer',
12
14
  'duplicate-registration': 'consumer',
15
+ 'invalid-value': 'consumer',
13
16
  'manifest-skew': 'consumer',
14
17
  'unforwarded-part': 'helix',
18
+ 'unknown-attribute': 'consumer',
15
19
  'unknown-tag': 'consumer',
16
20
  };
17
21
  export const normalize = (subject) => subject
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { CATEGORIES, KINDS } from './report.js';
7
7
  export { SHIPPED_RULES } from './rules.js';
8
8
  export { watch } from './usage.js';
9
9
  export { VERSION } from './version.js';
10
- import { analyze, registrations, skew, versionSkew } from './analyze.js';
10
+ import { analyze, markup, registrations, skew, versionSkew } from './analyze.js';
11
11
  import { collect } from './collect.js';
12
12
  import { pagePattern, resolveConfig } from './config.js';
13
13
  import { identify } from './identity.js';
@@ -27,7 +27,7 @@ export const diagnose = (options) => {
27
27
  const resolved = resolveConfig(file, call);
28
28
  const { detail } = resolved.configuration;
29
29
  const document_ = from ?? globalThis.document;
30
- const { answered, coverage, rules: located } = collect(document_);
30
+ const { answered, coverage, readers, rules: located, used } = collect(document_);
31
31
  const tags = index(tiers);
32
32
  const counted = watcher ? watcher.counts() : walk(document_);
33
33
  const seen = loadedVersions();
@@ -38,7 +38,8 @@ export const diagnose = (options) => {
38
38
  const judged = skewed.findings.length > 0
39
39
  ? { findings: [], silenced: [] }
40
40
  : applyRules([
41
- ...analyze(located, tags, document_, answered),
41
+ ...analyze(located, tags, document_, answered, used, readers),
42
+ ...markup(tags, document_),
42
43
  ...registrations(document_, duplicates),
43
44
  ].map((one) => identify(one, tags)), file?.rules, call.rules);
44
45
  const path = page ?? globalThis.location?.pathname;
@@ -29,6 +29,18 @@ export interface ManifestSource {
29
29
  /** Where the module is, which names the family: `src/button/root/button-root.ts`. */
30
30
  path?: string;
31
31
  declarations?: Array<{
32
+ attributes?: Array<{
33
+ name: string;
34
+ type?: {
35
+ text?: string;
36
+ };
37
+ }>;
38
+ members?: Array<{
39
+ kind?: string;
40
+ name: string;
41
+ readonly?: boolean;
42
+ static?: boolean;
43
+ }>;
32
44
  cssParts?: Array<{
33
45
  name: string;
34
46
  }>;
@@ -52,9 +64,20 @@ export interface Tier {
52
64
  * scope, and `analyze.ts` needs both in the same file.
53
65
  */
54
66
  export interface Published {
67
+ /**
68
+ * Each attribute the tag takes, with the values it allows where its type
69
+ * is a union of string literals, and `null` where it takes any value.
70
+ */
71
+ attributes: Map<string, Set<string> | null>;
55
72
  /** The family, from the module's path: `button` for `src/button/root/button-root.ts`. */
56
73
  family?: string | undefined;
57
74
  hooks: Map<string, Set<string>>;
75
+ /**
76
+ * The attributes the element writes on itself: one named for a read-only
77
+ * member, as `helix-notifications` writes `unread`. Output, so never a
78
+ * finding when the page shows it.
79
+ */
80
+ written: Set<string>;
58
81
  parts: Map<string, Set<string>>;
59
82
  }
60
83
  /**
@@ -80,4 +103,4 @@ export declare const versionsOf: (tiers: Tier[]) => Record<string, string>;
80
103
  * `--button-background-color` however close they sort, and a confident wrong
81
104
  * suggestion costs more than none.
82
105
  */
83
- export declare const nearest: (wrong: string, candidates: Iterable<string>) => string | undefined;
106
+ export declare const nearest: (wrong: string, candidates: Iterable<string>, ceiling?: number) => string | undefined;
package/dist/manifest.js CHANGED
@@ -10,9 +10,24 @@ export const index = (tiers) => {
10
10
  }
11
11
  let element = tags.get(tag);
12
12
  if (!element) {
13
- element = { family, hooks: new Map(), parts: new Map() };
13
+ element = {
14
+ attributes: new Map(),
15
+ family,
16
+ hooks: new Map(),
17
+ parts: new Map(),
18
+ written: new Set(),
19
+ };
14
20
  tags.set(tag, element);
15
21
  }
22
+ for (const member of declaration.members ?? []) {
23
+ if (member.readonly && !member.static && member.kind === 'field') {
24
+ element.written.add(member.name);
25
+ element.written.add(member.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
26
+ }
27
+ }
28
+ for (const attribute of declaration.attributes ?? []) {
29
+ element.attributes.set(attribute.name, allowed(attribute.type?.text));
30
+ }
16
31
  for (const [field, into] of [
17
32
  [declaration.cssParts, element.parts],
18
33
  [declaration.cssProperties, element.hooks],
@@ -28,6 +43,17 @@ export const index = (tiers) => {
28
43
  }
29
44
  return tags;
30
45
  };
46
+ const allowed = (type) => {
47
+ if (!type) {
48
+ return null;
49
+ }
50
+ const literals = [...type.matchAll(/'([^']*)'/g)].map((match) => match[1]);
51
+ const rest = type
52
+ .replace(/'[^']*'/g, '')
53
+ .replace(/\b(undefined|null)\b/g, '')
54
+ .replace(/[|\s]/g, '');
55
+ return literals.length > 0 && rest === '' ? new Set(literals) : null;
56
+ };
31
57
  export const versionsOf = (tiers) => {
32
58
  const versions = {};
33
59
  for (const tier of tiers) {
@@ -38,9 +64,9 @@ export const versionsOf = (tiers) => {
38
64
  }
39
65
  return versions;
40
66
  };
41
- export const nearest = (wrong, candidates) => {
67
+ export const nearest = (wrong, candidates, ceiling = Math.floor(wrong.length / 3)) => {
42
68
  let best;
43
- let distance = Math.floor(wrong.length / 3);
69
+ let distance = Math.min(ceiling, Math.floor(wrong.length / 3));
44
70
  for (const candidate of candidates) {
45
71
  const found = within(wrong, candidate, distance);
46
72
  if (found !== undefined && found < distance + 1) {
package/dist/report.d.ts CHANGED
@@ -87,7 +87,7 @@ export interface Finding {
87
87
  tag: string;
88
88
  }
89
89
  /** Every fault class, stable across versions. */
90
- export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'manifest-skew', 'unforwarded-part', 'unknown-tag'];
90
+ export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'unforwarded-part', 'unknown-attribute', 'unknown-tag'];
91
91
  /** A fault class. */
92
92
  export type Kind = (typeof KINDS)[number];
93
93
  /** How serious a finding is. */
package/dist/report.js CHANGED
@@ -2,8 +2,10 @@ export const KINDS = [
2
2
  'dead-hook',
3
3
  'dead-part',
4
4
  'duplicate-registration',
5
+ 'invalid-value',
5
6
  'manifest-skew',
6
7
  'unforwarded-part',
8
+ 'unknown-attribute',
7
9
  'unknown-tag',
8
10
  ];
9
11
  export const CATEGORIES = [
package/dist/rules.js CHANGED
@@ -4,8 +4,10 @@ export const SHIPPED_RULES = {
4
4
  'dead-hook': 'error',
5
5
  'dead-part': 'error',
6
6
  'duplicate-registration': 'error',
7
+ 'invalid-value': 'error',
7
8
  'manifest-skew': 'error',
8
9
  'unforwarded-part': 'warn',
10
+ 'unknown-attribute': 'error',
9
11
  'unknown-tag': 'error',
10
12
  };
11
13
  const SEVERITIES = new Set(['error', 'info', 'off', 'warn']);
package/dist/usage.js CHANGED
@@ -114,32 +114,49 @@ export const styled = (rules, tags, answered, values = true) => {
114
114
  hookValues: {},
115
115
  parts: {},
116
116
  };
117
+ const listing = new Map();
118
+ for (const [tag, element] of tags) {
119
+ for (const property of element.hooks.keys()) {
120
+ listing.set(property, [...(listing.get(property) ?? []), tag]);
121
+ }
122
+ }
123
+ const count = (located, tag, property) => {
124
+ bump(under(counted.hooks, tag, () => ({})), property);
125
+ if (!values) {
126
+ return;
127
+ }
128
+ const value = (located.rule.style?.getPropertyValue?.(property) ?? '').trim().slice(0, 200);
129
+ if (value !== '') {
130
+ bump(under(under(counted.hookValues, tag, () => ({})), property, () => ({})), value);
131
+ }
132
+ };
117
133
  for (const located of rules) {
118
134
  const selector = located.rule.selectorText;
119
- if (!selector?.includes('helix-')) {
135
+ if (!selector) {
120
136
  continue;
121
137
  }
122
- for (const { names, tag } of parts(selector)) {
138
+ for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
123
139
  for (const name of names) {
124
140
  if (tags.get(tag)?.parts.has(name)) {
125
141
  bump(under(counted.parts, tag, () => ({})), name);
126
142
  }
127
143
  }
128
144
  }
129
- for (const tag of targets(selector)) {
130
- for (const property of Array.from(located.rule.style ?? [])) {
131
- if (property.startsWith('--') &&
132
- (tags.get(tag)?.hooks.has(property) || answered.get(tag)?.has(property))) {
133
- bump(under(counted.hooks, tag, () => ({})), property);
134
- if (values) {
135
- const value = (located.rule.style?.getPropertyValue?.(property) ?? '')
136
- .trim()
137
- .slice(0, 200);
138
- if (value !== '') {
139
- bump(under(under(counted.hookValues, tag, () => ({})), property, () => ({})), value);
140
- }
145
+ const named = targets(selector).filter((tag) => tags.has(tag));
146
+ for (const property of Array.from(located.rule.style ?? [])) {
147
+ if (!property.startsWith('--')) {
148
+ continue;
149
+ }
150
+ if (named.length > 0) {
151
+ for (const tag of named) {
152
+ if (tags.get(tag)?.hooks.has(property) || answered.get(tag)?.has(property)) {
153
+ count(located, tag, property);
141
154
  }
142
155
  }
156
+ continue;
157
+ }
158
+ for (const tag of listing.get(property) ?? []) {
159
+ count(located, tag, property);
143
160
  }
144
161
  }
145
162
  }
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.137.0";
9
+ export declare const VERSION = "0.138.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.137.0';
1
+ export const VERSION = '0.138.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fusion.dev/helix-diagnostics",
3
3
  "description": "Finds the Helix mistakes that are valid CSS: dead parts, misnamed hooks, two copies of the library.",
4
- "version": "0.137.0",
4
+ "version": "0.138.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -16,6 +16,10 @@
16
16
  ".": {
17
17
  "types": "./dist/index.d.ts",
18
18
  "default": "./dist/index.js"
19
+ },
20
+ "./ci": {
21
+ "types": "./dist/ci.d.ts",
22
+ "default": "./dist/ci.js"
19
23
  }
20
24
  },
21
25
  "files": [
@@ -29,7 +33,7 @@
29
33
  },
30
34
  "bugs": "https://github.com/project-fusion/fusion/issues",
31
35
  "devDependencies": {
32
- "@fusion.dev/helix-testing": "0.137.0",
36
+ "@fusion.dev/helix-testing": "0.138.0",
33
37
  "@types/bun": "^1.3.0"
34
38
  },
35
39
  "homepage": "https://fusion.dev",