@fusion.dev/helix-diagnostics 0.0.0-dev
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/dist/analyze.d.ts +47 -0
- package/dist/analyze.js +230 -0
- package/dist/collect.d.ts +58 -0
- package/dist/collect.js +173 -0
- package/dist/console.d.ts +15 -0
- package/dist/console.js +31 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +64 -0
- package/dist/manifest.d.ts +68 -0
- package/dist/manifest.js +93 -0
- package/dist/report.d.ts +88 -0
- package/dist/report.js +13 -0
- package/package.json +46 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The faults, derived from the rules and the manifest and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* **Every rule here is set membership against the manifest, never a naming
|
|
5
|
+
* pattern**, and that is the design decision the whole file turns on. The
|
|
6
|
+
* obvious rule for a misnamed hook is "a `--<family>-*` property whose family
|
|
7
|
+
* does not match the element it is set on", and in this library that rule is
|
|
8
|
+
* wrong 27 times: `helix-breadcrumb` answers `--breadcrumbs-*` because the
|
|
9
|
+
* family is plural, and every chart shares a `--chart-*` namespace across nine
|
|
10
|
+
* tags. A pattern rule would open with 27 false findings in the library's own
|
|
11
|
+
* repository, and `#238` is the row that says what that costs: a check that
|
|
12
|
+
* cries wolf trains its only reader to ignore it.
|
|
13
|
+
*
|
|
14
|
+
* **Scope comes from the selector rather than the name.** A property is only
|
|
15
|
+
* examined when the rule it sits in targets a Helix tag, which is what keeps a
|
|
16
|
+
* consumer's own `--my-app-gap` out of the findings without a list of
|
|
17
|
+
* exemptions, and what lets a finding say which tag it is wrong for.
|
|
18
|
+
*/
|
|
19
|
+
import type { Answered, Located } from './collect.js';
|
|
20
|
+
import { type Published } from './manifest.js';
|
|
21
|
+
import type { Finding } from './report.js';
|
|
22
|
+
/**
|
|
23
|
+
* The findings in one page's rules.
|
|
24
|
+
*
|
|
25
|
+
* `tiers` is the tier names in the order they were passed, which is how an
|
|
26
|
+
* unforwarded part is phrased: the part exists, another tier publishes it, and
|
|
27
|
+
* the one being styled did not forward it.
|
|
28
|
+
*/
|
|
29
|
+
export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) => Finding[];
|
|
30
|
+
/**
|
|
31
|
+
* Two copies of Helix on one page, which is not a CSS fault and is the most
|
|
32
|
+
* expensive one on the list.
|
|
33
|
+
*
|
|
34
|
+
* **Helix already detects this, and detecting it again here would be worse.**
|
|
35
|
+
* `@fusion.dev/helix-core`'s `customElement` decorator sees the second registration as
|
|
36
|
+
* it happens, with both class objects in hand, which is a position nothing
|
|
37
|
+
* later can improve on: by the time a page can be scanned, the losing class is
|
|
38
|
+
* unreachable and all that is left is an identity mismatch to infer from. So
|
|
39
|
+
* this reads `duplicateRegistrations()` rather than re-deriving it, which is
|
|
40
|
+
* `CLAUDE.md`'s rule about where a fix lives applied to a detector.
|
|
41
|
+
*
|
|
42
|
+
* The identity check stays as a fallback, for the copy that never went through
|
|
43
|
+
* Helix's own decorator: a fork, a hand-written `customElements.define`, or a
|
|
44
|
+
* version old enough to predate the warning. That case reports what it can see,
|
|
45
|
+
* which is that a live element is not an instance of its own registered class.
|
|
46
|
+
*/
|
|
47
|
+
export declare const registrations: (from?: Document, known?: () => string[]) => Finding[];
|
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { nearest } from './manifest.js';
|
|
2
|
+
/** Every `helix-*` tag a selector mentions, in source order. */
|
|
3
|
+
const targets = (selector) => {
|
|
4
|
+
const found = new Set();
|
|
5
|
+
for (const match of selector.matchAll(/\bhelix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
|
|
6
|
+
found.add(match[0]);
|
|
7
|
+
}
|
|
8
|
+
return [...found];
|
|
9
|
+
};
|
|
10
|
+
/** Every `::part(a b)` in a selector, with the tag it hangs off. */
|
|
11
|
+
const parts = (selector) => {
|
|
12
|
+
const found = [];
|
|
13
|
+
for (const match of selector.matchAll(/(helix-[a-z0-9-]+)((?:[^\s>+~,]*))::part\(\s*([^)]*?)\s*\)/g)) {
|
|
14
|
+
const names = (match[3] ?? '').split(/\s+/).filter(Boolean);
|
|
15
|
+
if (match[1] && names.length > 0) {
|
|
16
|
+
found.push({ names, tag: match[1] });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return found;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Whether any of `wanted` sits inside `host`, shadow roots included.
|
|
23
|
+
*
|
|
24
|
+
* **This is the check a static tool cannot make, and skipping it is a three
|
|
25
|
+
* percent false-positive rate.** Measured on this repository: `landing.module.css`
|
|
26
|
+
* sets `--icon-button-radius` and `--icon-button-size` on `helix-copy-button`,
|
|
27
|
+
* which answers neither, and both work, because a copy button *renders* an icon
|
|
28
|
+
* button and custom properties inherit through a shadow boundary. A rule that
|
|
29
|
+
* reads the manifest alone calls those two dead. Six findings out of 197
|
|
30
|
+
* declarations examined, every one wrong, in the library's own repository.
|
|
31
|
+
*
|
|
32
|
+
* A collector running in the page can simply look. That is the whole argument
|
|
33
|
+
* for the collector being in the page rather than in a linter.
|
|
34
|
+
*/
|
|
35
|
+
const reaches = (host, wanted, depth = 0) => {
|
|
36
|
+
if (depth > 16) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
for (const node of [host, ...host.querySelectorAll('*')]) {
|
|
40
|
+
if (node !== host && wanted.has(node.localName)) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
const root = node.shadowRoot;
|
|
44
|
+
if (root) {
|
|
45
|
+
for (const child of root.children) {
|
|
46
|
+
if (wanted.has(child.localName) || reaches(child, wanted, depth + 1)) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
const origin = (located) => ({
|
|
55
|
+
cssText: located.rule.cssText,
|
|
56
|
+
index: located.index,
|
|
57
|
+
sheet: located.sheet,
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* The findings in one page's rules.
|
|
61
|
+
*
|
|
62
|
+
* `tiers` is the tier names in the order they were passed, which is how an
|
|
63
|
+
* unforwarded part is phrased: the part exists, another tier publishes it, and
|
|
64
|
+
* the one being styled did not forward it.
|
|
65
|
+
*/
|
|
66
|
+
export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
67
|
+
const findings = [];
|
|
68
|
+
for (const located of rules) {
|
|
69
|
+
const selector = located.rule.selectorText;
|
|
70
|
+
if (!selector?.includes('helix-')) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
for (const { names, tag } of parts(selector)) {
|
|
74
|
+
const element = tags.get(tag);
|
|
75
|
+
if (!element) {
|
|
76
|
+
findings.push({
|
|
77
|
+
kind: 'unknown-tag',
|
|
78
|
+
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.`,
|
|
79
|
+
origin: origin(located),
|
|
80
|
+
subject: tag,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
for (const part of names) {
|
|
85
|
+
const publishers = element.parts.get(part);
|
|
86
|
+
if (publishers) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
/* A part the *other* tier publishes is not a typo, it is a part that
|
|
90
|
+
was never forwarded, and the two need different sentences: one is
|
|
91
|
+
the author's mistake and the other is ours. */
|
|
92
|
+
const elsewhere = [...tags].find(([other, entry]) => other !== tag && entry.parts.has(part));
|
|
93
|
+
if (elsewhere) {
|
|
94
|
+
findings.push({
|
|
95
|
+
expected: part,
|
|
96
|
+
kind: 'unforwarded-part',
|
|
97
|
+
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.`,
|
|
98
|
+
origin: origin(located),
|
|
99
|
+
subject: `${tag}::part(${part})`,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const suggestion = nearest(part, element.parts.keys());
|
|
104
|
+
findings.push({
|
|
105
|
+
expected: suggestion,
|
|
106
|
+
kind: 'dead-part',
|
|
107
|
+
message: suggestion
|
|
108
|
+
? `\`${tag}\` publishes no part named \`${part}\`. The closest it does publish is \`${suggestion}\`.`
|
|
109
|
+
: `\`${tag}\` publishes no part named \`${part}\`, so this rule matches nothing. Published: ${[...element.parts.keys()].sort().join(', ') || 'none'}.`,
|
|
110
|
+
origin: origin(located),
|
|
111
|
+
subject: `${tag}::part(${part})`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/* A property set on a selector naming several Helix tags is wrong only if
|
|
116
|
+
it is unknown to all of them: `helix-button, helix-link { --gap: 0 }` is
|
|
117
|
+
fine when either answers it. */
|
|
118
|
+
const mentioned = targets(selector).map((tag) => [tag, tags.get(tag)]);
|
|
119
|
+
const known = mentioned.filter(([, element]) => element);
|
|
120
|
+
if (known.length === 0) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
for (const property of Array.from(located.rule.style ?? [])) {
|
|
124
|
+
if (!property.startsWith('--')) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (known.some(([, element]) => element?.hooks.has(property))) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
/* The manifest is a floor, not the whole truth. A tag whose own shadow
|
|
131
|
+
styles read the property answers it whatever `cem` recorded, which is
|
|
132
|
+
how `helix-copy-button` answers `--icon-button-size` while declaring no
|
|
133
|
+
hooks at all: it sets `styles = [HelixIconButton.styles, styles]` and
|
|
134
|
+
borrows the stylesheet outright, a relationship no manifest field has a
|
|
135
|
+
name for. */
|
|
136
|
+
if (known.some(([tag]) => answered.get(tag)?.has(property))) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
/* Unknown to every tag in the selector, and unknown to Helix entirely, is
|
|
140
|
+
a property of the consumer's own that happens to be set here. Only a
|
|
141
|
+
name Helix does have somewhere is evidence of a mistake. */
|
|
142
|
+
const answerers = new Set([...tags].filter(([, element]) => element.hooks.has(property)).map(([tag]) => tag));
|
|
143
|
+
const candidates = known.flatMap(([, element]) => [...(element?.hooks.keys() ?? [])]);
|
|
144
|
+
const suggestion = nearest(property, candidates);
|
|
145
|
+
if (answerers.size === 0 && !suggestion) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
/* Set on an ancestor of something that *does* answer it, this is
|
|
149
|
+
deliberate: the property inherits through the shadow boundary and
|
|
150
|
+
reaches the nested component. Only a page can settle that, so a run
|
|
151
|
+
without one declines to guess. */
|
|
152
|
+
if (answerers.size > 0 && !suggestion) {
|
|
153
|
+
if (!from) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
let inherits = false;
|
|
157
|
+
for (const host of from.querySelectorAll(selector.replaceAll(/::part\([^)]*\)/g, ''))) {
|
|
158
|
+
if (reaches(host, answerers)) {
|
|
159
|
+
inherits = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (inherits) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const tag = known[0]?.[0] ?? 'the element';
|
|
168
|
+
findings.push({
|
|
169
|
+
expected: suggestion,
|
|
170
|
+
kind: 'dead-hook',
|
|
171
|
+
message: suggestion
|
|
172
|
+
? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`, which is one character class away and is almost certainly what was meant.`
|
|
173
|
+
: `\`${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.`,
|
|
174
|
+
origin: origin(located),
|
|
175
|
+
subject: `${selector} { ${property} }`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return findings;
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Two copies of Helix on one page, which is not a CSS fault and is the most
|
|
183
|
+
* expensive one on the list.
|
|
184
|
+
*
|
|
185
|
+
* **Helix already detects this, and detecting it again here would be worse.**
|
|
186
|
+
* `@fusion.dev/helix-core`'s `customElement` decorator sees the second registration as
|
|
187
|
+
* it happens, with both class objects in hand, which is a position nothing
|
|
188
|
+
* later can improve on: by the time a page can be scanned, the losing class is
|
|
189
|
+
* unreachable and all that is left is an identity mismatch to infer from. So
|
|
190
|
+
* this reads `duplicateRegistrations()` rather than re-deriving it, which is
|
|
191
|
+
* `CLAUDE.md`'s rule about where a fix lives applied to a detector.
|
|
192
|
+
*
|
|
193
|
+
* The identity check stays as a fallback, for the copy that never went through
|
|
194
|
+
* Helix's own decorator: a fork, a hand-written `customElements.define`, or a
|
|
195
|
+
* version old enough to predate the warning. That case reports what it can see,
|
|
196
|
+
* which is that a live element is not an instance of its own registered class.
|
|
197
|
+
*/
|
|
198
|
+
export const registrations = (from = document, known = () => []) => {
|
|
199
|
+
const findings = [];
|
|
200
|
+
const registry = globalThis.customElements;
|
|
201
|
+
const said = new Set();
|
|
202
|
+
const duplicate = (tag, how) => {
|
|
203
|
+
if (said.has(tag)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
said.add(tag);
|
|
207
|
+
findings.push({
|
|
208
|
+
kind: 'duplicate-registration',
|
|
209
|
+
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.`,
|
|
210
|
+
subject: tag,
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
for (const tag of known()) {
|
|
214
|
+
duplicate(tag, 'reported by the decorator at the moment it happened');
|
|
215
|
+
}
|
|
216
|
+
if (!registry) {
|
|
217
|
+
return findings;
|
|
218
|
+
}
|
|
219
|
+
for (const element of from.querySelectorAll('*')) {
|
|
220
|
+
const tag = element.localName;
|
|
221
|
+
if (!tag.startsWith('helix-') || said.has(tag)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const registered = registry.get(tag);
|
|
225
|
+
if (registered && !(element instanceof registered)) {
|
|
226
|
+
duplicate(tag, 'a live element is not an instance of its own registered class');
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return findings;
|
|
230
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every stylesheet rule the page will actually apply, and an honest account of
|
|
3
|
+
* the ones that could not be read.
|
|
4
|
+
*
|
|
5
|
+
* **`document.styleSheets` is not the input.** Component CSS lives in adopted
|
|
6
|
+
* stylesheets inside shadow roots, which appear in that list nowhere at all, so
|
|
7
|
+
* a collector reading only it would report clean while blind to the half of the
|
|
8
|
+
* page it exists to check. Four sources are walked: the document's sheets, the
|
|
9
|
+
* document's adopted sheets, and both of those again for every shadow root
|
|
10
|
+
* reachable from the document.
|
|
11
|
+
*
|
|
12
|
+
* **Cross-origin sheets throw rather than return nothing**, which is the one
|
|
13
|
+
* failure mode that has to be caught rather than avoided: `.cssRules` on a sheet
|
|
14
|
+
* from another origin raises `SecurityError`, and a `try` around the access is
|
|
15
|
+
* the only way to know. Each one becomes a `Coverage.unreadable` entry, so the
|
|
16
|
+
* hole is in the output rather than in the reader's assumptions.
|
|
17
|
+
*/
|
|
18
|
+
import type { Coverage } from './report.js';
|
|
19
|
+
/** One rule, with where it came from. */
|
|
20
|
+
export interface Located {
|
|
21
|
+
index: number;
|
|
22
|
+
rule: CSSStyleRule;
|
|
23
|
+
sheet: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Every custom property a tag's own shadow styles read, by tag.
|
|
27
|
+
*
|
|
28
|
+
* **This is what the manifest cannot tell you.** `helix-copy-button` declares no
|
|
29
|
+
* hooks at all and answers `--icon-button-size` and `--icon-button-radius`,
|
|
30
|
+
* because its class sets `styles = [HelixIconButton.styles, styles].flat()`:
|
|
31
|
+
* it borrows another component's stylesheet outright. That is not inheritance,
|
|
32
|
+
* not a mixin and not a part, so no manifest field describes it and `cem` records
|
|
33
|
+
* nothing. Two correct declarations in this repository's own `landing.module.css`
|
|
34
|
+
* were called dead on the strength of that silence.
|
|
35
|
+
*
|
|
36
|
+
* A `var(--x)` inside the element's own shadow styles is direct evidence that
|
|
37
|
+
* the element reads the property, which is a stronger claim than the manifest
|
|
38
|
+
* makes and is available only to something running in the page. So it is
|
|
39
|
+
* collected on the way past and the manifest is treated as a floor rather than
|
|
40
|
+
* the whole truth.
|
|
41
|
+
*/
|
|
42
|
+
export type Answered = Map<string, Set<string>>;
|
|
43
|
+
/**
|
|
44
|
+
* Every readable rule in the page, and what was missed getting them.
|
|
45
|
+
*
|
|
46
|
+
* **Sheets are counted by identity, and the reason is Lit.** A Lit class holds
|
|
47
|
+
* one `CSSStyleSheet` and every instance of it adopts that same object, so a
|
|
48
|
+
* page with two hundred Helix elements presents the same twenty sheets over and
|
|
49
|
+
* over. Counted naively that is `sheets: 400`, a coverage denominator that
|
|
50
|
+
* describes the page's element count rather than its stylesheets, and four
|
|
51
|
+
* hundred passes over rules already read. A `Set` keyed on the sheet object
|
|
52
|
+
* fixes the number and the cost together.
|
|
53
|
+
*/
|
|
54
|
+
export declare const collect: (from?: Document) => {
|
|
55
|
+
answered: Answered;
|
|
56
|
+
coverage: Coverage;
|
|
57
|
+
rules: Located[];
|
|
58
|
+
};
|
package/dist/collect.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A sheet with no `href` still needs a name a reader can act on.
|
|
3
|
+
*
|
|
4
|
+
* **The ordinal is load-bearing, not decoration.** A Lit element adopts several
|
|
5
|
+
* sheets, its own and every base class's, and all of them are "adopted by
|
|
6
|
+
* `<helix-button>`": without a number, a finding's receipt names a place that
|
|
7
|
+
* matches three sheets and the reader has to guess which. Counted per label so
|
|
8
|
+
* the first of a kind is unnumbered and reads naturally.
|
|
9
|
+
*/
|
|
10
|
+
const name = (sheet, seen, host) => {
|
|
11
|
+
if (sheet.href) {
|
|
12
|
+
return sheet.href;
|
|
13
|
+
}
|
|
14
|
+
const base = host
|
|
15
|
+
? `adopted by <${host.localName}>`
|
|
16
|
+
: sheet.ownerNode instanceof globalThis.Element
|
|
17
|
+
? `<${sheet.ownerNode.localName}> in the document`
|
|
18
|
+
: 'adopted by the document';
|
|
19
|
+
const count = (seen.get(base) ?? 0) + 1;
|
|
20
|
+
seen.set(base, count);
|
|
21
|
+
return count === 1 ? base : `${base} (${count})`;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The rules of one sheet, flattened through `@media` and `@supports`.
|
|
25
|
+
*
|
|
26
|
+
* A dead `::part()` inside a media query is dead at that width, which is still
|
|
27
|
+
* dead. Grouping rules are descended into rather than skipped, and `@import` is
|
|
28
|
+
* followed through `styleSheet` because an imported sheet is a sheet.
|
|
29
|
+
*/
|
|
30
|
+
const rules = (list, sheet, into, depth = 0, reads) => {
|
|
31
|
+
if (depth > 8) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
35
|
+
/* Indexed rather than `list.item(index)`. Both are standard and only one is
|
|
36
|
+
universally implemented: happy-dom's `CSSRuleList` is array-like with no
|
|
37
|
+
`item`, so a collector calling it throws under the DOM the specs run in
|
|
38
|
+
and works in a browser, which is the worst of both. */
|
|
39
|
+
const rule = list[index];
|
|
40
|
+
if (!rule) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if ('selectorText' in rule) {
|
|
44
|
+
into.push({ index, rule: rule, sheet });
|
|
45
|
+
if (reads) {
|
|
46
|
+
for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)) {
|
|
47
|
+
if (match[1]) {
|
|
48
|
+
reads.add(match[1]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/* A nested rule is a rule. CSS nesting puts `&::part(x)` inside the rule
|
|
53
|
+
for the host, and the part name is just as wrong there. */
|
|
54
|
+
const nested = rule.cssRules;
|
|
55
|
+
if (nested) {
|
|
56
|
+
rules(nested, sheet, into, depth + 1, reads);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const grouped = rule.cssRules;
|
|
61
|
+
if (grouped) {
|
|
62
|
+
rules(grouped, sheet, into, depth + 1, reads);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const imported = rule.styleSheet;
|
|
66
|
+
if (imported) {
|
|
67
|
+
read(imported, into, [], new Map(), depth + 1, undefined, reads);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/** One sheet, or the reason it could not be read. */
|
|
72
|
+
const read = (sheet, into, unreadable, labels, depth = 0, host, reads) => {
|
|
73
|
+
const label = name(sheet, labels, host);
|
|
74
|
+
let list;
|
|
75
|
+
try {
|
|
76
|
+
/* Reading the property is what throws, so it is read before anything else
|
|
77
|
+
is done with it. `cssRules` on a cross-origin sheet raises SecurityError,
|
|
78
|
+
and there is no flag to ask instead. */
|
|
79
|
+
list = sheet.cssRules;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
unreadable.push({
|
|
83
|
+
reason: error instanceof DOMException && error.name === 'SecurityError' ? 'cross-origin' : 'threw',
|
|
84
|
+
sheet: label,
|
|
85
|
+
});
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
rules(list, label, into, depth, reads);
|
|
89
|
+
return true;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Every shadow root reachable from a node, depth first.
|
|
93
|
+
*
|
|
94
|
+
* **`querySelectorAll('*')` does not cross a shadow boundary**, so this recurses
|
|
95
|
+
* per root rather than querying once. A closed root is invisible here and is
|
|
96
|
+
* counted nowhere, which is the correct silence: the collector cannot know it
|
|
97
|
+
* exists, and inventing a number for what it cannot see is the dishonesty the
|
|
98
|
+
* coverage field is there to prevent. Every Helix root is open.
|
|
99
|
+
*/
|
|
100
|
+
const roots = (from, found, depth = 0) => {
|
|
101
|
+
if (depth > 32) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const element of from.querySelectorAll('*')) {
|
|
105
|
+
const root = element.shadowRoot;
|
|
106
|
+
if (root) {
|
|
107
|
+
found.push(root);
|
|
108
|
+
roots(root, found, depth + 1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Every readable rule in the page, and what was missed getting them.
|
|
114
|
+
*
|
|
115
|
+
* **Sheets are counted by identity, and the reason is Lit.** A Lit class holds
|
|
116
|
+
* one `CSSStyleSheet` and every instance of it adopts that same object, so a
|
|
117
|
+
* page with two hundred Helix elements presents the same twenty sheets over and
|
|
118
|
+
* over. Counted naively that is `sheets: 400`, a coverage denominator that
|
|
119
|
+
* describes the page's element count rather than its stylesheets, and four
|
|
120
|
+
* hundred passes over rules already read. A `Set` keyed on the sheet object
|
|
121
|
+
* fixes the number and the cost together.
|
|
122
|
+
*/
|
|
123
|
+
export const collect = (from = document) => {
|
|
124
|
+
const located = [];
|
|
125
|
+
const unreadable = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const labels = new Map();
|
|
128
|
+
const answered = new Map();
|
|
129
|
+
let readable = 0;
|
|
130
|
+
const take = (sheet, host) => {
|
|
131
|
+
if (!sheet || seen.has(sheet)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
seen.add(sheet);
|
|
135
|
+
let reads;
|
|
136
|
+
if (host) {
|
|
137
|
+
reads = answered.get(host.localName) ?? new Set();
|
|
138
|
+
answered.set(host.localName, reads);
|
|
139
|
+
}
|
|
140
|
+
if (read(sheet, located, unreadable, labels, 0, host, reads)) {
|
|
141
|
+
readable += 1;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
for (const sheet of Array.from(from.styleSheets)) {
|
|
145
|
+
take(sheet);
|
|
146
|
+
}
|
|
147
|
+
for (const sheet of from.adoptedStyleSheets ?? []) {
|
|
148
|
+
take(sheet);
|
|
149
|
+
}
|
|
150
|
+
const shadows = [];
|
|
151
|
+
roots(from, shadows);
|
|
152
|
+
for (const root of shadows) {
|
|
153
|
+
for (const sheet of Array.from(root.styleSheets ?? [])) {
|
|
154
|
+
take(sheet, root.host);
|
|
155
|
+
}
|
|
156
|
+
for (const sheet of root.adoptedStyleSheets ?? []) {
|
|
157
|
+
take(sheet, root.host);
|
|
158
|
+
}
|
|
159
|
+
/* `ShadowRoot.styleSheets` covers a `<style>` inside the root in a browser
|
|
160
|
+
and is absent from happy-dom entirely, so the elements are asked
|
|
161
|
+
directly as well. Identity dedup is what makes asking twice free, and
|
|
162
|
+
without this path every spec covering a root's own `<style>` would be a
|
|
163
|
+
spec that cannot fail. */
|
|
164
|
+
for (const owner of root.querySelectorAll('style, link')) {
|
|
165
|
+
take(owner.sheet, root.host);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
answered,
|
|
170
|
+
coverage: { read: readable, roots: shadows.length, sheets: seen.size, unreadable },
|
|
171
|
+
rules: located,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default sink: the console, and coverage before anything else.
|
|
3
|
+
*
|
|
4
|
+
* **Coverage is printed on a clean run too.** "No findings" from a collector
|
|
5
|
+
* that read three sheets of nineteen is not a pass, and the only way to stop
|
|
6
|
+
* that being read as one is to put the denominator next to the numerator every
|
|
7
|
+
* single time.
|
|
8
|
+
*
|
|
9
|
+
* 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.
|
|
12
|
+
*/
|
|
13
|
+
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;
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const line = (finding) => {
|
|
2
|
+
const out = [` ${finding.kind}: ${finding.subject}`, ` ${finding.message}`];
|
|
3
|
+
if (finding.origin) {
|
|
4
|
+
out.push(` in ${finding.origin.sheet}, rule ${finding.origin.index}`);
|
|
5
|
+
out.push(` ${finding.origin.cssText}`);
|
|
6
|
+
}
|
|
7
|
+
return out;
|
|
8
|
+
};
|
|
9
|
+
/** Writes a report and hands it back, so a caller can chain a second sink. */
|
|
10
|
+
export const report = (result, to = console) => {
|
|
11
|
+
const { coverage, findings } = result;
|
|
12
|
+
const missed = coverage.sheets - coverage.read;
|
|
13
|
+
to.log(`helix diagnostics: read ${coverage.read} of ${coverage.sheets} stylesheets across ${coverage.roots} shadow roots` +
|
|
14
|
+
(missed > 0 ? `, ${missed} unreadable` : ''));
|
|
15
|
+
for (const sheet of coverage.unreadable) {
|
|
16
|
+
to.log(` unreadable (${sheet.reason}): ${sheet.sheet}`);
|
|
17
|
+
}
|
|
18
|
+
if (findings.length === 0) {
|
|
19
|
+
to.log(missed > 0
|
|
20
|
+
? ' no findings in what could be read. The unreadable sheets above were not checked.'
|
|
21
|
+
: ' no findings.');
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
to.log(` ${findings.length} finding${findings.length === 1 ? '' : 's'}:`);
|
|
25
|
+
for (const finding of findings) {
|
|
26
|
+
for (const text of line(finding)) {
|
|
27
|
+
to.log(text);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a consumer gets wrong in Helix is valid CSS.
|
|
3
|
+
*
|
|
4
|
+
* It compiles, it ships, it produces no error, and it is detectable only against
|
|
5
|
+
* the manifest. A `::part()` naming a part nothing publishes styles nothing. A
|
|
6
|
+
* hook named for the wrong family inherits into the subtree and does nothing
|
|
7
|
+
* where it was written. Two copies of the library on one page make `instanceof`
|
|
8
|
+
* quietly false. None of it warns.
|
|
9
|
+
*
|
|
10
|
+
* The library's own repository found six classes of this in one week, written by
|
|
11
|
+
* the people who wrote the library, and the one that mattered most passed every
|
|
12
|
+
* one of sixty checks: a part rendering visibly with no rule, black on the
|
|
13
|
+
* headless tier, caught four days late by a screenshot. A third party gets none
|
|
14
|
+
* of those checks. This is what they get instead.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { diagnose, report } from '@fusion.dev/helix-diagnostics'
|
|
18
|
+
* import styled from '@fusion.dev/helix/custom-elements.json'
|
|
19
|
+
*
|
|
20
|
+
* report(diagnose({ tiers: [{ manifest: styled, name: '@fusion.dev/helix' }] }))
|
|
21
|
+
* ```
|
|
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.
|
|
27
|
+
*
|
|
28
|
+
* **Development only.** Import it from a dev-only entry: it walks every
|
|
29
|
+
* stylesheet in the page and has no business in a production bundle.
|
|
30
|
+
*/
|
|
31
|
+
export { collect } from './collect.js';
|
|
32
|
+
export { report } from './console.js';
|
|
33
|
+
export type { ManifestSource, Published, Tier } from './manifest.js';
|
|
34
|
+
export { index, nearest } from './manifest.js';
|
|
35
|
+
export type { Coverage, Finding, Origin, Report, Unreadable } from './report.js';
|
|
36
|
+
import type { Tier } from './manifest.js';
|
|
37
|
+
import type { Report } from './report.js';
|
|
38
|
+
/** What to look at, and what to compare it against. */
|
|
39
|
+
export interface Options {
|
|
40
|
+
/**
|
|
41
|
+
* `duplicateRegistrations` from `@fusion.dev/helix-core`, when it is installed.
|
|
42
|
+
*
|
|
43
|
+
* **Passed in rather than imported**, so this package does not depend on the
|
|
44
|
+
* library it diagnoses. A diagnostics release would otherwise have to follow
|
|
45
|
+
* every core release, and a consumer on an older core could not run a newer
|
|
46
|
+
* collector at all. Omitted, the duplicate check falls back to comparing a
|
|
47
|
+
* live element against its own registered class, which finds the same fault
|
|
48
|
+
* from outside and cannot name the losing class.
|
|
49
|
+
*/
|
|
50
|
+
duplicates?: () => string[];
|
|
51
|
+
/**
|
|
52
|
+
* The document to scan. Defaults to the ambient one.
|
|
53
|
+
*
|
|
54
|
+
* Present so a test can hand over a constructed document, and so a page with
|
|
55
|
+
* an iframe it owns can scan that too.
|
|
56
|
+
*/
|
|
57
|
+
from?: Document;
|
|
58
|
+
/**
|
|
59
|
+
* The manifests to check against, one per installed tier.
|
|
60
|
+
*
|
|
61
|
+
* **Pass both tiers when both are installed.** A part `@fusion.dev/helix-headless`
|
|
62
|
+
* publishes and `@fusion.dev/helix` never forwarded is a real fault and is
|
|
63
|
+
* invisible to either manifest alone.
|
|
64
|
+
*/
|
|
65
|
+
tiers: Tier[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One pass over the page.
|
|
69
|
+
*
|
|
70
|
+
* **It returns coverage and findings together and offers no way to have one
|
|
71
|
+
* without the other.** A collector that cannot read cross-origin sheets reports
|
|
72
|
+
* the same empty array whether it read everything or nothing, and this
|
|
73
|
+
* repository has already shipped one check that was structurally incapable of
|
|
74
|
+
* failing and green for weeks because of it. An empty `findings` is only
|
|
75
|
+
* meaningful beside the `coverage` that says what was looked at.
|
|
76
|
+
*
|
|
77
|
+
* Cheap enough to call on a route change. There is no observer and no polling
|
|
78
|
+
* loop: sheets that arrive later are found by calling it again.
|
|
79
|
+
*/
|
|
80
|
+
export declare const diagnose: ({ duplicates, from, tiers }: Options) => Report;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything a consumer gets wrong in Helix is valid CSS.
|
|
3
|
+
*
|
|
4
|
+
* It compiles, it ships, it produces no error, and it is detectable only against
|
|
5
|
+
* the manifest. A `::part()` naming a part nothing publishes styles nothing. A
|
|
6
|
+
* hook named for the wrong family inherits into the subtree and does nothing
|
|
7
|
+
* where it was written. Two copies of the library on one page make `instanceof`
|
|
8
|
+
* quietly false. None of it warns.
|
|
9
|
+
*
|
|
10
|
+
* The library's own repository found six classes of this in one week, written by
|
|
11
|
+
* the people who wrote the library, and the one that mattered most passed every
|
|
12
|
+
* one of sixty checks: a part rendering visibly with no rule, black on the
|
|
13
|
+
* headless tier, caught four days late by a screenshot. A third party gets none
|
|
14
|
+
* of those checks. This is what they get instead.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { diagnose, report } from '@fusion.dev/helix-diagnostics'
|
|
18
|
+
* import styled from '@fusion.dev/helix/custom-elements.json'
|
|
19
|
+
*
|
|
20
|
+
* report(diagnose({ tiers: [{ manifest: styled, name: '@fusion.dev/helix' }] }))
|
|
21
|
+
* ```
|
|
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.
|
|
27
|
+
*
|
|
28
|
+
* **Development only.** Import it from a dev-only entry: it walks every
|
|
29
|
+
* stylesheet in the page and has no business in a production bundle.
|
|
30
|
+
*/
|
|
31
|
+
export { collect } from './collect.js';
|
|
32
|
+
export { report } from './console.js';
|
|
33
|
+
export { index, nearest } from './manifest.js';
|
|
34
|
+
import { analyze, registrations } from './analyze.js';
|
|
35
|
+
import { collect } from './collect.js';
|
|
36
|
+
import { index } from './manifest.js';
|
|
37
|
+
/**
|
|
38
|
+
* One pass over the page.
|
|
39
|
+
*
|
|
40
|
+
* **It returns coverage and findings together and offers no way to have one
|
|
41
|
+
* without the other.** A collector that cannot read cross-origin sheets reports
|
|
42
|
+
* the same empty array whether it read everything or nothing, and this
|
|
43
|
+
* repository has already shipped one check that was structurally incapable of
|
|
44
|
+
* failing and green for weeks because of it. An empty `findings` is only
|
|
45
|
+
* meaningful beside the `coverage` that says what was looked at.
|
|
46
|
+
*
|
|
47
|
+
* Cheap enough to call on a route change. There is no observer and no polling
|
|
48
|
+
* loop: sheets that arrive later are found by calling it again.
|
|
49
|
+
*/
|
|
50
|
+
export const diagnose = ({ duplicates, from, tiers }) => {
|
|
51
|
+
if (tiers.length === 0) {
|
|
52
|
+
throw new Error('diagnose() needs at least one manifest. Without one every part name is unknown and every finding would be noise.');
|
|
53
|
+
}
|
|
54
|
+
const document_ = from ?? globalThis.document;
|
|
55
|
+
const { answered, coverage, rules } = collect(document_);
|
|
56
|
+
const tags = index(tiers);
|
|
57
|
+
return {
|
|
58
|
+
coverage,
|
|
59
|
+
findings: [
|
|
60
|
+
...analyze(rules, tags, document_, answered),
|
|
61
|
+
...registrations(document_, duplicates),
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A custom-elements manifest, indexed for lookup.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is the only thing that knows which parts a tag publishes and
|
|
5
|
+
* which hooks it answers, and it is already a dependency of anyone consuming
|
|
6
|
+
* Helix: `package.json` points at it through `customElements`, and every editor
|
|
7
|
+
* integration reads it. So the collector takes it as input rather than shipping
|
|
8
|
+
* a copy, which is also what keeps a diagnostics release from having to follow
|
|
9
|
+
* a library release.
|
|
10
|
+
*
|
|
11
|
+
* **Both tiers are passed together when both are installed.** A part that
|
|
12
|
+
* `@fusion.dev/helix-headless` publishes and `@fusion.dev/helix` does not forward is a
|
|
13
|
+
* real fault (148 of them across 19 families, found by hand), and it is only
|
|
14
|
+
* visible when the two manifests are compared. Which manifest an entry came
|
|
15
|
+
* from is therefore tracked rather than merged away.
|
|
16
|
+
*/
|
|
17
|
+
/** The subset of the manifest schema this reads. Nothing else is touched. */
|
|
18
|
+
export interface ManifestSource {
|
|
19
|
+
modules?: Array<{
|
|
20
|
+
declarations?: Array<{
|
|
21
|
+
cssParts?: Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
}>;
|
|
24
|
+
cssProperties?: Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
}>;
|
|
27
|
+
tagName?: string;
|
|
28
|
+
}>;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
/** One manifest, and which tier shipped it. */
|
|
32
|
+
export interface Tier {
|
|
33
|
+
manifest: ManifestSource;
|
|
34
|
+
/** Package name, used verbatim in a finding's message. */
|
|
35
|
+
name: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* What one tag publishes, per tier.
|
|
39
|
+
*
|
|
40
|
+
* Not `Element`, which it was: that shadows the DOM's own `Element` wherever
|
|
41
|
+
* both are in scope, and `analyze.ts` needs both in the same file.
|
|
42
|
+
*/
|
|
43
|
+
export interface Published {
|
|
44
|
+
hooks: Map<string, Set<string>>;
|
|
45
|
+
parts: Map<string, Set<string>>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Every tag across every tier, with the tiers that publish each part and hook.
|
|
49
|
+
*
|
|
50
|
+
* A `Set` of tier names rather than a boolean because the useful finding is not
|
|
51
|
+
* "this part does not exist" but "this part exists on the headless tier and the
|
|
52
|
+
* styled one did not forward it", and that sentence needs the names.
|
|
53
|
+
*/
|
|
54
|
+
export declare const index: (tiers: Tier[]) => Map<string, Published>;
|
|
55
|
+
/**
|
|
56
|
+
* The published name closest to a wrong one, or nothing when none is close.
|
|
57
|
+
*
|
|
58
|
+
* **A suggestion is the difference between a finding and a chore.**
|
|
59
|
+
* `--breadcrumb-hover-color` is wrong because the family is `breadcrumbs`, and
|
|
60
|
+
* a reader told only that the hook is unknown has to go and read the manifest;
|
|
61
|
+
* told that `--breadcrumbs-hover-color` exists, they are done.
|
|
62
|
+
*
|
|
63
|
+
* Levenshtein with a ceiling of a third of the name's length. The ceiling is
|
|
64
|
+
* what stops a suggestion from being noise: `--button-x` is not a typo for
|
|
65
|
+
* `--button-background-color` however close they sort, and a confident wrong
|
|
66
|
+
* suggestion costs more than none.
|
|
67
|
+
*/
|
|
68
|
+
export declare const nearest: (wrong: string, candidates: Iterable<string>) => string | undefined;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A custom-elements manifest, indexed for lookup.
|
|
3
|
+
*
|
|
4
|
+
* The manifest is the only thing that knows which parts a tag publishes and
|
|
5
|
+
* which hooks it answers, and it is already a dependency of anyone consuming
|
|
6
|
+
* Helix: `package.json` points at it through `customElements`, and every editor
|
|
7
|
+
* integration reads it. So the collector takes it as input rather than shipping
|
|
8
|
+
* a copy, which is also what keeps a diagnostics release from having to follow
|
|
9
|
+
* a library release.
|
|
10
|
+
*
|
|
11
|
+
* **Both tiers are passed together when both are installed.** A part that
|
|
12
|
+
* `@fusion.dev/helix-headless` publishes and `@fusion.dev/helix` does not forward is a
|
|
13
|
+
* real fault (148 of them across 19 families, found by hand), and it is only
|
|
14
|
+
* visible when the two manifests are compared. Which manifest an entry came
|
|
15
|
+
* from is therefore tracked rather than merged away.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Every tag across every tier, with the tiers that publish each part and hook.
|
|
19
|
+
*
|
|
20
|
+
* A `Set` of tier names rather than a boolean because the useful finding is not
|
|
21
|
+
* "this part does not exist" but "this part exists on the headless tier and the
|
|
22
|
+
* styled one did not forward it", and that sentence needs the names.
|
|
23
|
+
*/
|
|
24
|
+
export const index = (tiers) => {
|
|
25
|
+
const tags = new Map();
|
|
26
|
+
for (const tier of tiers) {
|
|
27
|
+
for (const module of tier.manifest.modules ?? []) {
|
|
28
|
+
for (const declaration of module.declarations ?? []) {
|
|
29
|
+
const tag = declaration.tagName;
|
|
30
|
+
if (!tag) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
let element = tags.get(tag);
|
|
34
|
+
if (!element) {
|
|
35
|
+
element = { hooks: new Map(), parts: new Map() };
|
|
36
|
+
tags.set(tag, element);
|
|
37
|
+
}
|
|
38
|
+
for (const [field, into] of [
|
|
39
|
+
[declaration.cssParts, element.parts],
|
|
40
|
+
[declaration.cssProperties, element.hooks],
|
|
41
|
+
]) {
|
|
42
|
+
for (const entry of field ?? []) {
|
|
43
|
+
const publishers = into.get(entry.name) ?? new Set();
|
|
44
|
+
publishers.add(tier.name);
|
|
45
|
+
into.set(entry.name, publishers);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return tags;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The published name closest to a wrong one, or nothing when none is close.
|
|
55
|
+
*
|
|
56
|
+
* **A suggestion is the difference between a finding and a chore.**
|
|
57
|
+
* `--breadcrumb-hover-color` is wrong because the family is `breadcrumbs`, and
|
|
58
|
+
* a reader told only that the hook is unknown has to go and read the manifest;
|
|
59
|
+
* told that `--breadcrumbs-hover-color` exists, they are done.
|
|
60
|
+
*
|
|
61
|
+
* Levenshtein with a ceiling of a third of the name's length. The ceiling is
|
|
62
|
+
* what stops a suggestion from being noise: `--button-x` is not a typo for
|
|
63
|
+
* `--button-background-color` however close they sort, and a confident wrong
|
|
64
|
+
* suggestion costs more than none.
|
|
65
|
+
*/
|
|
66
|
+
export const nearest = (wrong, candidates) => {
|
|
67
|
+
let best;
|
|
68
|
+
let distance = Math.floor(wrong.length / 3);
|
|
69
|
+
for (const candidate of candidates) {
|
|
70
|
+
const found = within(wrong, candidate, distance);
|
|
71
|
+
if (found !== undefined && found < distance + 1) {
|
|
72
|
+
best = candidate;
|
|
73
|
+
distance = found;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return best;
|
|
77
|
+
};
|
|
78
|
+
/** Levenshtein distance, or `undefined` once it is certainly over the ceiling. */
|
|
79
|
+
const within = (from, to, ceiling) => {
|
|
80
|
+
if (Math.abs(from.length - to.length) > ceiling) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
let previous = Array.from({ length: to.length + 1 }, (_, column) => column);
|
|
84
|
+
for (let row = 1; row <= from.length; row += 1) {
|
|
85
|
+
const current = [row];
|
|
86
|
+
for (let column = 1; column <= to.length; column += 1) {
|
|
87
|
+
current[column] = Math.min((previous[column] ?? 0) + 1, (current[column - 1] ?? 0) + 1, (previous[column - 1] ?? 0) + (from[row - 1] === to[column - 1] ? 0 : 1));
|
|
88
|
+
}
|
|
89
|
+
previous = current;
|
|
90
|
+
}
|
|
91
|
+
const total = previous[to.length];
|
|
92
|
+
return total !== undefined && total <= ceiling ? total : undefined;
|
|
93
|
+
};
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a diagnostic run returns, and why coverage is not a field beside the
|
|
3
|
+
* findings.
|
|
4
|
+
*
|
|
5
|
+
* **A bare pass is a lie this collector is structurally capable of telling.**
|
|
6
|
+
* Cross-origin stylesheets throw on `.cssRules`, and a run that could not read
|
|
7
|
+
* half its input reports the same empty array as a run that read all of it.
|
|
8
|
+
* `check:dialect` shipped in exactly that condition for weeks: green, and
|
|
9
|
+
* incapable of being anything else. So `Report` has no `findings` that can be
|
|
10
|
+
* destructured away from the `coverage` that qualifies them, and the console
|
|
11
|
+
* sink prints coverage first whether or not anything was found.
|
|
12
|
+
*/
|
|
13
|
+
/** Where a rule was read from, so a reader can go and look at it. */
|
|
14
|
+
export interface Origin {
|
|
15
|
+
/**
|
|
16
|
+
* The rule's own text, which is the receipt.
|
|
17
|
+
*
|
|
18
|
+
* **There is no line number and there cannot be one.** CSSOM exposes
|
|
19
|
+
* `cssText` and an index within the sheet; the byte offset in the authored
|
|
20
|
+
* file is not in the object model at any level. `24-diagnostics.md` asks for a
|
|
21
|
+
* line, and this is the honest substitute: the text of the rule is what a
|
|
22
|
+
* reader searches for, and it survives minification and bundling in a way a
|
|
23
|
+
* line number would not.
|
|
24
|
+
*/
|
|
25
|
+
cssText: string;
|
|
26
|
+
/** Position within the sheet's own rule list, for a sheet with repeats. */
|
|
27
|
+
index: number;
|
|
28
|
+
/** The sheet's `href`, or a description of why it has none. */
|
|
29
|
+
sheet: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One thing that is wrong, with everything needed to disagree with it.
|
|
33
|
+
*
|
|
34
|
+
* **No finding without a receipt.** A rules engine makes a wrong finding cheap
|
|
35
|
+
* to add, and a check that cries wolf trains its only reader to ignore it. The
|
|
36
|
+
* `origin` and `expected` fields are what let a reader settle it in five
|
|
37
|
+
* seconds, and a finding that cannot produce them is a bug in the collector
|
|
38
|
+
* rather than a finding.
|
|
39
|
+
*/
|
|
40
|
+
export interface Finding {
|
|
41
|
+
/**
|
|
42
|
+
* What the collector believes the author meant, when it can tell.
|
|
43
|
+
*
|
|
44
|
+
* `| undefined` rather than the bare optional, because
|
|
45
|
+
* `exactOptionalPropertyTypes` is on and `nearest()` returns `undefined` when
|
|
46
|
+
* nothing is close enough to suggest. Writing the key as absent-or-string and
|
|
47
|
+
* then assigning `undefined` to it is the exact thing that setting exists to
|
|
48
|
+
* catch.
|
|
49
|
+
*/
|
|
50
|
+
expected?: string | undefined;
|
|
51
|
+
/** Machine-readable fault class, stable across versions. */
|
|
52
|
+
kind: 'dead-hook' | 'dead-part' | 'duplicate-registration' | 'unforwarded-part' | 'unknown-tag';
|
|
53
|
+
/** One sentence, addressed to whoever wrote the CSS. */
|
|
54
|
+
message: string;
|
|
55
|
+
/** Where it was read. Absent only for faults that are not in a stylesheet. */
|
|
56
|
+
origin?: Origin;
|
|
57
|
+
/** The selector the fault sits in, or the tag for a runtime fault. */
|
|
58
|
+
subject: string;
|
|
59
|
+
}
|
|
60
|
+
/** A sheet the collector could not read, and the reason. */
|
|
61
|
+
export interface Unreadable {
|
|
62
|
+
reason: 'cross-origin' | 'detached' | 'threw';
|
|
63
|
+
sheet: string;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* How much of the page the run actually saw.
|
|
67
|
+
*
|
|
68
|
+
* `roots` counts shadow roots traversed rather than elements visited, because
|
|
69
|
+
* that is the number that moves when a component hides its styles: Helix roots
|
|
70
|
+
* are all open (Lit's default, and nothing in the library overrides `mode`), so
|
|
71
|
+
* their adopted stylesheets are readable, and a third-party closed root is a
|
|
72
|
+
* hole this reports rather than steps over.
|
|
73
|
+
*/
|
|
74
|
+
export interface Coverage {
|
|
75
|
+
/** Sheets whose rules were read end to end. */
|
|
76
|
+
read: number;
|
|
77
|
+
/** Shadow roots entered, including nested ones. */
|
|
78
|
+
roots: number;
|
|
79
|
+
/** Sheets found, readable or not. */
|
|
80
|
+
sheets: number;
|
|
81
|
+
/** Every sheet that could not be read, with its reason. */
|
|
82
|
+
unreadable: Unreadable[];
|
|
83
|
+
}
|
|
84
|
+
/** The whole result. Coverage and findings, never one without the other. */
|
|
85
|
+
export interface Report {
|
|
86
|
+
coverage: Coverage;
|
|
87
|
+
findings: Finding[];
|
|
88
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a diagnostic run returns, and why coverage is not a field beside the
|
|
3
|
+
* findings.
|
|
4
|
+
*
|
|
5
|
+
* **A bare pass is a lie this collector is structurally capable of telling.**
|
|
6
|
+
* Cross-origin stylesheets throw on `.cssRules`, and a run that could not read
|
|
7
|
+
* half its input reports the same empty array as a run that read all of it.
|
|
8
|
+
* `check:dialect` shipped in exactly that condition for weeks: green, and
|
|
9
|
+
* incapable of being anything else. So `Report` has no `findings` that can be
|
|
10
|
+
* destructured away from the `coverage` that qualifies them, and the console
|
|
11
|
+
* sink prints coverage first whether or not anything was found.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fusion.dev/helix-diagnostics",
|
|
3
|
+
"description": "Finds the Helix mistakes that are valid CSS: dead parts, misnamed hooks, two copies of the library.",
|
|
4
|
+
"version": "0.0.0-dev",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"directory": "helix/diagnostics",
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/project-fusion/fusion.git"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.json",
|
|
27
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
|
+
"test": "bun test src"
|
|
29
|
+
},
|
|
30
|
+
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@fusion.dev/helix-testing": "0.0.0-dev",
|
|
33
|
+
"@types/bun": "^1.3.0"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://fusion.dev",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"web-components",
|
|
38
|
+
"custom-elements",
|
|
39
|
+
"css",
|
|
40
|
+
"diagnostics",
|
|
41
|
+
"helix"
|
|
42
|
+
],
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "restricted"
|
|
45
|
+
}
|
|
46
|
+
}
|