@fusion.dev/helix-diagnostics 0.135.0 → 0.136.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
@@ -9,28 +9,107 @@ npm install --save-dev @fusion.dev/helix-diagnostics
9
9
  ```
10
10
 
11
11
  ```js
12
- import { diagnose, report } from '@fusion.dev/helix-diagnostics'
12
+ import { diagnose, report, watch } from '@fusion.dev/helix-diagnostics'
13
+ import { duplicateRegistrations } from '@fusion.dev/helix-core'
14
+ import styled from '@fusion.dev/helix/custom-elements.json' with { type: 'json' }
13
15
  import headless from '@fusion.dev/helix-headless/custom-elements.json' with { type: 'json' }
14
16
 
15
- report(diagnose({ tiers: [headless] }))
17
+ // Before the components load, so every element that renders is counted.
18
+ const watcher = watch()
19
+
20
+ // Later, once the page has rendered, and again after a route change.
21
+ report(
22
+ diagnose({
23
+ duplicates: duplicateRegistrations,
24
+ tiers: [
25
+ { manifest: styled, name: '@fusion.dev/helix' },
26
+ { manifest: headless, name: '@fusion.dev/helix-headless' },
27
+ ],
28
+ watcher,
29
+ }),
30
+ )
16
31
  ```
17
32
 
18
- `diagnose` needs at least one manifest and says so rather than defaulting: with
19
- none, every part name is unknown and every finding would be noise. `report` is
20
- the console sink; the `Report` it returns is the same object, so you can send it
21
- somewhere else instead.
33
+ `diagnose` needs at least one manifest and says so instead of defaulting: with
34
+ none, every part name is unknown and every finding would be noise. Pass both
35
+ tiers when both are installed, since a part the headless tier publishes and the
36
+ styled tier never forwarded is only visible with both. `report` is the console
37
+ sink; the `Report` it returns is the same object, so you can send it somewhere
38
+ else as well.
22
39
 
23
40
  None of these produces an error anywhere. They are detectable only against the
24
41
  custom elements manifest, which every Helix package already publishes, so the
25
42
  diagnostic cannot drift from what is true.
26
43
 
27
- **Coverage is half the return value, not a footnote.** A cross-origin stylesheet
28
- throws on `.cssRules`, so a run that read three sheets of nineteen returns the
29
- same empty findings array as one that read all nineteen. Read `coverage` before
30
- you believe `findings`.
44
+ ## What a report holds
45
+
46
+ - **`coverage`**: how many stylesheets and shadow roots were read, and which
47
+ sheets could not be. A cross-origin stylesheet throws on `.cssRules`, so a run
48
+ that read three sheets of nineteen returns the same empty findings as one that
49
+ 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.
53
+ - **`usage`**: the Helix elements on the page by tag, the axes set on them
54
+ (`emphasis`, `material`, `separation`, and `tone`), the named slots filled,
55
+ 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`.
61
+
62
+ ## What the console shows
63
+
64
+ `report` writes one group per run, headed by the counts. It opens when there
65
+ is something to act on, and collapses to one line when the run is clean. Each
66
+ kind of entry goes to its own console method, so the browser's level filter
67
+ works on it:
68
+
69
+ | Entry | Console method |
70
+ | --- | --- |
71
+ | A finding at `error` | `console.error` |
72
+ | A finding at `warn` | `console.warn` |
73
+ | A finding at `info` | `console.info` |
74
+ | Stylesheets that could not be read | `console.warn` |
75
+ | Coverage, and findings turned off | `console.info` |
76
+ | Usage | `console.debug`, hidden at the default level |
77
+
78
+ ## Finding kinds
79
+
80
+ | Kind | Severity | Means |
81
+ | --- | --- | --- |
82
+ | `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 |
84
+ | `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
85
+ | `unknown-tag` | error | A rule names a tag no manifest lists |
86
+ | `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 |
88
+
89
+ ## Change a severity
90
+
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:
95
+
96
+ ```js
97
+ import overrides from './helix-diagnostics.json' with { type: 'json' }
98
+
99
+ diagnose({
100
+ file: overrides,
101
+ rules: {
102
+ 'dead-hook': { severity: 'warn', tags: ['helix-card'] },
103
+ 'unforwarded-part': 'off',
104
+ },
105
+ tiers,
106
+ })
107
+ ```
108
+
109
+ An override for a kind that does not exist throws, and names the closest kind.
31
110
 
32
- Development only. It walks stylesheets in the page and makes no network calls,
33
- ever.
111
+ Development only. It walks every stylesheet in the page, and it sends nothing
112
+ anywhere.
34
113
 
35
114
  [Documentation](https://fusion.dev/helix)
36
115
 
package/dist/analyze.d.ts CHANGED
@@ -18,7 +18,14 @@
18
18
  */
19
19
  import type { Answered, Located } from './collect.js';
20
20
  import { type Published } from './manifest.js';
21
- import type { Finding } from './report.js';
21
+ import type { Raw } from './report.js';
22
+ /** Every `helix-*` tag a selector mentions, in source order. */
23
+ export declare const targets: (selector: string) => string[];
24
+ /** Every `::part(a b)` in a selector, with the tag it hangs off. */
25
+ export declare const parts: (selector: string) => Array<{
26
+ names: string[];
27
+ tag: string;
28
+ }>;
22
29
  /**
23
30
  * The findings in one page's rules.
24
31
  *
@@ -26,7 +33,7 @@ import type { Finding } from './report.js';
26
33
  * unforwarded part is phrased: the part exists, another tier publishes it, and
27
34
  * the one being styled did not forward it.
28
35
  */
29
- export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) => Finding[];
36
+ export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) => Raw[];
30
37
  /**
31
38
  * Two copies of Helix on one page, which is not a CSS fault and is the most
32
39
  * expensive one on the list.
@@ -44,4 +51,20 @@ export declare const analyze: (rules: Located[], tags: Map<string, Published>, f
44
51
  * version old enough to predate the warning. That case reports what it can see,
45
52
  * which is that a live element is not an instance of its own registered class.
46
53
  */
47
- export declare const registrations: (from?: Document, known?: () => string[]) => Finding[];
54
+ export declare const registrations: (from?: Document, known?: () => string[]) => Raw[];
55
+ /**
56
+ * Evidence that the manifest is from another version of Helix than the page
57
+ * runs: an element rendering a part its manifest entry does not list.
58
+ *
59
+ * **Read from what the elements render, because nothing records a version.**
60
+ * Neither the manifest nor the running code carries one. What a matching
61
+ * manifest guarantees is that every part an element renders is listed, since
62
+ * both are built from the same source, so a rendered part the manifest lacks
63
+ * means the two were built from different source. The reverse proves nothing:
64
+ * an element renders some parts only in some states.
65
+ *
66
+ * **One finding replaces all the others** (see `diagnose`). A part renamed
67
+ * between versions reads as dead in every rule that styles it, and a hundred
68
+ * findings that are each the same mistake are worse than one that names it.
69
+ */
70
+ export declare const skew: (tags: Map<string, Published>, from: Document) => Raw[];
package/dist/analyze.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { nearest } from './manifest.js';
2
- const targets = (selector) => {
2
+ export const targets = (selector) => {
3
3
  const found = new Set();
4
4
  for (const match of selector.matchAll(/\bhelix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
5
5
  found.add(match[0]);
6
6
  }
7
7
  return [...found];
8
8
  };
9
- const parts = (selector) => {
9
+ export const parts = (selector) => {
10
10
  const found = [];
11
11
  for (const match of selector.matchAll(/(helix-[a-z0-9-]+)((?:[^\s>+~,]*))::part\(\s*([^)]*?)\s*\)/g)) {
12
12
  const names = (match[3] ?? '').split(/\s+/).filter(Boolean);
@@ -55,6 +55,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
55
55
  message: `\`${tag}\` is in no manifest passed to the collector. Either the tag is misspelled, or the manifest is from a different version of Helix than the page.`,
