@fusion.dev/helix-diagnostics 0.135.0 → 0.137.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,154 @@ 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
+ 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.
55
+ - **`coverage`**: how many stylesheets and shadow roots were read, and which
56
+ sheets could not be. A cross-origin stylesheet throws on `.cssRules`, so a run
57
+ that read three sheets of nineteen returns the same empty findings as one that
58
+ read all nineteen. Read `coverage` before you believe `findings`.
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.
64
+ - **`usage`**: the Helix elements on the page by tag, the axes set on them
65
+ (`emphasis`, `material`, `separation`, and `tone`), the named slots filled,
66
+ the elements Helix renders inside other Helix elements, and the parts and
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.
76
+
77
+ ## What the console shows
78
+
79
+ `report` writes one group per run, headed by the counts. It opens when there
80
+ is something to act on, and collapses to one line when the run is clean. Each
81
+ kind of entry goes to its own console method, so the browser's level filter
82
+ works on it:
83
+
84
+ | Entry | Console method |
85
+ | --- | --- |
86
+ | A finding at `error` | `console.error` |
87
+ | A finding at `warn` | `console.warn` |
88
+ | A finding at `info` | `console.info` |
89
+ | Stylesheets that could not be read | `console.warn` |
90
+ | Coverage, and findings turned off | `console.info` |
91
+ | Usage | `console.debug`, hidden at the default level |
92
+
93
+ ## Finding kinds
94
+
95
+ | Kind | Severity | Means |
96
+ | --- | --- | --- |
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 |
99
+ | `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
100
+ | `unknown-tag` | error | A rule names a tag no manifest lists |
101
+ | `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 |
103
+
104
+ ## Configure it
105
+
106
+ Every setting can be given in three places, and a later place wins, field by
107
+ field: the defaults this package ships, your project's file, and the call.
108
+ The page cannot read files, so import your file and pass it as `file`:
109
+
110
+ ```js
111
+ import project from './helix-diagnostics.json' with { type: 'json' }
112
+
113
+ diagnose({
114
+ environment: 'development',
115
+ file: project,
116
+ rules: { 'unforwarded-part': 'off' },
117
+ tiers,
118
+ })
119
+ ```
120
+
121
+ ```json
122
+ {
123
+ "project": { "name": "storefront", "tags": ["web"] },
124
+ "rules": { "dead-hook": { "severity": "warn", "tags": ["helix-card"] } },
125
+ "console": { "findings": "warn", "usage": "off" },
126
+ "send": { "performance": false },
127
+ "detail": { "evidence": "full", "hookValues": true, "pages": "patterns" },
128
+ "environments": { "development": true, "ci": true, "production": false }
129
+ }
130
+ ```
131
+
132
+ | Setting | Takes | Default |
133
+ | --- | --- | --- |
134
+ | `rules.<kind>` | `error`, `warn`, `info`, `off`, or `{ severity, tags }` | see Finding kinds |
135
+ | `console.findings` | `error`, `warn`, `info`, or `off`: the lowest severity printed | `info` |
136
+ | `console.coverage` | `info` or `off` | `info` |
137
+ | `console.usage` | `debug` or `off` | `debug` |
138
+ | `send.<category>` | `true` or `false`, for `findings`, `usage`, `coverage`, `configuration`, `runtime`, `performance`, and `assist` | all on but `performance` |
139
+ | `detail.evidence` | `full`, `selector`, or `none` | `full` |
140
+ | `detail.hookValues` | `true` or `false` | `true` |
141
+ | `detail.pages` | `patterns`, `full` (the path, never the query), or `none` | `patterns` |
142
+ | `environments.<name>` | `true` or `false`; `production` takes `{ sample }` from 0 to 1 | development and CI on |
143
+ | `project` | `key`, `name`, and `tags` | none |
144
+
145
+ `send`, `detail`, and `environments` decide what a sending sink sends, and
146
+ nothing here sends yet: they are recorded in every report, so a dashboard
147
+ knows what a project withheld. A setting or a value that does not exist
148
+ throws, and names the closest that does.
149
+
150
+ Development only. It walks every stylesheet in the page, and it sends nothing
151
+ anywhere.
152
+
153
+ ## Versions
31
154
 
32
- Development only. It walks stylesheets in the page and makes no network calls,
33
- ever.
155
+ `@fusion.dev/helix-core` lists its version on the page as it loads, and every
156
+ manifest built from 0.137 names the package and version it describes. Where
157
+ both sides say, a manifest from another version is one `manifest-skew`
158
+ finding in place of all others. Where either is silent, the check falls back
159
+ to a part rendering that the manifest does not list.
34
160
 
