@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 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.2.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.2.0",
32
+ "@fusion.dev/helix-testing": "0.4.0",
33
33
  "@types/bun": "^1.3.0"
34
34
  },
35
35
  "homepage": "https://fusion.dev",