@fusion.dev/helix-diagnostics 0.186.1 → 0.188.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -174,6 +174,23 @@ runs in, or the file `--config <path>` names, and refuses a setting that names
174
174
  nothing, as the page does. It exits 1 on a finding at `error`, and 3 where the
175
175
  project installs no Helix lint data. `--json` prints the run.
176
176
 
177
+ ## Run it on a page, from the command line
178
+
179
+ `helix diagnostics --once <url>` opens the page headless, runs this package's
180
+ diagnostics in it once the page holds still, prints each finding and what the
181
+ library logged as it ran, and exits. Nothing is added to your app: the
182
+ command serves the installed package on the page's own origin. Repeat
183
+ `--once` for each page.
184
+
185
+ ```sh
186
+ npx helix diagnostics --once http://localhost:5173/ --once http://localhost:5173/settings
187
+ ```
188
+
189
+ It needs `@fusion.dev/helix-cli` and Playwright installed beside this package.
190
+ It exits 1 on a finding at `error`, and 3 where a page could not be opened.
191
+ `--out` writes the runs to the CI file, and `--json` prints one document a
192
+ page. An agent gets the same runs from the MCP tool `helix_check_page`.
193
+
177
194
  ## Run it once, directly
178
195
 
179
196
  `diagnose()` is one run, which `initDiagnostics()` makes for you. Call it
@@ -269,7 +286,13 @@ works on it:
269
286
  | `unknown-slot` | error | A child names a slot its Helix element does not have, so it is not drawn. Found by `helix scan`, from the templates |
270
287
  | `invalid-value` | error | An attribute with a closed set of values carries one outside it, so the element falls back to its default |