56
56
  origin: origin(located),
57
57
  subject: tag,
58
+ tag,
58
59
  });
59
60
  continue;
60
61
  }
@@ -71,6 +72,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
71
72
  message: `\`${tag}\` does not publish \`${part}\`, though \`${elsewhere[0]}\` does. The rule matches nothing until the part is forwarded, which is a gap in Helix rather than in this CSS.`,
72
73
  origin: origin(located),
73
74
  subject: `${tag}::part(${part})`,
75
+ tag,
74
76
  });
75
77
  continue;
76
78
  }
@@ -83,6 +85,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
83
85
  : `\`${tag}\` publishes no part named \`${part}\`, so this rule matches nothing. Published: ${[...element.parts.keys()].sort().join(', ') || 'none'}.`,
84
86
  origin: origin(located),
85
87
  subject: `${tag}::part(${part})`,
88
+ tag,
86
89
  });
87
90
  }
88
91
  }
@@ -131,6 +134,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
131
134
  : `\`${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.`,
132
135
  origin: origin(located),
133
136
  subject: `${selector} { ${property} }`,
137
+ tag,
134
138
  });
135
139
  }
136
140
  }
@@ -149,6 +153,7 @@ export const registrations = (from = document, known = () => []) => {
149
153
  kind: 'duplicate-registration',
150
154
  message: `\`${tag}\` is registered twice by different classes, so this page holds two copies of Helix (${how}). The first registration keeps the tag: every element of that name is built from it whatever the second copy exports, which makes \`instanceof\` against the second copy false and leaves its fixes out of the DOM. Deduplicate the dependency.`,
151
155
  subject: tag,
156
+ tag,
152
157
  });
153
158
  };
154
159
  for (const tag of known()) {
@@ -169,3 +174,36 @@ export const registrations = (from = document, known = () => []) => {
169
174
  }
170
175
  return findings;
171
176
  };
