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