271
288
  | `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
272
- | `unknown-tag` | error | A rule names a tag no manifest lists |
289
+ | `unknown-tag` | error | A rule or an element names a tag no manifest lists |
290
+ | `unknown-event` | error | A React `on` prop on a Helix element names an event the element does not dispatch, so the handler never runs. Found by `helix scan`, from the templates |
291
+ | `unimported-tag` | warn | An element is written and nothing in the project imports the entry that registers it, so the browser draws an unknown element. Found by `helix scan`, from the imports |
292
+ | `disabled-attribute` | error | A rule styles a form control's `[disabled]`, which a control disabled by a `<fieldset disabled>` does not carry. Write `:disabled`. Found by `helix scan` |
293
+ | `primitive-token` | error | A rule reads a `--_helix-` primitive, which is internal to `@fusion.dev/helix-styles`. Found by `helix scan` |
294
+ | `unknown-token` | error | A rule reads or sets a `--helix-` name the token set does not have, so it falls back silently. Found by `helix scan` |
295
+ | `unreflected-attribute` | warn | A rule selects a Helix element by an attribute it takes and never writes back, so it matches only markup a server wrote and nothing after a client navigation |
273
296
  | `duplicate-registration` | error | Two copies of Helix are on the page |
274
297
  | `manifest-skew` | error | The manifest is from another version than the page runs. It replaces every other finding, since each would be judged against the wrong version. Turned off, the page is judged as usual |
275
298
 
package/dist/analyze.d.ts CHANGED
@@ -54,8 +54,8 @@ export declare const analyze: (rules: Located[], tags: Map<string, Published>, f
54
54
  * it happens, with both class objects in hand, which is a position nothing
55
55
  * later can improve on: by the time a page can be scanned, the losing class is
56
56
  * unreachable and all that is left is an identity mismatch to infer from. So
57
- * this reads `duplicateRegistrations()` rather than re-deriving it, which is
58
- * `AGENTS.md`'s rule about where a fix lives applied to a detector.
57
+ * this reads `duplicateRegistrations()` and does not re-derive it, so the fact
58
+ * comes from where the fault starts.
59
59
  *
60
60
  * The identity check stays as a fallback, for the copy that never went through
61
61
  * Helix's own decorator: a fork, a hand-written `customElements.define`, or a
package/dist/analyze.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { nearest } from './manifest.js';
2
- import { pageOf } from './page.js';
2
+ import { OVERLAY, pageOf } from './page.js';
3
3
  export const targets = (selector) => {
4
4
  const found = new Set();
5
5
  for (const match of selector.matchAll(/(?<![\w.#-])helix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
@@ -153,6 +153,67 @@ const loose = (located, answerers, from, used, onPage, listedBy, readers, sugges
153
153
  }
154
154
  return findings;
155
155
  };
156
+ const ALWAYS_WRITTEN = new Set([
157
+ 'class',
158
+ 'dir',
159
+ 'hidden',
160
+ 'id',
161
+ 'lang',
162
+ 'part',
163
+ 'role',
164
+ 'slot',
165
+ 'style',
166
+ 'tabindex',
167
+ 'title',
168
+ ]);
169
+ const SELECTS = /([^\s>+~,()]*)\[\s*([a-zA-Z][\w-]*)\s*[~^|$*]?=?[^\]]*\]/g;
170
+ const unreflected = (selector, located, tags, from) => {
171
+ if (!from || !selector.includes('[')) {
172
+ return [];
173
+ }
174
+ const found = [];
175
+ const seen = new Set();
176
+ for (const [, compound = '', attribute = ''] of selector.matchAll(SELECTS)) {
177
+ if (ALWAYS_WRITTEN.has(attribute) ||
178
+ attribute.startsWith('aria-') ||
179
+ attribute.startsWith('data-') ||
180
+ seen.has(attribute)) {
181
+ continue;
182
+ }
183
+ const base = compound.replace(/\[[^\]]*\]/g, '').replace(/::?[\w-]+(\([^)]*\))?/g, '');
184
+ if (!base) {
185
+ continue;
186
+ }
187
+ let reached;
188
+ try {
189
+ reached = [...from.querySelectorAll(base)];
190
+ }
191
+ catch {
192
+ continue;
193
+ }
194
+ const tag = reached
195
+ .map((element) => element.localName)
196
+ .find((name) => {
197
+ const published = tags.get(name);
198
+ return (published?.reflected !== undefined &&
199
+ published.attributes.has(attribute) &&
200
+ !published.reflected.has(attribute) &&
201
+ !published.written.has(attribute));
202
+ });
203
+ if (!tag) {
204
+ continue;
205
+ }
206
+ seen.add(attribute);
207
+ found.push({
208
+ kind: 'unreflected-attribute',
209
+ message: `\`${selector}\` selects \`${tag}\` by \`${attribute}\`, which the element takes and never writes back to itself, so the rule matches only markup a server wrote: after a client navigation the property is set and the attribute is absent. Select a class or an attribute the element reflects, or set \`${attribute}\` as an attribute.`,
210
+ origin: origin(located),
211
+ subject: `${selector} [${attribute}]`,
212
+ tag,
213
+ });
214
+ }
215
+ return found;
216
+ };
156
217
  const origin = (located) => ({
157
218
  cssText: located.rule.cssText,
158
219
  index: located.index,
@@ -197,6 +258,7 @@ export const analyze = (rules, tags, from, answered = new Map(), used = new Set(
197
258
  if (!selector) {
198
259
  continue;
199
260
  }
261
+ findings.push(...unreflected(selector, located, tags, from));
200
262
  for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
201
263
  const element = tags.get(tag);
202
264
  if (!element) {
@@ -402,12 +464,43 @@ const GLOBAL = new Set([
402
464
  ]);
403
465
  const PREFIXES = ['aria-', 'data-', 'on', '_ngcontent-', '_nghost-', 'ng-', 'v-', 'x-', ':', '@'];
404
466
  const HOOK_DISTANCE = 2;
467
+ const unknownTag = (tags, tag) => {
468
+ if (!tag.startsWith('helix-') || tag === OVERLAY) {
469
+ return undefined;
470
+ }
471
+ const bare = nearest(tag.slice('helix-'.length), [...tags.keys()]
472
+ .filter((name) => name.startsWith('helix-'))
473
+ .map((name) => name.slice('helix-'.length)));
474
+ const suggestion = bare === undefined ? undefined : `helix-${bare}`;
475
+ return {
476
+ expected: suggestion,
477
+ kind: 'unknown-tag',
478
+ message: suggestion
479
+ ? `\`${tag}\` is in no manifest passed to the collector, so the browser draws it as an unknown element. The closest tag is \`${suggestion}\`.`
480
+ : `\`${tag}\` is in no manifest passed to the collector, so the browser draws it as an unknown element. Check the spelling, or load the package that publishes the tag.`,
481
+ subject: `<${tag}>`,
482
+ tag,
483
+ };
484
+ };
405
485
  export const markup = (tags, from, page = pageOf(from)) => {
406
486
  const found = new Map();
407
487
  for (const element of page.elements) {
408
488
  const host = element.getRootNode();
409
489
  const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
410
490
  const published = composed ? undefined : tags.get(element.localName);
491
+ if (!(composed || published) && tags.size > 0) {
492
+ const raw = unknownTag(tags, element.localName);
493
+ if (raw) {
494
+ const key = `${raw.kind} ${raw.subject}`;
495
+ const seen = found.get(key);
496
+ if (seen) {
497
+ seen.count += 1;
498
+ }
499
+ else {
500
+ found.set(key, { ...raw, count: 1 });
501
+ }
502
+ }
503
+ }
411
504
  if (published) {
412
505
  for (const { name, value } of Array.from(element.attributes)) {
413
506
  if (GLOBAL.has(name) ||
package/dist/collect.d.ts CHANGED
@@ -36,8 +36,8 @@ export interface Located {
36
36
  * because its class sets `styles = [HelixIconButton.styles, styles].flat()`:
37
37
  * it borrows another component's stylesheet outright. That is not inheritance,
38
38
  * not a mixin and not a part, so no manifest field describes it and `cem` records
39
- * nothing. Two correct declarations in this repository's own `landing.module.css`
40
- * were called dead on the strength of that silence.
39
+ * nothing. Two correct declarations in a site's stylesheet were called dead on
40
+ * the strength of that silence.
41
41
  *
42
42
  * A `var(--x)` inside the element's own shadow styles is direct evidence that
43
43
  * the element reads the property, which is a stronger claim than the manifest
package/dist/identity.js CHANGED
@@ -1,24 +1,36 @@
1
1
  const CATEGORY = {
2
2
  'dead-hook': 'styling',
3
3
  'dead-part': 'styling',
4
+ 'disabled-attribute': 'styling',
4
5
  'duplicate-registration': 'integrity',
5
6
  'invalid-value': 'markup',
6
7
  'manifest-skew': 'integrity',
8
+ 'primitive-token': 'styling',
7
9
  'unforwarded-part': 'styling',
10
+ 'unimported-tag': 'integrity',
8
11
  'unknown-attribute': 'markup',
12
+ 'unknown-event': 'markup',
9
13
  'unknown-slot': 'markup',
10
14
  'unknown-tag': 'integrity',
15
+ 'unknown-token': 'styling',
16
+ 'unreflected-attribute': 'styling',
11
17
  };
12
18
  const OWNER = {
13
19
  'dead-hook': 'consumer',
14
20
  'dead-part': 'consumer',
21
+ 'disabled-attribute': 'consumer',
15
22
  'duplicate-registration': 'consumer',
16
23
  'invalid-value': 'consumer',
17
24
  'manifest-skew': 'consumer',
25
+ 'primitive-token': 'consumer',
18
26
  'unforwarded-part': 'helix',
27
+ 'unimported-tag': 'consumer',
19
28
  'unknown-attribute': 'consumer',
29
+ 'unknown-event': 'consumer',
20
30
  'unknown-slot': 'consumer',
21
31
  'unknown-tag': 'consumer',
32
+ 'unknown-token': 'consumer',
33
+ 'unreflected-attribute': 'consumer',
22
34
  };
23
35
  export const normalize = (subject) => subject
24
36
  .replace(/___[A-Za-z0-9_-]{5}\b/g, '')
@@ -36,9 +36,11 @@ export interface ManifestSource {
36
36
  };
37
37
  }>;
38
38
  members?: Array<{
39
+ attribute?: string;
39
40
  kind?: string;
40
41
  name: string;
41
42
  readonly?: boolean;
43
+ reflects?: boolean;
42
44
  static?: boolean;
43
45
  }>;
44
46
  cssParts?: Array<{
@@ -85,6 +87,12 @@ export interface LintTag {
85
87
  * lint data written before 0.165, which a reader takes as unknown.
86
88
  */
87
89
  slots?: string[];
90
+ /**
91
+ * The attributes a property writes back to the element when it is set, so a
92
+ * selector on one matches however the property was set. Absent from lint data
93
+ * written before this field, which a reader takes as unknown.
94
+ */
95
+ reflected?: string[];
88
96
  /** The attributes the element writes on itself: one named for a read-only member. */
89
97
  written: string[];
90
98
  }
@@ -118,6 +126,11 @@ export interface Published {
118
126
  /** The family, from the module's path: `button` for `src/button/root/button-root.ts`. */
119
127
  family?: string | undefined;
120
128
  hooks: Map<string, Set<string>>;
129
+ /**
130
+ * The attributes a property writes back when it is set. Absent where a tier's
131
+ * lint data predates the field, which says nothing either way.
132
+ */
133
+ reflected?: Set<string> | undefined;
121
134
  /**
122
135
  * The attributes the element writes on itself: one named for a read-only
123
136
  * member, as `helix-notifications` writes `unread`. Output, so never a
package/dist/manifest.js CHANGED
@@ -12,11 +12,15 @@ export const lintOf = (manifest, name) => {
12
12
  family,
13
13
  hooks: [],
14
14
  parts: [],
15
+ reflected: [],
15
16
  slots: [],
16
17
  written: [],
17
18
  };
18
19
  tags[tag] = entry;
19
20
  for (const member of declaration.members ?? []) {
21
+ if (member.reflects && member.attribute) {
22
+ entry.reflected?.push(member.attribute);
23
+ }
20
24
  if (member.readonly && !member.static && member.kind === 'field') {
21
25
  entry.written.push(member.name, member.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
22
26
  }
@@ -35,6 +39,7 @@ export const lintOf = (manifest, name) => {
35
39
  entry.parts = [...new Set(entry.parts)];
36
40
  entry.hooks = [...new Set(entry.hooks)];
37
41
  entry.slots = [...new Set(entry.slots)];
42
+ entry.reflected = [...new Set(entry.reflected)];
38
43
  }
39
44
  return {
40
45
  package: manifest.helix?.package ?? name,
@@ -62,6 +67,12 @@ export const index = (tiers) => {
62
67
  for (const name of entry.written) {
63
68
  element.written.add(name);
64
69
  }
70
+ if (entry.reflected) {
71
+ element.reflected ??= new Set();
72
+ for (const name of entry.reflected) {
73
+ element.reflected.add(name);
74
+ }
75
+ }
65
76
  for (const [name, values] of Object.entries(entry.attributes)) {
66
77
  element.attributes.set(name, values ? new Set(values) : null);
67
78
  }
package/dist/report.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * **A bare pass is a lie this collector is structurally capable of telling.**
6
6
  * Cross-origin stylesheets throw on `.cssRules`, and a run that could not read
7
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
8
+ * `fusion x prose`'s `dialect` shipped in exactly that condition for weeks: green, and
9
9
  * incapable of being anything else. So `Report` has no `findings` that can be
10
10
  * destructured away from the `coverage` that qualifies them, and the console
11
11
  * sink prints coverage first whether or not anything was found.
@@ -97,7 +97,7 @@ export interface Finding {
97
97
  tag: string;
98
98
  }
99
99
  /** Every fault class, stable across versions. */
100
- export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'unforwarded-part', 'unknown-attribute', 'unknown-slot', 'unknown-tag'];
100
+ export declare const KINDS: readonly ['dead-hook', 'dead-part', 'disabled-attribute', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'primitive-token', 'unforwarded-part', 'unimported-tag', 'unknown-attribute', 'unknown-event', 'unknown-slot', 'unknown-tag', 'unknown-token', 'unreflected-attribute'];
101
101
  /** A fault class. */
102
102
  export type Kind = (typeof KINDS)[number];
103
103
  /** How serious a finding is. */
package/dist/report.js CHANGED
@@ -1,13 +1,19 @@
1
1
  export const KINDS = [
2
2
  'dead-hook',
3
3
  'dead-part',
4
+ 'disabled-attribute',
4
5
  'duplicate-registration',
5
6
  'invalid-value',
6
7
  'manifest-skew',
8
+ 'primitive-token',
7
9
  'unforwarded-part',
10
+ 'unimported-tag',
8
11
  'unknown-attribute',
12
+ 'unknown-event',
9
13
  'unknown-slot',
10
14
  'unknown-tag',
15
+ 'unknown-token',
16
+ 'unreflected-attribute',
11
17
  ];
12
18
  export const CATEGORIES = [
13
19
  'assist',
package/dist/rules.js CHANGED
@@ -3,13 +3,19 @@ import { KINDS, } from './report.js';
3
3
  export const SHIPPED_RULES = {
4
4
  'dead-hook': 'error',
5
5
  'dead-part': 'error',
6
+ 'disabled-attribute': 'error',
6
7
  'duplicate-registration': 'error',
7
8
  'invalid-value': 'error',
8
9
  'manifest-skew': 'error',
10
+ 'primitive-token': 'error',
9
11
  'unforwarded-part': 'warn',
12
+ 'unimported-tag': 'warn',
10
13
  'unknown-attribute': 'error',
14
+ 'unknown-event': 'error',
11
15
  'unknown-slot': 'error',
12
16
  'unknown-tag': 'error',
17
+ 'unknown-token': 'error',
18
+ 'unreflected-attribute': 'warn',
13
19
  };
14
20
  const SEVERITIES = new Set(['error', 'info', 'off', 'warn']);
15
21
  export const checkRules = (rules, layer) => {
package/dist/version.d.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * release, so the two never disagree; a spec beside this holds them to it.
7
7
  * Nothing else in the running code knows which release it is.
8
8
  */
9
- export declare const VERSION = "0.186.1";
9
+ export declare const VERSION = "0.188.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.186.1';
1
+ export const VERSION = '0.188.0';
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.186.1",
4
+ "version": "0.188.0",
5
5
  "license": "MIT",
6
6
  "author": "Ross Sheppard <ross.sheppard@fusion.dev>",
7
7
  "repository": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "bugs": "https://github.com/project-fusion/fusion/issues",
35
35
  "devDependencies": {
36
- "@fusion.dev/helix-testing": "0.186.1",
36
+ "@fusion.dev/helix-testing": "0.188.0",
37
37
  "@types/bun": "^1.4.2"
38
38
  },
39
39
  "homepage": "https://fusion.dev",