177
+ export const skew = (tags, from) => {
178
+ const findings = [];
179
+ const said = new Set();
180
+ const visit = (root, depth) => {
181
+ if (depth > 32) {
182
+ return;
183
+ }
184
+ for (const element of root.querySelectorAll('*')) {
185
+ const shadow = element.shadowRoot;
186
+ if (!shadow) {
187
+ continue;
188
+ }
189
+ const tag = element.localName;
190
+ const published = tags.get(tag);
191
+ if (published && !said.has(tag)) {
192
+ const rendered = new Set([...shadow.querySelectorAll('[part]')].flatMap((node) => (node.getAttribute('part') ?? '').split(/\s+/).filter(Boolean)));
193
+ const missing = [...rendered].filter((name) => !published.parts.has(name)).sort();
194
+ if (missing.length > 0) {
195
+ said.add(tag);
196
+ findings.push({
197
+ kind: 'manifest-skew',
198
+ message: `\`${tag}\` renders ${missing.map((name) => `\`${name}\``).join(', ')}, which the manifest passed in does not list, so the manifest is from a different version of Helix than the page runs. Every other finding would be judged against the wrong version, so none are reported. Pass the \`custom-elements.json\` from the installed packages.`,
199
+ subject: tag,
200
+ tag,
201
+ });
202
+ }
203
+ }
204
+ visit(shadow, depth + 1);
205
+ }
206
+ };
207
+ visit(from, 0);
208
+ return findings;
209
+ };
package/dist/console.d.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  /**
2
- * The default sink: the console, and coverage before anything else.
2
+ * The default sink: the console, at the level each entry deserves.
3
+ *
4
+ * **Each kind of entry has its own console level**, so the browser's level
5
+ * filter and a test runner's capture treat each as what it is:
6
+ *
7
+ * | Entry | Level | Why |
8
+ * | --- | --- | --- |
9
+ * | A finding at `error` | `console.error` | The CSS does nothing where it was written |
10
+ * | A finding at `warn` | `console.warn` | Worth fixing, and the page works |
11
+ * | A finding at `info` | `console.info` | Advice |
12
+ * | Unreadable stylesheets | `console.warn` | The run could not see part of the page |
13
+ * | Coverage, overrides, a clean run | `console.info` | What the run looked at |
14
+ * | Usage | `console.debug` | For whoever studies usage, hidden at the default level |
15
+ *
16
+ * **All of it in one group**, headed by the counts, so a page with other logs
17
+ * keeps a run together and a clean run collapses to one line.
3
18
  *
4
19
  * **Coverage is printed on a clean run too.** "No findings" from a collector
5
20
  * that read three sheets of nineteen is not a pass, and the only way to stop
@@ -7,9 +22,10 @@
7
22
  * single time.
8
23
  *
9
24
  * A sink and not a policy. It writes and returns; a CI sink writing JSON and an
10
- * OTLP exporter are the same `Report` consumed differently, which is what keeps
11
- * all three agreeing by construction rather than by our diligence.
25
+ * OTLP exporter are the same `Report` consumed differently.
12
26
  */
13
27
  import type { Report } from './report.js';
28
+ /** The console methods this sink calls. A test hands in its own. */
29
+ export type ConsoleSink = Pick<Console, 'debug' | 'error' | 'group' | 'groupCollapsed' | 'groupEnd' | 'info' | 'warn'>;
14
30
  /** Writes a report and hands it back, so a caller can chain a second sink. */
