@fusion.dev/helix-diagnostics 0.2.0 → 0.4.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/dist/analyze.js +0 -59
- package/dist/collect.js +0 -51
- package/dist/console.js +0 -1
- package/dist/index.js +0 -42
- package/dist/manifest.js +0 -37
- package/dist/report.js +0 -12
- package/package.json +3 -3
package/dist/analyze.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { nearest } from './manifest.js';
|
|
2
|
-
/** Every `helix-*` tag a selector mentions, in source order. */
|
|
3
2
|
const targets = (selector) => {
|
|
4
3
|
const found = new Set();
|
|
5
4
|
for (const match of selector.matchAll(/\bhelix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
|
|
@@ -7,7 +6,6 @@ const targets = (selector) => {
|
|
|
7
6
|
}
|
|
8
7
|
return [...found];
|
|
9
8
|
};
|
|
10
|
-
/** Every `::part(a b)` in a selector, with the tag it hangs off. */
|
|
11
9
|
const parts = (selector) => {
|
|
12
10
|
const found = [];
|
|
13
11
|
for (const match of selector.matchAll(/(helix-[a-z0-9-]+)((?:[^\s>+~,]*))::part\(\s*([^)]*?)\s*\)/g)) {
|
|
@@ -18,20 +16,6 @@ const parts = (selector) => {
|
|
|
18
16
|
}
|
|
19
17
|
return found;
|
|
20
18
|
};
|
|
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
19
|
const reaches = (host, wanted, depth = 0) => {
|
|
36
20
|
if (depth > 16) {
|
|
37
21
|
return false;
|
|
@@ -56,13 +40,6 @@ const origin = (located) => ({
|
|
|
56
40
|
index: located.index,
|
|
57
41
|
sheet: located.sheet,
|
|
58
42
|
});
|
|
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
43
|
export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
67
44
|
const findings = [];
|
|
68
45
|
for (const located of rules) {
|
|
@@ -86,9 +63,6 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
86
63
|
if (publishers) {
|
|
87
64
|
continue;
|
|
88
65
|
}
|
|
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
66
|
const elsewhere = [...tags].find(([other, entry]) => other !== tag && entry.parts.has(part));
|
|
93
67
|
if (elsewhere) {
|
|
94
68
|
findings.push({
|
|
@@ -112,9 +86,6 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
112
86
|
});
|
|
113
87
|
}
|
|
114
88
|
}
|
|
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
89
|
const mentioned = targets(selector).map((tag) => [tag, tags.get(tag)]);
|
|
119
90
|
const known = mentioned.filter(([, element]) => element);
|
|
120
91
|
if (known.length === 0) {
|
|
@@ -127,28 +98,15 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
127
98
|
if (known.some(([, element]) => element?.hooks.has(property))) {
|
|
128
99
|
continue;
|
|
129
100
|
}
|
|
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
101
|
if (known.some(([tag]) => answered.get(tag)?.has(property))) {
|
|
137
102
|
continue;
|
|
138
103
|
}
|
|
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
104
|
const answerers = new Set([...tags].filter(([, element]) => element.hooks.has(property)).map(([tag]) => tag));
|
|
143
105
|
const candidates = known.flatMap(([, element]) => [...(element?.hooks.keys() ?? [])]);
|
|
144
106
|
const suggestion = nearest(property, candidates);
|
|
145
107
|
if (answerers.size === 0 && !suggestion) {
|
|
146
108
|
continue;
|
|
147
109
|
}
|
|
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
110
|
if (answerers.size > 0 && !suggestion) {
|
|
153
111
|
if (!from) {
|
|
154
112
|
continue;
|
|
@@ -178,23 +136,6 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
178
136
|
}
|
|
179
137
|
return findings;
|
|
180
138
|
};
|
|
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
|
-
* `AGENTS.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
139
|
export const registrations = (from = document, known = () => []) => {
|
|
199
140
|
const findings = [];
|
|
200
141
|
const registry = globalThis.customElements;
|
package/dist/collect.js
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
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
1
|
const name = (sheet, seen, host) => {
|
|
11
2
|
if (sheet.href) {
|
|
12
3
|
return sheet.href;
|
|
@@ -20,22 +11,11 @@ const name = (sheet, seen, host) => {
|
|
|
20
11
|
seen.set(base, count);
|
|
21
12
|
return count === 1 ? base : `${base} (${count})`;
|
|
22
13
|
};
|
|
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
14
|
const rules = (list, sheet, into, depth = 0, reads) => {
|
|
31
15
|
if (depth > 8) {
|
|
32
16
|
return;
|
|
33
17
|
}
|
|
34
18
|
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
19
|
const rule = list[index];
|
|
40
20
|
if (!rule) {
|
|
41
21
|
continue;
|
|
@@ -49,8 +29,6 @@ const rules = (list, sheet, into, depth = 0, reads) => {
|
|
|
49
29
|
}
|
|
50
30
|
}
|
|
51
31
|
}
|
|
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
32
|
const nested = rule.cssRules;
|
|
55
33
|
if (nested) {
|
|
56
34
|
rules(nested, sheet, into, depth + 1, reads);
|
|
@@ -68,14 +46,10 @@ const rules = (list, sheet, into, depth = 0, reads) => {
|
|
|
68
46
|
}
|
|
69
47
|
}
|
|
70
48
|
};
|
|
71
|
-
/** One sheet, or the reason it could not be read. */
|
|
72
49
|
const read = (sheet, into, unreadable, labels, depth = 0, host, reads) => {
|
|
73
50
|
const label = name(sheet, labels, host);
|
|
74
51
|
let list;
|
|
75
52
|
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
53
|
list = sheet.cssRules;
|
|
80
54
|
}
|
|
81
55
|
catch (error) {
|
|
@@ -88,15 +62,6 @@ const read = (sheet, into, unreadable, labels, depth = 0, host, reads) => {
|
|
|
88
62
|
rules(list, label, into, depth, reads);
|
|
89
63
|
return true;
|
|
90
64
|
};
|
|
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
65
|
const roots = (from, found, depth = 0) => {
|
|
101
66
|
if (depth > 32) {
|
|
102
67
|
return;
|
|
@@ -109,17 +74,6 @@ const roots = (from, found, depth = 0) => {
|
|
|
109
74
|
}
|
|
110
75
|
}
|
|
111
76
|
};
|
|
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
77
|
export const collect = (from = document) => {
|
|
124
78
|
const located = [];
|
|
125
79
|
const unreadable = [];
|
|
@@ -156,11 +110,6 @@ export const collect = (from = document) => {
|
|
|
156
110
|
for (const sheet of root.adoptedStyleSheets ?? []) {
|
|
157
111
|
take(sheet, root.host);
|
|
158
112
|
}
|
|
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
113
|
for (const owner of root.querySelectorAll('style, link')) {
|
|
165
114
|
take(owner.sheet, root.host);
|
|
166
115
|
}
|
package/dist/console.js
CHANGED
|
@@ -6,7 +6,6 @@ const line = (finding) => {
|
|
|
6
6
|
}
|
|
7
7
|
return out;
|
|
8
8
|
};
|
|
9
|
-
/** Writes a report and hands it back, so a caller can chain a second sink. */
|
|
10
9
|
export const report = (result, to = console) => {
|
|
11
10
|
const { coverage, findings } = result;
|
|
12
11
|
const missed = coverage.sheets - coverage.read;
|
package/dist/index.js
CHANGED
|
@@ -1,51 +1,9 @@
|
|
|
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
1
|
export { collect } from './collect.js';
|
|
32
2
|
export { report } from './console.js';
|
|
33
3
|
export { index, nearest } from './manifest.js';
|
|
34
4
|
import { analyze, registrations } from './analyze.js';
|
|
35
5
|
import { collect } from './collect.js';
|
|
36
6
|
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. An empty
|
|
43
|
-
* `findings` is only meaningful beside the `coverage` that says what was looked
|
|
44
|
-
* at.
|
|
45
|
-
*
|
|
46
|
-
* Cheap enough to call on a route change. There is no observer and no polling
|
|
47
|
-
* loop: sheets that arrive later are found by calling it again.
|
|
48
|
-
*/
|
|
49
7
|
export const diagnose = ({ duplicates, from, tiers }) => {
|
|
50
8
|
if (tiers.length === 0) {
|
|
51
9
|
throw new Error('diagnose() needs at least one manifest. Without one every part name is unknown and every finding would be noise.');
|
package/dist/manifest.js
CHANGED
|
@@ -1,26 +1,3 @@
|
|
|
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
1
|
export const index = (tiers) => {
|
|
25
2
|
const tags = new Map();
|
|
26
3
|
for (const tier of tiers) {
|
|
@@ -50,19 +27,6 @@ export const index = (tiers) => {
|
|
|
50
27
|
}
|
|
51
28
|
return tags;
|
|
52
29
|
};
|
|
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
30
|
export const nearest = (wrong, candidates) => {
|
|
67
31
|
let best;
|
|
68
32
|
let distance = Math.floor(wrong.length / 3);
|
|
@@ -75,7 +39,6 @@ export const nearest = (wrong, candidates) => {
|
|
|
75
39
|
}
|
|
76
40
|
return best;
|
|
77
41
|
};
|
|
78
|
-
/** Levenshtein distance, or `undefined` once it is certainly over the ceiling. */
|
|
79
42
|
const within = (from, to, ceiling) => {
|
|
80
43
|
if (Math.abs(from.length - to.length) > ceiling) {
|
|
81
44
|
return undefined;
|
package/dist/report.js
CHANGED
|
@@ -1,13 +1 @@
|
|
|
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
|
-
* `fusion prose`'s `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
1
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fusion.dev/helix-diagnostics",
|
|
3
3
|
"description": "Finds the Helix mistakes that are valid CSS: dead parts, misnamed hooks, two copies of the library.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
],
|
|
24
24
|
"sideEffects": false,
|
|
25
25
|
"scripts": {
|
|
26
|
-
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"build": "tsc -p tsconfig.json && tsc -p tsconfig.types.json",
|
|
27
27
|
"test": "bun test src",
|
|
28
28
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
29
29
|
},
|
|
30
30
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@fusion.dev/helix-testing": "0.
|
|
32
|
+
"@fusion.dev/helix-testing": "0.4.0",
|
|
33
33
|
"@types/bun": "^1.3.0"
|
|
34
34
|
},
|
|
35
35
|
"homepage": "https://fusion.dev",
|