@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.
@@ -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[];
@@ -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
+ };
@@ -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;
@@ -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
+ };
@@ -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;
@@ -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
+ };
@@ -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
+ }