@fusion.dev/helix-diagnostics 0.186.0 → 0.187.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
@@ -269,7 +269,13 @@ works on it:
269
269
  | `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
270
  | `invalid-value` | error | An attribute with a closed set of values carries one outside it, so the element falls back to its default |
271
271
  | `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 |
272
+ | `unknown-tag` | error | A rule or an element names a tag no manifest lists |
273
+ | `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 |
274
+ | `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 |
275
+ | `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` |
276
+ | `primitive-token` | error | A rule reads a `--_helix-` primitive, which is internal to `@fusion.dev/helix-styles`. Found by `helix scan` |
277
+ | `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` |
278
+ | `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
279
  | `duplicate-registration` | error | Two copies of Helix are on the page |
274
280
  | `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
281
 
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/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.0";
9
+ export declare const VERSION = "0.187.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.186.0';
1
+ export const VERSION = '0.187.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.0",
4
+ "version": "0.187.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.0",
36
+ "@fusion.dev/helix-testing": "0.187.0",
37
37
  "@types/bun": "^1.4.2"
38
38
  },
39
39
  "homepage": "https://fusion.dev",