15
- export declare const report: (result: Report, to?: Console) => Report;
31
+ export declare const report: (result: Report, to?: ConsoleSink) => Report;
package/dist/console.js CHANGED
@@ -1,30 +1,47 @@
1
- const line = (finding) => {
2
- const out = [` ${finding.kind}: ${finding.subject}`, ` ${finding.message}`];
1
+ const entry = (finding) => {
2
+ const by = finding.decidedBy === 'shipped' ? '' : ` (${finding.severity}, set by the ${finding.decidedBy})`;
3
+ const lines = [`${finding.kind}${by}: ${finding.subject}`, finding.message];
3
4
  if (finding.origin) {
4
- out.push(` in ${finding.origin.sheet}, rule ${finding.origin.index}`);
5
- out.push(` ${finding.origin.cssText}`);
5
+ lines.push(`in ${finding.origin.sheet}, rule ${finding.origin.index}`, finding.origin.cssText);
6
6
  }
7
- return out;
7
+ return lines.join('\n');
8
8
  };
9
+ const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
9
10
  export const report = (result, to = console) => {
10
11
  const { coverage, findings } = result;
12
+ const bySeverity = (severity) => findings.filter((finding) => finding.severity === severity).length;
13
+ const errors = bySeverity('error');
14
+ const warnings = bySeverity('warn');
11
15
  const missed = coverage.sheets - coverage.read;
12
- to.log(`helix diagnostics: read ${coverage.read} of ${coverage.sheets} stylesheets across ${coverage.roots} shadow roots` +
13
- (missed > 0 ? `, ${missed} unreadable` : ''));
14
- for (const sheet of coverage.unreadable) {
15
- to.log(` unreadable (${sheet.reason}): ${sheet.sheet}`);
16
+ const heading = findings.length === 0
17
+ ? 'helix diagnostics: no findings'
18
+ : `helix diagnostics: ${counted(errors, 'error')}, ${counted(warnings, 'warning')}, ${bySeverity('info')} info`;
19
+ if (errors + warnings > 0 || missed > 0) {
20
+ to.group(heading);
16
21
  }
17
- if (findings.length === 0) {
18
- to.log(missed > 0
19
- ? ' no findings in what could be read. The unreadable sheets above were not checked.'
20
- : ' no findings.');
21
- return result;
22
+ else {
23
+ to.groupCollapsed(heading);
24
+ }
25
+ to.info(`read ${coverage.read} of ${counted(coverage.sheets, 'stylesheet')} across ${counted(coverage.roots, 'shadow root')}`);
26
+ if (missed > 0) {
27
+ to.warn([
28
+ `${counted(missed, 'stylesheet')} could not be read, so ${missed === 1 ? 'it was' : 'they were'} not checked:`,
29
+ ...coverage.unreadable.map((sheet) => `${sheet.sheet} (${sheet.reason})`),
30
+ ].join('\n'));
31
+ }
32
+ const { argument, file } = result.silenced;
33
+ if (argument + file > 0) {
34
+ to.info(`${counted(argument + file, 'finding')} turned off: ${file} by the file, ${argument} by the argument`);
22
35
  }
23
- to.log(` ${findings.length} finding${findings.length === 1 ? '' : 's'}:`);
24
36
  for (const finding of findings) {
25
- for (const text of line(finding)) {
26
- to.log(text);
27
- }
37
+ to[finding.severity](entry(finding));
28
38
  }
39
+ const { counted: how, hooks, parts, tags, unlisted } = result.usage;
40
+ const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
41
+ const elements = Object.values(tags).reduce((total, one) => total + one, 0);
42
+ const others = Object.keys(unlisted).length;
43
+ to.debug(`usage (${how}): ${counted(Object.keys(tags).length, 'tag')} in ${counted(elements, 'element')}, ${counted(sum(parts), 'part')} styled, ${counted(sum(hooks), 'hook')} set` +
44
+ (others > 0 ? `, and ${counted(others, 'helix- tag')} no manifest lists` : ''));
45
+ to.groupEnd();
29
46
  return result;
30
47
  };
package/dist/index.d.ts CHANGED
@@ -20,10 +20,9 @@
20
20
  * report(diagnose({ tiers: [{ manifest: styled, name: '@fusion.dev/helix' }] }))
21
21
  * ```
22
22
  *
23
- * **Zero network calls, at this layer, ever.** The privacy policy this project
24
- * published says the site sends nothing, and a diagnostics package that phones
25
- * home from a consumer's page would make us the third party we told our own
26
- * readers we were not. Findings go where the caller puts them.
23
+ * **Nothing here sends anything.** `diagnose` returns a report and `report`
24
+ * prints it. Where else a report goes is a sink's decision, and every sink
25
+ * that leaves the page is one the caller adds.
27
26
  *
28
27
  * **Development only.** Import it from a dev-only entry: it walks every
29
28
  * stylesheet in the page and has no business in a production bundle.
@@ -32,9 +31,14 @@ export { collect } from './collect.js';
32
31
  export { report } from './console.js';
33
32
  export type { ManifestSource, Published, Tier } from './manifest.js';
34
33
  export { index, nearest } from './manifest.js';
35
- export type { Coverage, Finding, Origin, Report, Unreadable } from './report.js';
34
+ export type { Coverage, Finding, Kind, Layer, Origin, Report, Severity, Unreadable, Usage, } from './report.js';
35
+ export { KINDS } from './report.js';
36
+ export { type Override, type Rules, SHIPPED_RULES } from './rules.js';
37
+ export { type Watcher, watch } from './usage.js';
36
38
  import type { Tier } from './manifest.js';
37
39
  import type { Report } from './report.js';
40
+ import { type Rules } from './rules.js';
41
+ import { type Watcher } from './usage.js';
38
42
  /** What to look at, and what to compare it against. */
39
43
  export interface Options {
40
44
  /**
@@ -48,6 +52,11 @@ export interface Options {
48
52
  * from outside and cannot name the losing class.
49
53
  */
50
54
  duplicates?: () => string[];
55
+ /**
56
+ * The project's own overrides, parsed from its file. The build reads the
57
+ * file, since the page cannot: import the JSON and pass it here.
58
+ */
59
+ file?: Rules | undefined;
51
60
  /**
52
61
  * The document to scan. Defaults to the ambient one.
53
62
  *
@@ -55,6 +64,15 @@ export interface Options {
55
64
  * an iframe it owns can scan that too.
56
65
  */
57
66
  from?: Document;
67
+ /**
68
+ * Overrides for this run, which win over `file`. Each says a severity,
69
+ * `off`, or either for some tags only:
70
+ *
71
+ * ```ts
72
+ * diagnose({ tiers, rules: { 'unforwarded-part': 'off', 'dead-hook': { severity: 'warn', tags: ['helix-card'] } } })
73
+ * ```
74
+ */
75
+ rules?: Rules | undefined;
58
76
  /**
59
77
  * The manifests to check against, one per installed tier.
60
78
  *
@@ -63,6 +81,11 @@ export interface Options {
63
81
  * invisible to either manifest alone.
64
82
  */
65
83
  tiers: Tier[];
84
+ /**
85
+ * A watcher from `watch()`, so usage counts every element that rendered
86
+ * since it began. Without one, usage counts the elements in the page now.
87
+ */
88
+ watcher?: Watcher | undefined;
66
89
  }
67
90
  /**
68
91
  * One pass over the page.
@@ -75,5 +98,8 @@ export interface Options {
75
98
  *
76
99
  * Cheap enough to call on a route change. There is no observer and no polling
77
100
  * loop: sheets that arrive later are found by calling it again.
101
+ *
102
+ * **A manifest from another version is refused**, with one finding that says
103
+ * so in place of every other: see `skew`.
78
104
  */
79
- export declare const diagnose: ({ duplicates, from, tiers }: Options) => Report;
105
+ export declare const diagnose: ({ duplicates, file, from, rules, tiers, watcher }: Options) => Report;
package/dist/index.js CHANGED
@@ -1,21 +1,38 @@
1
1
  export { collect } from './collect.js';
2
2
  export { report } from './console.js';
3
3
  export { index, nearest } from './manifest.js';
4
- import { analyze, registrations } from './analyze.js';
4
+ export { KINDS } from './report.js';
5
+ export { SHIPPED_RULES } from './rules.js';
6
+ export { watch } from './usage.js';
7
+ import { analyze, registrations, skew } from './analyze.js';
5
8
  import { collect } from './collect.js';
6
9
  import { index } from './manifest.js';
7
- export const diagnose = ({ duplicates, from, tiers }) => {
10
+ import { applyRules, checkRules } from './rules.js';
11
+ import { listed, styled, walk } from './usage.js';
12
+ export const diagnose = ({ duplicates, file, from, rules, tiers, watcher }) => {
8
13
  if (tiers.length === 0) {
9
14
  throw new Error('diagnose() needs at least one manifest. Without one every part name is unknown and every finding would be noise.');
10
15
  }
16
+ checkRules(file, 'file');
17
+ checkRules(rules, 'argument');
11
18
  const document_ = from ?? globalThis.document;
12
- const { answered, coverage, rules } = collect(document_);
19
+ const { answered, coverage, rules: located } = collect(document_);
13
20
  const tags = index(tiers);
21
+ const skewed = applyRules(skew(tags, document_), file, rules);
22
+ const judged = skewed.findings.length > 0
23
+ ? { findings: [], silenced: { argument: 0, file: 0 } }
24
+ : applyRules([...analyze(located, tags, document_, answered), ...registrations(document_, duplicates)], file, rules);
14
25
  return {
15
26
  coverage,
16
- findings: [
17
- ...analyze(rules, tags, document_, answered),
18
- ...registrations(document_, duplicates),
19
- ],
27
+ findings: [...skewed.findings, ...judged.findings],
28
+ silenced: {
29
+ argument: skewed.silenced.argument + judged.silenced.argument,
30
+ file: skewed.silenced.file + judged.silenced.file,
31
+ },
32
+ usage: {
33
+ ...listed(watcher ? watcher.counts() : walk(document_), tags),
34
+ ...styled(located, tags, answered),
35
+ counted: watcher ? 'watched' : 'walked',
36
+ },
20
37
  };
21
38
  };
package/dist/report.d.ts CHANGED
@@ -49,14 +49,34 @@ export interface Finding {
49
49
  */
50
50
  expected?: string | undefined;
51
51
  /** Machine-readable fault class, stable across versions. */
52
- kind: 'dead-hook' | 'dead-part' | 'duplicate-registration' | 'unforwarded-part' | 'unknown-tag';
52
+ kind: Kind;
53
53
  /** One sentence, addressed to whoever wrote the CSS. */
54
54
  message: string;
55
55
  /** Where it was read. Absent only for faults that are not in a stylesheet. */
56
56
  origin?: Origin;
57
+ /** How serious it is, after the shipped rules and any overrides. */
58
+ severity: Severity;
59
+ /**
60
+ * Which layer set `severity`: the rules this package ships, the project's
61
+ * file, or the argument to `diagnose`. With two layers able to override,
62
+ * a severity alone cannot say where it came from.
63
+ */
64
+ decidedBy: Layer;
57
65
  /** The selector the fault sits in, or the tag for a runtime fault. */
58
66
  subject: string;
67
+ /** The Helix tag the finding is about, which a scoped override matches on. */
68
+ tag: string;
59
69
  }
70
+ /** Every fault class, stable across versions. */
71
+ export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'manifest-skew', 'unforwarded-part', 'unknown-tag'];
72
+ /** A fault class. */
73
+ export type Kind = (typeof KINDS)[number];
74
+ /** How serious a finding is. */
75
+ export type Severity = 'error' | 'info' | 'warn';
76
+ /** Where a finding's severity was decided. */
77
+ export type Layer = 'argument' | 'file' | 'shipped';
78
+ /** A finding before the rules have given it a severity. */
79
+ export type Raw = Omit<Finding, 'decidedBy' | 'severity'>;
60
80
  /** A sheet the collector could not read, and the reason. */
61
81
  export interface Unreadable {
62
82
  reason: 'cross-origin' | 'detached' | 'threw';
@@ -81,8 +101,58 @@ export interface Coverage {
81
101
  /** Every sheet that could not be read, with its reason. */
82
102
  unreadable: Unreadable[];
83
103
  }
104
+ /**
105
+ * What a page uses of Helix, counted by tag.
106
+ *
107
+ * Plain objects of counts, so a sink can write the report as JSON and read it
108
+ * back unchanged.
109
+ */
110
+ export interface Usage {
111
+ /**
112
+ * Each axis set, by tag, axis, and value: `emphasis`, `material`,
113
+ * `separation`, and `tone`. An axis left unset is not counted.
114
+ */
115
+ axes: Record<string, Record<string, Record<string, number>>>;
116
+ /**
117
+ * The Helix elements other Helix elements render inside their own shadow
118
+ * roots, such as the select a pagination size draws. Kept apart from
119
+ * `tags`, which counts what the page put there.
120
+ */
121
+ composed: Record<string, number>;
122
+ /**
123
+ * How the elements were counted. `watched`: every element that rendered
124
+ * since `watch()` began, including the ones that have gone, such as a
125
+ * closed dialog. `walked`: the elements in the page at the moment of the
126
+ * run, which misses those.
127
+ */
128
+ counted: 'walked' | 'watched';
129
+ /** Each hook the page's stylesheets set, by tag, counted per declaration. */
130
+ hooks: Record<string, Record<string, number>>;
131
+ /** Each part the page's stylesheets style, by tag, counted per rule. */
132
+ parts: Record<string, Record<string, number>>;
133
+ /** Each named slot filled, by tag. */
134
+ slots: Record<string, Record<string, number>>;
135
+ /** Each Helix element the page put there, by tag. */
136
+ tags: Record<string, number>;
137
+ /**
138
+ * Elements named `helix-*` that no manifest passed in lists, by tag: a
139
+ * page's own element under the prefix, or a family whose manifest was not
140
+ * passed. Kept out of the other counts, which are Helix's.
141
+ */
142
+ unlisted: Record<string, number>;
143
+ }
84
144
  /** The whole result. Coverage and findings, never one without the other. */
85
145
  export interface Report {
86
146
  coverage: Coverage;
87
147
  findings: Finding[];
148
+ /**
149
+ * How many findings each override layer turned off. A finding turned off
150
+ * is still a fault, and a report that hid the count would read as cleaner
151
+ * than the page is.
152
+ */
153
+ silenced: {
154
+ argument: number;
155
+ file: number;
156
+ };
157
+ usage: Usage;
88
158
  }
package/dist/report.js CHANGED
@@ -1 +1,8 @@
1
- export {};
1
+ export const KINDS = [
2
+ 'dead-hook',
3
+ 'dead-part',
4
+ 'duplicate-registration',
5
+ 'manifest-skew',
6
+ 'unforwarded-part',
7
+ 'unknown-tag',
8
+ ];
@@ -0,0 +1,34 @@
1
+ import { type Finding, type Kind, type Layer, type Raw, type Severity } from './report.js';
2
+ /** One override: a severity, `off`, or either for some tags only. */
3
+ export type Override = Severity | 'off' | {
4
+ /** The severity, or `off`. */
5
+ severity: Severity | 'off';
6
+ /** The tags it applies to. Every other tag keeps the layer below. */
7
+ tags: string[];
8
+ };
9
+ /** Overrides by kind. A list holds several, each for different tags. */
10
+ export type Rules = Partial<Record<Kind, Override | Override[]>>;
11
+ /**
12
+ * The shipped severities.
13
+ *
14
+ * `unforwarded-part` is a warning because the fault is in Helix: the part
15
+ * exists on the other tier, and the styled tier never forwarded it.
16
+ * Everything else means the page's CSS does nothing where it was written, or
17
+ * that the manifest cannot be trusted, and is an error.
18
+ */
19
+ export declare const SHIPPED_RULES: Readonly<Record<Kind, Severity>>;
20
+ /** Throws on an override that would silently do nothing. */
21
+ export declare const checkRules: (rules: Rules | undefined, layer: Exclude<Layer, 'shipped'>) => void;
22
+ /**
23
+ * The findings with their severities, and how many each layer turned off.
24
+ *
25
+ * The shipped rule first, then the file, then the argument, each later layer
26
+ * replacing what it speaks to.
27
+ */
28
+ export declare const applyRules: (findings: Raw[], file: Rules | undefined, argument: Rules | undefined) => {
29
+ findings: Finding[];
30
+ silenced: {
31
+ argument: number;
32
+ file: number;
33
+ };
34
+ };
package/dist/rules.js ADDED
@@ -0,0 +1,66 @@
1
+ import { nearest } from './manifest.js';
2
+ import { KINDS } from './report.js';
3
+ export const SHIPPED_RULES = {
4
+ 'dead-hook': 'error',
5
+ 'dead-part': 'error',
6
+ 'duplicate-registration': 'error',
7
+ 'manifest-skew': 'error',
8
+ 'unforwarded-part': 'warn',
9
+ 'unknown-tag': 'error',
10
+ };
11
+ const SEVERITIES = new Set(['error', 'info', 'off', 'warn']);
12
+ export const checkRules = (rules, layer) => {
13
+ for (const [kind, value] of Object.entries(rules ?? {})) {
14
+ if (!KINDS.includes(kind)) {
15
+ const close = nearest(kind, KINDS);
16
+ throw new Error(`The ${layer} overrides \`${kind}\`, which is no kind of finding.` +
17
+ (close ? ` The closest is \`${close}\`.` : ` The kinds are ${KINDS.join(', ')}.`));
18
+ }
19
+ for (const one of Array.isArray(value) ? value : [value]) {
20
+ const severity = typeof one === 'string' ? one : one?.severity;
21
+ if (!SEVERITIES.has(severity)) {
22
+ throw new Error(`The ${layer} sets \`${kind}\` to \`${String(severity)}\`. A severity is error, warn, info, or off.`);
23
+ }
24
+ if (typeof one === 'object' && !(Array.isArray(one.tags) && one.tags.length > 0)) {
25
+ throw new Error(`The ${layer} scopes \`${kind}\` with no tags. Give the tags it applies to, or set the severity alone.`);
26
+ }
27
+ }
28
+ }
29
+ };
30
+ const said = (rules, finding) => {
31
+ const value = rules?.[finding.kind];
32
+ if (value === undefined) {
33
+ return undefined;
34
+ }
35
+ const list = Array.isArray(value) ? value : [value];
36
+ const scoped = list.find((one) => typeof one === 'object' && one.tags.includes(finding.tag));
37
+ if (scoped && typeof scoped === 'object') {
38
+ return scoped.severity;
39
+ }
40
+ const plain = list.find((one) => typeof one === 'string');
41
+ return typeof plain === 'string' ? plain : undefined;
42
+ };
43
+ export const applyRules = (findings, file, argument) => {
44
+ const silenced = { argument: 0, file: 0 };
45
+ const kept = [];
46
+ for (const finding of findings) {
47
+ let severity = SHIPPED_RULES[finding.kind];
48
+ let decidedBy = 'shipped';
49
+ for (const [layer, rules] of [
50
+ ['file', file],
51
+ ['argument', argument],
52
+ ]) {
53
+ const value = said(rules, finding);
54
+ if (value !== undefined) {
55
+ severity = value;
56
+ decidedBy = layer;
57
+ }
58
+ }
59
+ if (severity === 'off') {
60
+ silenced[decidedBy] += 1;
61
+ continue;
62
+ }
63
+ kept.push({ ...finding, decidedBy, severity });
64
+ }
65
+ return { findings: kept, silenced };
66
+ };
@@ -0,0 +1,44 @@
1
+ import type { Answered, Located } from './collect.js';
2
+ import type { Published } from './manifest.js';
3
+ import type { Usage } from './report.js';
4
+ /** The element counts, before the stylesheets add theirs. */
5
+ type Counted = Pick<Usage, 'axes' | 'composed' | 'slots' | 'tags'>;
6
+ /** The elements in the page now, counted once each. */
7
+ export declare const walk: (from: Document) => Counted;
8
+ /**
9
+ * The counts for the tags a manifest lists, and the rest apart as `unlisted`.
10
+ *
11
+ * Counted by prefix, since `watch()` runs before any manifest is at hand, and
12
+ * sorted here, where one is.
13
+ */
14
+ export declare const listed: (counted: Counted, tags: Map<string, Published>) => Counted & Pick<Usage, 'unlisted'>;
15
+ /** A running count of what renders, from `watch()`. */
16
+ export interface Watcher {
17
+ /** The counts so far. A copy: counting goes on after it is read. */
18
+ counts(): Pick<Usage, 'axes' | 'composed' | 'slots' | 'tags'>;
19
+ /** Stops counting, and gives the slot back to whatever held it before. */
20
+ stop(): void;
21
+ }
22
+ /**
23
+ * Starts counting the Helix elements that render, from now on, and the ones
24
+ * already in the page.
25
+ *
26
+ * Hand the watcher to `diagnose`, and its report counts every element seen
27
+ * since, including the ones that have gone. Without one, `diagnose` counts the
28
+ * elements in the page at the moment it runs, and says so.
29
+ *
30
+ * **Start it early**, before the components load where you can, so the
31
+ * elements an early route renders and removes are counted. The ones in the
32
+ * page when it starts are counted by walking the page once.
33
+ *
34
+ * Two watchers on one page both count: a second one keeps the first's
35
+ * listener and calls it too.
36
+ */
37
+ export declare const watch: (from?: Document) => Watcher;
38
+ /**
39
+ * The parts and hooks a page's stylesheets reach for, where the manifest or
40
+ * the element's own styles say they exist. A dead one is a finding and is not
41
+ * counted as use.
42
+ */
43
+ export declare const styled: (rules: Located[], tags: Map<string, Published>, answered: Answered) => Pick<Usage, 'hooks' | 'parts'>;
44
+ export {};
package/dist/usage.js ADDED
@@ -0,0 +1,131 @@
1
+ import { parts, targets } from './analyze.js';
2
+ const SLOT = Symbol.for('fusion.dev/helix/usage');
3
+ const AXES = ['emphasis', 'material', 'separation', 'tone'];
4
+ const bump = (into, key, by = 1) => {
5
+ into[key] = (into[key] ?? 0) + by;
6
+ };
7
+ const under = (into, key, make) => {
8
+ let found = into[key];
9
+ if (found === undefined) {
10
+ found = make();
11
+ into[key] = found;
12
+ }
13
+ return found;
14
+ };
15
+ const empty = () => ({ axes: {}, composed: {}, slots: {}, tags: {} });
16
+ const tally = (counted, element) => {
17
+ const tag = element.localName;
18
+ if (!tag.startsWith('helix-')) {
19
+ return;
20
+ }
21
+ const root = element.getRootNode();
22
+ const host = root instanceof ShadowRoot ? root.host.localName : '';
23
+ if (host.startsWith('helix-')) {
24
+ bump(counted.composed, tag);
25
+ return;
26
+ }
27
+ bump(counted.tags, tag);
28
+ for (const axis of AXES) {
29
+ const value = element.getAttribute(axis);
30
+ if (value !== null && value !== '') {
31
+ bump(under(under(counted.axes, tag, () => ({})), axis, () => ({})), value);
32
+ }
33
+ }
34
+ for (const child of element.children) {
35
+ const name = child.getAttribute('slot');
36
+ if (name) {
37
+ bump(under(counted.slots, tag, () => ({})), name);
38
+ }
39
+ }
40
+ };
41
+ const everyElement = (from, visit, depth = 0) => {
42
+ if (depth > 32) {
43
+ return;
44
+ }
45
+ for (const element of from.querySelectorAll('*')) {
46
+ visit(element);
47
+ if (element.shadowRoot) {
48
+ everyElement(element.shadowRoot, visit, depth + 1);
49
+ }
50
+ }
51
+ };
52
+ export const walk = (from) => {
53
+ const counted = empty();
54
+ everyElement(from, (element) => tally(counted, element));
55
+ return counted;
56
+ };
57
+ export const listed = (counted, tags) => {
58
+ const keep = (counts) => Object.fromEntries(Object.entries(counts).filter(([tag]) => tags.has(tag)));
59
+ const unlisted = {};
60
+ for (const counts of [counted.tags, counted.composed]) {
61
+ for (const [tag, count] of Object.entries(counts)) {
62
+ if (!tags.has(tag)) {
63
+ bump(unlisted, tag, count);
64
+ }
65
+ }
66
+ }
67
+ return {
68
+ axes: keep(counted.axes),
69
+ composed: keep(counted.composed),
70
+ slots: keep(counted.slots),
71
+ tags: keep(counted.tags),
72
+ unlisted,
73
+ };
74
+ };
75
+ export const watch = (from = document) => {
76
+ const counted = empty();
77
+ const seen = new WeakSet();
78
+ const see = (element) => {
79
+ if (!seen.has(element)) {
80
+ seen.add(element);
81
+ tally(counted, element);
82
+ }
83
+ };
84
+ const store = globalThis;
85
+ const before = store[SLOT];
86
+ let live = true;
87
+ const listener = (element) => {
88
+ if (live) {
89
+ see(element);
90
+ }
91
+ if (typeof before === 'function') {
92
+ before(element);
93
+ }
94
+ };
95
+ store[SLOT] = listener;
96
+ everyElement(from, see);
97
+ return {
98
+ counts: () => structuredClone(counted),
99
+ stop: () => {
100
+ live = false;
101
+ if (store[SLOT] === listener) {
102
+ store[SLOT] = before;
103
+ }
104
+ },
105
+ };
106
+ };
107
+ export const styled = (rules, tags, answered) => {
108
+ const counted = { hooks: {}, parts: {} };
109
+ for (const located of rules) {
110
+ const selector = located.rule.selectorText;
111
+ if (!selector?.includes('helix-')) {
112
+ continue;
113
+ }
114
+ for (const { names, tag } of parts(selector)) {
115
+ for (const name of names) {
116
+ if (tags.get(tag)?.parts.has(name)) {
117
+ bump(under(counted.parts, tag, () => ({})), name);
118
+ }
119
+ }
120
+ }
121
+ for (const tag of targets(selector)) {
122
+ for (const property of Array.from(located.rule.style ?? [])) {
123
+ if (property.startsWith('--') &&
124
+ (tags.get(tag)?.hooks.has(property) || answered.get(tag)?.has(property))) {
125
+ bump(under(counted.hooks, tag, () => ({})), property);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ return counted;
131
+ };
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.135.0",
4
+ "version": "0.136.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "bugs": "https://github.com/project-fusion/fusion/issues",
31
31
  "devDependencies": {
32
- "@fusion.dev/helix-testing": "0.135.0",
32
+ "@fusion.dev/helix-testing": "0.136.0",
33
33
  "@types/bun": "^1.3.0"
34
34
  },
35
35
  "homepage": "https://fusion.dev",