35
161
  [Documentation](https://fusion.dev/helix)
36
162
 
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,27 @@ 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[];
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[];
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,46 @@ 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
+ };
210
+ export const versionSkew = (manifests, seen) => seen.length === 0
211
+ ? []
212
+ : Object.entries(manifests)
213
+ .filter(([, version]) => !seen.includes(version))
214
+ .map(([name, version]) => ({
215
+ kind: 'manifest-skew',
216
+ 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.`,
217
+ subject: `${name}@${version}`,
218
+ tag: '',
219
+ }));
@@ -0,0 +1,71 @@
1
+ import { type Category, type Configuration, type Layer, type Report } from './report.js';
2
+ import { type Rules } from './rules.js';
3
+ /** Everything a project can set. Every field is optional; see `DEFAULTS`. */
4
+ export interface Config {
5
+ /** The lowest level each category prints at in the console, or `off`. */
6
+ console?: Partial<Configuration['console']>;
7
+ /** How much detail goes with what is sent. */
8
+ detail?: Partial<Configuration['detail']>;
9
+ /** Where sending happens. Production takes `{ sample }`, from 0 to 1. */
10
+ environments?: Partial<Configuration['environments']>;
11
+ /**
12
+ * The project: its key, which opts it in to sending, and its own name and
13
+ * labels. `tags` are free, and `fusion` marks Fusion's own projects.
14
+ */
15
+ project?: {
16
+ key?: string;
17
+ name?: string;
18
+ tags?: string[];
19
+ };
20
+ /** Severities by kind; see `Rules`. */
21
+ rules?: Rules;
22
+ /** Each category, sent or withheld. Nothing is sent without a key. */
23
+ send?: Partial<Record<Category, boolean>>;
24
+ }
25
+ /** The shipped defaults. */
26
+ export declare const DEFAULTS: {
27
+ readonly console: {
28
+ readonly coverage: 'info';
29
+ readonly findings: 'info';
30
+ readonly usage: 'debug';
31
+ };
32
+ readonly detail: {
33
+ readonly evidence: 'full';
34
+ readonly hookValues: true;
35
+ readonly pages: 'patterns';
36
+ };
37
+ readonly environments: {
38
+ readonly ci: true;
39
+ readonly development: true;
40
+ readonly production: false;
41
+ };
42
+ readonly send: {
43
+ readonly assist: true;
44
+ readonly configuration: true;
45
+ readonly coverage: true;
46
+ readonly findings: true;
47
+ readonly performance: false;
48
+ readonly runtime: true;
49
+ readonly usage: true;
50
+ };
51
+ };
52
+ /** Throws on a setting that names nothing, or a value it cannot take. */
53
+ export declare const checkConfig: (config: Config | undefined, layer: Exclude<Layer, 'shipped'>) => void;
54
+ /** The settings in effect, from the defaults, the file, and the call. */
55
+ export interface Resolved {
56
+ configuration: Omit<Configuration, 'overrides' | 'silenced'>;
57
+ project: Report['project'];
58
+ /** Each category's sending, and which place decided it. */
59
+ send: Record<Category, {
60
+ decidedBy: Layer;
61
+ on: boolean;
62
+ }>;
63
+ }
64
+ /** The three places, merged, with a later one winning field by field. */
65
+ export declare const resolveConfig: (file: Config | undefined, call: Config | undefined) => Resolved;
66
+ /**
67
+ * A page's path as a pattern: each segment that names one record, a number,
68
+ * a UUID, or a long run of hex or mixed letters and digits, becomes `:id`.
69
+ * `/orders/1042/items/9f8e7d6c5b4a` reads `/orders/:id/items/:id`.
70
+ */
71
+ export declare const pagePattern: (path: string) => string;
package/dist/config.js ADDED
@@ -0,0 +1,126 @@
1
+ import { nearest } from './manifest.js';
2
+ import { CATEGORIES } from './report.js';
3
+ import { checkRules } from './rules.js';
4
+ export const DEFAULTS = {
5
+ console: { coverage: 'info', findings: 'info', usage: 'debug' },
6
+ detail: { evidence: 'full', hookValues: true, pages: 'patterns' },
7
+ environments: { ci: true, development: true, production: false },
8
+ send: {
9
+ assist: true,
10
+ configuration: true,
11
+ coverage: true,
12
+ findings: true,
13
+ performance: false,
14
+ runtime: true,
15
+ usage: true,
16
+ },
17
+ };
18
+ const ALLOWED = {
19
+ console: {
20
+ coverage: ['info', 'off'],
21
+ findings: ['error', 'warn', 'info', 'off'],
22
+ usage: ['debug', 'off'],
23
+ },
24
+ detail: {
25
+ evidence: ['full', 'selector', 'none'],
26
+ hookValues: [true, false],
27
+ pages: ['patterns', 'full', 'none'],
28
+ },
29
+ environments: { ci: [true, false], development: [true, false], production: 'sample' },
30
+ project: { key: 'string', name: 'string', tags: 'tags' },
31
+ send: Object.fromEntries(CATEGORIES.map((category) => [category, [true, false]])),
32
+ };
33
+ const shown = (value) => typeof value === 'string' ? `\`${value}\`` : String(value);
34
+ export const checkConfig = (config, layer) => {
35
+ if (config === undefined) {
36
+ return;
37
+ }
38
+ if (typeof config !== 'object' || config === null || Array.isArray(config)) {
39
+ throw new Error(`The ${layer} is not a configuration object.`);
40
+ }
41
+ for (const [section, value] of Object.entries(config)) {
42
+ if (section === 'rules') {
43
+ checkRules(value, layer);
44
+ continue;
45
+ }
46
+ const allowed = ALLOWED[section];
47
+ if (!allowed) {
48
+ const close = nearest(section, [...Object.keys(ALLOWED), 'rules']);
49
+ throw new Error(`The ${layer} sets \`${section}\`, which is no setting.` +
50
+ (close ? ` The closest is \`${close}\`.` : ''));
51
+ }
52
+ for (const [name, one] of Object.entries(value)) {
53
+ const takes = allowed[name];
54
+ if (takes === undefined) {
55
+ const close = nearest(name, Object.keys(allowed));
56
+ throw new Error(`The ${layer} sets \`${section}.${name}\`, which is no setting.` +
57
+ (close ? ` The closest is \`${section}.${close}\`.` : ''));
58
+ }
59
+ const fits = takes === 'string'
60
+ ? typeof one === 'string'
61
+ : takes === 'tags'
62
+ ? Array.isArray(one) && one.every((tag) => typeof tag === 'string')
63
+ : takes === 'sample'
64
+ ? one === false ||
65
+ (typeof one === 'object' &&
66
+ one !== null &&
67
+ typeof one.sample === 'number' &&
68
+ one.sample >= 0 &&
69
+ one.sample <= 1)
70
+ : takes.includes(one);
71
+ if (!fits) {
72
+ const expected = takes === 'string'
73
+ ? 'a string'
74
+ : takes === 'tags'
75
+ ? 'a list of strings'
76
+ : takes === 'sample'
77
+ ? '`false`, or `{ sample }` from 0 to 1'
78
+ : takes.map(shown).join(', ');
79
+ throw new Error(`The ${layer} sets \`${section}.${name}\` to \`${JSON.stringify(one)}\`. It takes ${expected}.`);
80
+ }
81
+ }
82
+ }
83
+ };
84
+ export const resolveConfig = (file, call) => {
85
+ checkConfig(file, 'file');
86
+ checkConfig(call, 'argument');
87
+ const merged = (section, base) => ({
88
+ ...base,
89
+ ...(file?.[section] ?? {}),
90
+ ...(call?.[section] ?? {}),
91
+ });
92
+ const send = Object.fromEntries(CATEGORIES.map((category) => {
93
+ const fromCall = call?.send?.[category];
94
+ const fromFile = file?.send?.[category];
95
+ return [
96
+ category,
97
+ fromCall !== undefined
98
+ ? { decidedBy: 'argument', on: fromCall }
99
+ : fromFile !== undefined
100
+ ? { decidedBy: 'file', on: fromFile }
101
+ : { decidedBy: 'shipped', on: DEFAULTS.send[category] },
102
+ ];
103
+ }));
104
+ const project = { ...(file?.project ?? {}), ...(call?.project ?? {}) };
105
+ return {
106
+ configuration: {
107
+ console: merged('console', { ...DEFAULTS.console }),
108
+ detail: merged('detail', { ...DEFAULTS.detail }),
109
+ environments: merged('environments', { ...DEFAULTS.environments }),
110
+ },
111
+ project: {
112
+ ...(project.name === undefined ? {} : { name: project.name }),
113
+ tags: project.tags ?? [],
114
+ },
115
+ send,
116
+ };
117
+ };
118
+ export const pagePattern = (path) => path
119
+ .split('/')
120
+ .map((segment) => /^\d+$/.test(segment) ||
121
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment) ||
122
+ /^[0-9a-f]{8,}$/i.test(segment) ||
123
+ (segment.length >= 12 && /\d/.test(segment) && /^[A-Za-z0-9_-]+$/.test(segment))
124
+ ? ':id'
125
+ : segment)
126
+ .join('/');
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,17 @@
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';
14
- /** 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;
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'>;
30
+ /**
31
+ * Writes a report and hands it back, so a caller can chain a second sink.
32
+ *
33
+ * **Each category prints as the project's `console` setting says**: findings
34
+ * at or above a severity, coverage and usage on or off. The heading counts
35
+ * every finding whatever is printed under it, and the ones the rules turned
36
+ * off, so a quiet console never reads as a clean page.
37
+ */
38
+ export declare const report: (result: Report, to?: ConsoleSink) => Report;
package/dist/console.js CHANGED
@@ -1,30 +1,69 @@
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'}`;
10
+ const RANK = { error: 3, info: 1, warn: 2 };
9
11
  export const report = (result, to = console) => {
10
- const { coverage, findings } = result;
12
+ const { configuration, coverage, findings } = result;
13
+ const levels = configuration.console;
14
+ const bySeverity = (severity) => findings.filter((finding) => finding.severity === severity).length;
15
+ const errors = bySeverity('error');
16
+ const warnings = bySeverity('warn');
17
+ const off = configuration.silenced.reduce((total, one) => total + one.count, 0);
11
18
  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
- }
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
- }
23
- to.log(` ${findings.length} finding${findings.length === 1 ? '' : 's'}:`);
24
- for (const finding of findings) {
25
- for (const text of line(finding)) {
26
- to.log(text);
19
+ const counts = [
20
+ counted(errors, 'error'),
21
+ counted(warnings, 'warning'),
22
+ `${bySeverity('info')} info`,
23
+ ...(off > 0 ? [`${off} turned off`] : []),
24
+ ];
25
+ const heading = findings.length === 0 && off === 0
26
+ ? 'helix diagnostics: no findings'
27
+ : `helix diagnostics: ${counts.join(', ')}`;
28
+ if (errors + warnings > 0 || missed > 0) {
29
+ to.group(heading);
30
+ }
31
+ else {
32
+ to.groupCollapsed(heading);
33
+ }
34
+ if (levels.coverage !== 'off') {
35
+ to.info(`read ${coverage.read} of ${counted(coverage.sheets, 'stylesheet')} across ${counted(coverage.roots, 'shadow root')}` +
36
+ (result.run.page ? ` on ${result.run.page}` : ''));
37
+ }
38
+ if (missed > 0) {
39
+ to.warn([
40
+ `${counted(missed, 'stylesheet')} could not be read, so ${missed === 1 ? 'it was' : 'they were'} not checked:`,
41
+ ...coverage.unreadable.map((sheet) => `${sheet.sheet} (${sheet.reason})`),
42
+ ].join('\n'));
43
+ }
44
+ if (off > 0 && levels.coverage !== 'off') {
45
+ to.info(configuration.silenced
46
+ .map((one) => `${counted(one.count, one.kind)} turned off by the ${one.layer}`)
47
+ .join('\n'));
48
+ }
49
+ if (levels.findings !== 'off') {
50
+ const floor = RANK[levels.findings];
51
+ for (const finding of findings) {
52
+ if (RANK[finding.severity] >= floor) {
53
+ to[finding.severity](entry(finding));
54
+ }
27
55
  }
28
56
  }
57
+ if (levels.usage !== 'off') {
58
+ const { counted: how, hooks, parts, tags, unlisted } = result.usage;
59
+ const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
60
+ const elements = Object.values(tags).reduce((total, one) => total + one, 0);
61
+ const others = Object.keys(unlisted).length;
62
+ const versions = result.versions.helix;
63
+ to.debug(`usage (${how}): ${counted(Object.keys(tags).length, 'tag')} in ${counted(elements, 'element')}, ${counted(sum(parts), 'part')} styled, ${counted(sum(hooks), 'hook')} set` +
64
+ (others > 0 ? `, and ${counted(others, 'helix- tag')} no manifest lists` : '') +
65
+ (versions.length > 0 ? `; Helix ${versions.join(' and ')}` : ''));
66
+ }
67
+ to.groupEnd();
29
68
  return result;
30
69
  };