@fusion.dev/helix-diagnostics 0.137.0 → 0.138.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 +33 -2
- package/dist/analyze.d.ts +12 -2
- package/dist/analyze.js +250 -22
- package/dist/ci.d.ts +18 -0
- package/dist/ci.js +30 -0
- package/dist/collect.d.ts +18 -0
- package/dist/collect.js +29 -2
- package/dist/identity.js +4 -0
- package/dist/index.js +4 -3
- package/dist/manifest.d.ts +24 -1
- package/dist/manifest.js +29 -3
- package/dist/report.d.ts +1 -1
- package/dist/report.js +2 -0
- package/dist/rules.js +2 -0
- package/dist/usage.js +31 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -95,11 +95,24 @@ works on it:
|
|
|
95
95
|
| Kind | Severity | Means |
|
|
96
96
|
| --- | --- | --- |
|
|
97
97
|
| `dead-part` | error | A `::part()` names a part the element does not publish |
|
|
98
|
-
| `dead-hook` | error | A
|
|
98
|
+
| `dead-hook` | error | A hook is set where nothing answers it, or a name close to a hook is set that nothing reads |
|
|
99
|
+
| `unknown-attribute` | error | A Helix element carries an attribute it does not take |
|
|
100
|
+
| `invalid-value` | error | An attribute with a closed set of values carries one outside it, so the element falls back to its default |
|
|
99
101
|
| `unforwarded-part` | warn | The headless tier publishes the part and the styled tier does not forward it, which is a gap in Helix |
|
|
100
102
|
| `unknown-tag` | error | A rule names a tag no manifest lists |
|
|
101
103
|
| `duplicate-registration` | error | Two copies of Helix are on the page |
|
|
102
|
-
| `manifest-skew` | error |
|
|
104
|
+
| `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 |
|
|
105
|
+
|
|
106
|
+
**Hooks are checked wherever they are set**: on a Helix tag, a class, `:root`,
|
|
107
|
+
or any other selector. A hook on a selector naming no Helix tag is judged only
|
|
108
|
+
on what the page holds now. It is a finding when the selector matches elements
|
|
109
|
+
and none of them is, or holds, an element that answers it, while one that does
|
|
110
|
+
is on the page. A selector matching nothing yet is left alone, since it may be
|
|
111
|
+
for another route.
|
|
112
|
+
|
|
113
|
+
**Attributes are checked on the elements the page wrote.** The global HTML
|
|
114
|
+
attributes, `aria-*`, `data-*`, and the prefixes frameworks write are never
|
|
115
|
+
findings. The markup Helix renders inside its own components is Helix's.
|
|
103
116
|
|
|
104
117
|
## Configure it
|
|
105
118
|
|
|
@@ -150,6 +163,24 @@ throws, and names the closest that does.
|
|
|
150
163
|
Development only. It walks every stylesheet in the page, and it sends nothing
|
|
151
164
|
anywhere.
|
|
152
165
|
|
|
166
|
+
## In CI
|
|
167
|
+
|
|
168
|
+
`@fusion.dev/helix-diagnostics/ci` gathers the runs of a CI job into one JSON
|
|
169
|
+
file, in the shape every sink shares, and says whether the job should fail:
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
import { ciSink } from '@fusion.dev/helix-diagnostics/ci'
|
|
173
|
+
|
|
174
|
+
// `runs` are the reports `diagnose` returned in each page your job opened.
|
|
175
|
+
const { code, summary } = ciSink(runs, { file: 'reports/helix-diagnostics.json' })
|
|
176
|
+
console.log(summary)
|
|
177
|
+
process.exitCode = code
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
A fault found on many pages is one line, with the pages it was on. `error`
|
|
181
|
+
findings fail the job by default; pass `failOn: 'warn'` to fail on warnings
|
|
182
|
+
too, or `'never'` to report without failing.
|
|
183
|
+
|
|
153
184
|
## Versions
|
|
154
185
|
|
|
155
186
|
`@fusion.dev/helix-core` lists its version on the page as it loads, and every
|
package/dist/analyze.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* consumer's own `--my-app-gap` out of the findings without a list of
|
|
17
17
|
* exemptions, and what lets a finding say which tag it is wrong for.
|
|
18
18
|
*/
|
|
19
|
-
import type { Answered, Located } from './collect.js';
|
|
19
|
+
import type { Answered, Located, Readers } from './collect.js';
|
|
20
20
|
import { type Published } from './manifest.js';
|
|
21
21
|
import type { Raw } from './report.js';
|
|
22
22
|
/** Every `helix-*` tag a selector mentions, in source order. */
|
|
@@ -33,7 +33,7 @@ export declare const parts: (selector: string) => Array<{
|
|
|
33
33
|
* unforwarded part is phrased: the part exists, another tier publishes it, and
|
|
34
34
|
* the one being styled did not forward it.
|
|
35
35
|
*/
|
|
36
|
-
export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered) => Raw[];
|
|
36
|
+
export declare const analyze: (rules: Located[], tags: Map<string, Published>, from?: Document, answered?: Answered, used?: Set<string>, readers?: Readers) => Raw[];
|
|
37
37
|
/**
|
|
38
38
|
* Two copies of Helix on one page, which is not a CSS fault and is the most
|
|
39
39
|
* expensive one on the list.
|
|
@@ -75,3 +75,13 @@ export declare const skew: (tags: Map<string, Published>, from: Document) => Raw
|
|
|
75
75
|
* `diagnose` makes first.
|
|
76
76
|
*/
|
|
77
77
|
export declare const versionSkew: (manifests: Record<string, string>, seen: string[]) => Raw[];
|
|
78
|
+
/**
|
|
79
|
+
* The attributes on the page's Helix elements that do nothing: a name the
|
|
80
|
+
* element does not take, and a value outside the ones a closed set allows.
|
|
81
|
+
* Both are valid HTML and silent, since the element falls back to its default.
|
|
82
|
+
*
|
|
83
|
+
* **Only what the page wrote.** An element Helix renders inside another is
|
|
84
|
+
* Helix's own markup. One finding per tag, attribute, and value, however many
|
|
85
|
+
* elements carry it.
|
|
86
|
+
*/
|
|
87
|
+
export declare const markup: (tags: Map<string, Published>, from: Document) => Raw[];
|
package/dist/analyze.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { nearest } from './manifest.js';
|
|
2
2
|
export const targets = (selector) => {
|
|
3
3
|
const found = new Set();
|
|
4
|
-
for (const match of selector.matchAll(
|
|
4
|
+
for (const match of selector.matchAll(/(?<![\w.#-])helix-[a-z0-9]+(?:-[a-z0-9]+)*/g)) {
|
|
5
5
|
found.add(match[0]);
|
|
6
6
|
}
|
|
7
7
|
return [...found];
|
|
@@ -16,38 +16,159 @@ export const parts = (selector) => {
|
|
|
16
16
|
}
|
|
17
17
|
return found;
|
|
18
18
|
};
|
|
19
|
-
const
|
|
19
|
+
const matchable = (selector) => {
|
|
20
|
+
if (selector.includes(':host')) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const plain = selector
|
|
24
|
+
.replace(/::[\w-]+(\([^)]*\))?/g, '')
|
|
25
|
+
.replace(/:(focus-visible|focus-within|hover|focus|active|visited|target)(?![\w-])/g, '')
|
|
26
|
+
.trim();
|
|
27
|
+
return plain === '' ? null : plain;
|
|
28
|
+
};
|
|
29
|
+
const answersAt = (node, property, answering, readers) => {
|
|
30
|
+
if (answering.has(node.localName)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
for (const { scope, selector } of readers.get(property) ?? []) {
|
|
34
|
+
if (scope !== undefined && node.getRootNode() !== scope) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const plain = matchable(selector);
|
|
38
|
+
try {
|
|
39
|
+
if (plain !== null && node.matches(plain)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
};
|
|
48
|
+
const reachesAnswer = (host, property, answering, readers, depth = 0) => {
|
|
20
49
|
if (depth > 16) {
|
|
21
50
|
return false;
|
|
22
51
|
}
|
|
23
52
|
for (const node of [host, ...host.querySelectorAll('*')]) {
|
|
24
|
-
if (node
|
|
53
|
+
if (answersAt(node, property, answering, readers)) {
|
|
25
54
|
return true;
|
|
26
55
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (wanted.has(child.localName) || reaches(child, wanted, depth + 1)) {
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
56
|
+
for (const child of node.shadowRoot?.children ?? []) {
|
|
57
|
+
if (reachesAnswer(child, property, answering, readers, depth + 1)) {
|
|
58
|
+
return true;
|
|
33
59
|
}
|
|
34
60
|
}
|
|
35
61
|
}
|
|
36
62
|
return false;
|
|
37
63
|
};
|
|
64
|
+
const loose = (located, answerers, from, used, onPage, listedBy, readers) => {
|
|
65
|
+
const findings = [];
|
|
66
|
+
if (located.scope instanceof ShadowRoot && located.scope.host.localName.startsWith('helix-')) {
|
|
67
|
+
return findings;
|
|
68
|
+
}
|
|
69
|
+
const selector = located.rule.selectorText;
|
|
70
|
+
for (const property of Array.from(located.rule.style ?? [])) {
|
|
71
|
+
if (!property.startsWith('--') ||
|
|
72
|
+
property.startsWith('--helix-') ||
|
|
73
|
+
property.startsWith('--_')) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const answering = answerers.get(property);
|
|
77
|
+
if (!answering) {
|
|
78
|
+
if (used.has(property)) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const suggestion = nearest(property, answerers.keys(), HOOK_DISTANCE);
|
|
82
|
+
if (!suggestion) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const tag = [...(listedBy.get(suggestion) ?? answerers.get(suggestion) ?? [])].sort()[0] ?? '';
|
|
86
|
+
findings.push({
|
|
87
|
+
expected: suggestion,
|
|
88
|
+
kind: 'dead-hook',
|
|
89
|
+
message: `\`${selector}\` sets \`${property}\`, which no Helix element answers and nothing on the page reads. \`${suggestion}\` is a hook \`${tag}\` answers, two edits or fewer away, and almost certainly what was meant.`,
|
|
90
|
+
origin: origin(located),
|
|
91
|
+
subject: `${selector} { ${property} }`,
|
|
92
|
+
tag,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const scope = located.scope ?? from;
|
|
97
|
+
const plain = matchable(selector);
|
|
98
|
+
if (!scope || !from?.documentElement || plain === null) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
let matched;
|
|
102
|
+
try {
|
|
103
|
+
matched = [...scope.querySelectorAll(plain)];
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (matched.length === 0 || !onPage(property, answering)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (matched.some((host) => reachesAnswer(host, property, answering, readers))) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const tags = [...answering].sort();
|
|
115
|
+
findings.push({
|
|
116
|
+
kind: 'dead-hook',
|
|
117
|
+
message: `\`${selector}\` sets \`${property}\`, and nothing under the ${matched.length === 1 ? 'element' : `${matched.length} elements`} it matches answers it, though something elsewhere on this page does. ${tags
|
|
118
|
+
.slice(0, 3)
|
|
119
|
+
.map((one) => `\`${one}\``)
|
|
120
|
+
.join(', ')} ${tags.length === 1 ? 'answers' : 'answer'} it. It targets the wrong element, or expects a descendant that is not there.`,
|
|
121
|
+
origin: origin(located),
|
|
122
|
+
subject: `${selector} { ${property} }`,
|
|
123
|
+
tag: tags[0] ?? '',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return findings;
|
|
127
|
+
};
|
|
38
128
|
const origin = (located) => ({
|
|
39
129
|
cssText: located.rule.cssText,
|
|
40
130
|
index: located.index,
|
|
41
131
|
sheet: located.sheet,
|
|
42
132
|
});
|
|
43
|
-
export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
133
|
+
export const analyze = (rules, tags, from, answered = new Map(), used = new Set(), readers = new Map()) => {
|
|
44
134
|
const findings = [];
|
|
135
|
+
const answeredBy = new Map();
|
|
136
|
+
const answers = (property, tag) => {
|
|
137
|
+
const set = answeredBy.get(property) ?? new Set();
|
|
138
|
+
set.add(tag);
|
|
139
|
+
answeredBy.set(property, set);
|
|
140
|
+
};
|
|
141
|
+
const listedBy = new Map();
|
|
142
|
+
for (const [tag, element] of tags) {
|
|
143
|
+
for (const property of element.hooks.keys()) {
|
|
144
|
+
answers(property, tag);
|
|
145
|
+
listedBy.set(property, new Set([...(listedBy.get(property) ?? []), tag]));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const [tag, reads] of answered) {
|
|
149
|
+
for (const property of reads) {
|
|
150
|
+
if (tags.has(tag)) {
|
|
151
|
+
answers(property, tag);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const present = new Map();
|
|
156
|
+
const onPage = (property, answering) => {
|
|
157
|
+
let found = present.get(property);
|
|
158
|
+
if (found === undefined) {
|
|
159
|
+
found = from?.documentElement
|
|
160
|
+
? reachesAnswer(from.documentElement, property, answering, readers)
|
|
161
|
+
: false;
|
|
162
|
+
present.set(property, found);
|
|
163
|
+
}
|
|
164
|
+
return found;
|
|
165
|
+
};
|
|
45
166
|
for (const located of rules) {
|
|
46
167
|
const selector = located.rule.selectorText;
|
|
47
|
-
if (!selector
|
|
168
|
+
if (!selector) {
|
|
48
169
|
continue;
|
|
49
170
|
}
|
|
50
|
-
for (const { names, tag } of parts(selector)) {
|
|
171
|
+
for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
|
|
51
172
|
const element = tags.get(tag);
|
|
52
173
|
if (!element) {
|
|
53
174
|
findings.push({
|
|
@@ -92,6 +213,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
92
213
|
const mentioned = targets(selector).map((tag) => [tag, tags.get(tag)]);
|
|
93
214
|
const known = mentioned.filter(([, element]) => element);
|
|
94
215
|
if (known.length === 0) {
|
|
216
|
+
findings.push(...loose(located, answeredBy, from, used, onPage, listedBy, readers));
|
|
95
217
|
continue;
|
|
96
218
|
}
|
|
97
219
|
for (const property of Array.from(located.rule.style ?? [])) {
|
|
@@ -106,22 +228,27 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
106
228
|
}
|
|
107
229
|
const answerers = new Set([...tags].filter(([, element]) => element.hooks.has(property)).map(([tag]) => tag));
|
|
108
230
|
const candidates = known.flatMap(([, element]) => [...(element?.hooks.keys() ?? [])]);
|
|
109
|
-
const suggestion = nearest(property, candidates);
|
|
110
|
-
if (answerers.size === 0 && !suggestion) {
|
|
231
|
+
const suggestion = nearest(property, candidates, HOOK_DISTANCE);
|
|
232
|
+
if ((answerers.size === 0 && !suggestion) || (answerers.size === 0 && used.has(property))) {
|
|
111
233
|
continue;
|
|
112
234
|
}
|
|
113
235
|
if (answerers.size > 0 && !suggestion) {
|
|
114
236
|
if (!from) {
|
|
115
237
|
continue;
|
|
116
238
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
inherits = true;
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
239
|
+
const plain = matchable(selector);
|
|
240
|
+
if (plain === null) {
|
|
241
|
+
continue;
|
|
123
242
|
}
|
|
124
|
-
|
|
243
|
+
let matched;
|
|
244
|
+
try {
|
|
245
|
+
matched = [...(located.scope ?? from).querySelectorAll(plain)];
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (matched.length === 0 ||
|
|
251
|
+
matched.some((host) => reachesAnswer(host, property, answeredBy.get(property) ?? answerers, readers))) {
|
|
125
252
|
continue;
|
|
126
253
|
}
|
|
127
254
|
}
|
|
@@ -130,7 +257,7 @@ export const analyze = (rules, tags, from, answered = new Map()) => {
|
|
|
130
257
|
expected: suggestion,
|
|
131
258
|
kind: 'dead-hook',
|
|
132
259
|
message: suggestion
|
|
133
|
-
? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`,
|
|
260
|
+
? `\`${tag}\` does not answer \`${property}\`. It answers \`${suggestion}\`, two edits or fewer away, which is almost certainly what was meant.`
|
|
134
261
|
: `\`${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.`,
|
|
135
262
|
origin: origin(located),
|
|
136
263
|
subject: `${selector} { ${property} }`,
|
|
@@ -217,3 +344,104 @@ export const versionSkew = (manifests, seen) => seen.length === 0
|
|
|
217
344
|
subject: `${name}@${version}`,
|
|
218
345
|
tag: '',
|
|
219
346
|
}));
|
|
347
|
+
const GLOBAL = new Set([
|
|
348
|
+
'accesskey',
|
|
349
|
+
'autocapitalize',
|
|
350
|
+
'autofocus',
|
|
351
|
+
'class',
|
|
352
|
+
'contenteditable',
|
|
353
|
+
'defer-hydration',
|
|
354
|
+
'dir',
|
|
355
|
+
'draggable',
|
|
356
|
+
'enterkeyhint',
|
|
357
|
+
'exportparts',
|
|
358
|
+
'hidden',
|
|
359
|
+
'id',
|
|
360
|
+
'inert',
|
|
361
|
+
'inputmode',
|
|
362
|
+
'is',
|
|
363
|
+
'itemid',
|
|
364
|
+
'itemprop',
|
|
365
|
+
'itemref',
|
|
366
|
+
'itemscope',
|
|
367
|
+
'itemtype',
|
|
368
|
+
'lang',
|
|
369
|
+
'nonce',
|
|
370
|
+
'part',
|
|
371
|
+
'popover',
|
|
372
|
+
'role',
|
|
373
|
+
'slot',
|
|
374
|
+
'spellcheck',
|
|
375
|
+
'style',
|
|
376
|
+
'tabindex',
|
|
377
|
+
'title',
|
|
378
|
+
'translate',
|
|
379
|
+
'writingsuggestions',
|
|
380
|
+
]);
|
|
381
|
+
const PREFIXES = ['aria-', 'data-', 'on', '_ngcontent-', '_nghost-', 'ng-', 'v-', 'x-', ':', '@'];
|
|
382
|
+
const HOOK_DISTANCE = 2;
|
|
383
|
+
export const markup = (tags, from) => {
|
|
384
|
+
const found = new Map();
|
|
385
|
+
const visit = (root, depth) => {
|
|
386
|
+
if (depth > 32) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
for (const element of root.querySelectorAll('*')) {
|
|
390
|
+
const host = element.getRootNode();
|
|
391
|
+
const composed = host instanceof ShadowRoot && host.host.localName.startsWith('helix-');
|
|
392
|
+
const published = composed ? undefined : tags.get(element.localName);
|
|
393
|
+
if (published) {
|
|
394
|
+
for (const { name, value } of Array.from(element.attributes)) {
|
|
395
|
+
if (GLOBAL.has(name) ||
|
|
396
|
+
PREFIXES.some((prefix) => name.startsWith(prefix)) ||
|
|
397
|
+
published.written.has(name)) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const tag = element.localName;
|
|
401
|
+
const allows = published.attributes.get(name);
|
|
402
|
+
let raw;
|
|
403
|
+
if (allows === undefined) {
|
|
404
|
+
const suggestion = nearest(name, published.attributes.keys());
|
|
405
|
+
raw = {
|
|
406
|
+
expected: suggestion,
|
|
407
|
+
kind: 'unknown-attribute',
|
|
408
|
+
message: suggestion
|
|
409
|
+
? `\`${tag}\` takes no attribute \`${name}\`, so it does nothing. The closest it takes is \`${suggestion}\`.`
|
|
410
|
+
: `\`${tag}\` takes no attribute \`${name}\`, so it does nothing.`,
|
|
411
|
+
subject: `<${tag} ${name}>`,
|
|
412
|
+
tag,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
else if (allows && value !== '' && !allows.has(value)) {
|
|
416
|
+
const suggestion = nearest(value, allows);
|
|
417
|
+
raw = {
|
|
418
|
+
expected: suggestion,
|
|
419
|
+
kind: 'invalid-value',
|
|
420
|
+
message: `\`${tag}\`'s \`${name}\` takes ${[...allows].map((one) => `\`${one}\``).join(', ')}, so \`${value}\` falls back to the default.${suggestion ? ` The closest is \`${suggestion}\`.` : ''}`,
|
|
421
|
+
subject: `<${tag} ${name}="${value}">`,
|
|
422
|
+
tag,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (raw) {
|
|
426
|
+
const key = `${raw.kind} ${raw.subject}`;
|
|
427
|
+
const seen = found.get(key);
|
|
428
|
+
if (seen) {
|
|
429
|
+
seen.count += 1;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
found.set(key, { ...raw, count: 1 });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (element.shadowRoot) {
|
|
438
|
+
visit(element.shadowRoot, depth + 1);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
visit(from, 0);
|
|
443
|
+
return [...found.values()].map(({ count, ...raw }) => ({
|
|
444
|
+
...raw,
|
|
445
|
+
message: count > 1 ? `${raw.message} On ${count} elements.` : raw.message,
|
|
446
|
+
}));
|
|
447
|
+
};
|
package/dist/ci.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Report, Severity } from './report.js';
|
|
2
|
+
/** What the CI sink writes, and what fails the job. */
|
|
3
|
+
export interface CiOptions {
|
|
4
|
+
/** Where the runs are written, as JSON. */
|
|
5
|
+
file: string;
|
|
6
|
+
/** The lowest severity that fails the job, or `never`. `error` by default. */
|
|
7
|
+
failOn?: Severity | 'never';
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Writes the runs, and says what failed.
|
|
11
|
+
*
|
|
12
|
+
* Returns the exit code the job should end with and the summary to print;
|
|
13
|
+
* the caller prints and exits, so a script can add its own lines first.
|
|
14
|
+
*/
|
|
15
|
+
export declare const ciSink: (runs: Report[], { file, failOn }: CiOptions) => {
|
|
16
|
+
code: 0 | 1;
|
|
17
|
+
summary: string;
|
|
18
|
+
};
|
package/dist/ci.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
const RANK = { error: 3, info: 1, warn: 2 };
|
|
4
|
+
const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
5
|
+
export const ciSink = (runs, { file, failOn = 'error' }) => {
|
|
6
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
7
|
+
writeFileSync(file, `${JSON.stringify({ runs, schema: 1 }, null, 2)}\n`);
|
|
8
|
+
const faults = new Map();
|
|
9
|
+
for (const run of runs) {
|
|
10
|
+
for (const finding of run.findings) {
|
|
11
|
+
const fault = faults.get(finding.fingerprint) ?? { finding, pages: new Set() };
|
|
12
|
+
fault.pages.add(run.run.page ?? '(no page)');
|
|
13
|
+
faults.set(finding.fingerprint, fault);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const all = [...faults.values()];
|
|
17
|
+
const of = (severity) => all.filter(({ finding }) => finding.severity === severity).length;
|
|
18
|
+
const failing = failOn === 'never' ? [] : all.filter(({ finding }) => RANK[finding.severity] >= RANK[failOn]);
|
|
19
|
+
const unread = runs.reduce((total, run) => total + run.coverage.unreadable.length, 0);
|
|
20
|
+
const lines = [
|
|
21
|
+
`helix diagnostics: ${counted(runs.length, 'run')}, ${counted(of('error'), 'error')}, ${counted(of('warn'), 'warning')}, ${of('info')} info` +
|
|
22
|
+
(unread > 0 ? `, ${counted(unread, 'unreadable stylesheet')}` : '') +
|
|
23
|
+
`. Written to ${file}.`,
|
|
24
|
+
];
|
|
25
|
+
for (const { finding, pages } of failing) {
|
|
26
|
+
const where = [...pages].sort();
|
|
27
|
+
lines.push(` ${finding.severity} ${finding.kind}: ${finding.subject}`, ` ${finding.message}`, ` on ${where.slice(0, 5).join(', ')}${where.length > 5 ? `, and ${where.length - 5} more` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
return { code: failing.length > 0 ? 1 : 0, summary: lines.join('\n') };
|
|
30
|
+
};
|
package/dist/collect.d.ts
CHANGED
|
@@ -20,6 +20,11 @@ import type { Coverage } from './report.js';
|
|
|
20
20
|
export interface Located {
|
|
21
21
|
index: number;
|
|
22
22
|
rule: CSSStyleRule;
|
|
23
|
+
/**
|
|
24
|
+
* Where the rule's selector matches: the document for a page's own sheet,
|
|
25
|
+
* and the shadow root of the first host to adopt it for a component's.
|
|
26
|
+
*/
|
|
27
|
+
scope?: Document | ShadowRoot | undefined;
|
|
23
28
|
sheet: string;
|
|
24
29
|
}
|
|
25
30
|
/**
|
|
@@ -40,6 +45,17 @@ export interface Located {
|
|
|
40
45
|
* the whole truth.
|
|
41
46
|
*/
|
|
42
47
|
export type Answered = Map<string, Set<string>>;
|
|
48
|
+
/**
|
|
49
|
+
* The rules outside Helix's components that read each custom property with
|
|
50
|
+
* `var()`, by selector and the root they apply in. An element one of them
|
|
51
|
+
* matches answers the property as surely as a Helix tag does: Helix's own
|
|
52
|
+
* base sheet reads every button hook for a plain `[data-button]` link, and an
|
|
53
|
+
* example reads `--chart-series-1` in its own `::part()` rules.
|
|
54
|
+
*/
|
|
55
|
+
export type Readers = Map<string, Array<{
|
|
56
|
+
scope: Document | ShadowRoot | undefined;
|
|
57
|
+
selector: string;
|
|
58
|
+
}>>;
|
|
43
59
|
/**
|
|
44
60
|
* Every readable rule in the page, and what was missed getting them.
|
|
45
61
|
*
|
|
@@ -54,5 +70,7 @@ export type Answered = Map<string, Set<string>>;
|
|
|
54
70
|
export declare const collect: (from?: Document) => {
|
|
55
71
|
answered: Answered;
|
|
56
72
|
coverage: Coverage;
|
|
73
|
+
readers: Readers;
|
|
57
74
|
rules: Located[];
|
|
75
|
+
used: Set<string>;
|
|
58
76
|
};
|
package/dist/collect.js
CHANGED
|
@@ -23,7 +23,7 @@ const rules = (list, sheet, into, depth = 0, reads) => {
|
|
|
23
23
|
if ('selectorText' in rule) {
|
|
24
24
|
into.push({ index, rule: rule, sheet });
|
|
25
25
|
if (reads) {
|
|
26
|
-
for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[
|
|
26
|
+
for (const match of (rule.cssText ?? '').matchAll(/var\(\s*(--[\w-]+)/g)) {
|
|
27
27
|
if (match[1]) {
|
|
28
28
|
reads.add(match[1]);
|
|
29
29
|
}
|
|
@@ -80,20 +80,38 @@ export const collect = (from = document) => {
|
|
|
80
80
|
const seen = new Set();
|
|
81
81
|
const labels = new Map();
|
|
82
82
|
const answered = new Map();
|
|
83
|
+
const used = new Set();
|
|
84
|
+
const readers = new Map();
|
|
83
85
|
let readable = 0;
|
|
84
86
|
const take = (sheet, host) => {
|
|
85
87
|
if (!sheet || seen.has(sheet)) {
|
|
86
88
|
return;
|
|
87
89
|
}
|
|
88
90
|
seen.add(sheet);
|
|
89
|
-
let reads;
|
|
91
|
+
let reads = used;
|
|
90
92
|
if (host) {
|
|
91
93
|
reads = answered.get(host.localName) ?? new Set();
|
|
92
94
|
answered.set(host.localName, reads);
|
|
93
95
|
}
|
|
96
|
+
const before = located.length;
|
|
94
97
|
if (read(sheet, located, unreadable, labels, 0, host, reads)) {
|
|
95
98
|
readable += 1;
|
|
96
99
|
}
|
|
100
|
+
const scope = host ? (host.shadowRoot ?? undefined) : from;
|
|
101
|
+
const ours = !host?.localName.startsWith('helix-');
|
|
102
|
+
for (let at = before; at < located.length; at += 1) {
|
|
103
|
+
const one = located[at];
|
|
104
|
+
one.scope = scope;
|
|
105
|
+
if (ours && one.rule.selectorText) {
|
|
106
|
+
for (const match of (one.rule.cssText ?? '').matchAll(/var\(\s*(--[\w-]+)/g)) {
|
|
107
|
+
const property = match[1];
|
|
108
|
+
readers.set(property, [
|
|
109
|
+
...(readers.get(property) ?? []),
|
|
110
|
+
{ scope, selector: one.rule.selectorText },
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
97
115
|
};
|
|
98
116
|
for (const sheet of Array.from(from.styleSheets)) {
|
|
99
117
|
take(sheet);
|
|
@@ -114,9 +132,18 @@ export const collect = (from = document) => {
|
|
|
114
132
|
take(owner.sheet, root.host);
|
|
115
133
|
}
|
|
116
134
|
}
|
|
135
|
+
for (const [tag, reads] of answered) {
|
|
136
|
+
if (!tag.startsWith('helix-')) {
|
|
137
|
+
for (const property of reads) {
|
|
138
|
+
used.add(property);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
117
142
|
return {
|
|
118
143
|
answered,
|
|
119
144
|
coverage: { read: readable, roots: shadows.length, sheets: seen.size, unreadable },
|
|
145
|
+
readers,
|
|
120
146
|
rules: located,
|
|
147
|
+
used,
|
|
121
148
|
};
|
|
122
149
|
};
|
package/dist/identity.js
CHANGED
|
@@ -2,16 +2,20 @@ const CATEGORY = {
|
|
|
2
2
|
'dead-hook': 'styling',
|
|
3
3
|
'dead-part': 'styling',
|
|
4
4
|
'duplicate-registration': 'integrity',
|
|
5
|
+
'invalid-value': 'markup',
|
|
5
6
|
'manifest-skew': 'integrity',
|
|
6
7
|
'unforwarded-part': 'styling',
|
|
8
|
+
'unknown-attribute': 'markup',
|
|
7
9
|
'unknown-tag': 'integrity',
|
|
8
10
|
};
|
|
9
11
|
const OWNER = {
|
|
10
12
|
'dead-hook': 'consumer',
|
|
11
13
|
'dead-part': 'consumer',
|
|
12
14
|
'duplicate-registration': 'consumer',
|
|
15
|
+
'invalid-value': 'consumer',
|
|
13
16
|
'manifest-skew': 'consumer',
|
|
14
17
|
'unforwarded-part': 'helix',
|
|
18
|
+
'unknown-attribute': 'consumer',
|
|
15
19
|
'unknown-tag': 'consumer',
|
|
16
20
|
};
|
|
17
21
|
export const normalize = (subject) => subject
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export { CATEGORIES, KINDS } from './report.js';
|
|
|
7
7
|
export { SHIPPED_RULES } from './rules.js';
|
|
8
8
|
export { watch } from './usage.js';
|
|
9
9
|
export { VERSION } from './version.js';
|
|
10
|
-
import { analyze, registrations, skew, versionSkew } from './analyze.js';
|
|
10
|
+
import { analyze, markup, registrations, skew, versionSkew } from './analyze.js';
|
|
11
11
|
import { collect } from './collect.js';
|
|
12
12
|
import { pagePattern, resolveConfig } from './config.js';
|
|
13
13
|
import { identify } from './identity.js';
|
|
@@ -27,7 +27,7 @@ export const diagnose = (options) => {
|
|
|
27
27
|
const resolved = resolveConfig(file, call);
|
|
28
28
|
const { detail } = resolved.configuration;
|
|
29
29
|
const document_ = from ?? globalThis.document;
|
|
30
|
-
const { answered, coverage, rules: located } = collect(document_);
|
|
30
|
+
const { answered, coverage, readers, rules: located, used } = collect(document_);
|
|
31
31
|
const tags = index(tiers);
|
|
32
32
|
const counted = watcher ? watcher.counts() : walk(document_);
|
|
33
33
|
const seen = loadedVersions();
|
|
@@ -38,7 +38,8 @@ export const diagnose = (options) => {
|
|
|
38
38
|
const judged = skewed.findings.length > 0
|
|
39
39
|
? { findings: [], silenced: [] }
|
|
40
40
|
: applyRules([
|
|
41
|
-
...analyze(located, tags, document_, answered),
|
|
41
|
+
...analyze(located, tags, document_, answered, used, readers),
|
|
42
|
+
...markup(tags, document_),
|
|
42
43
|
...registrations(document_, duplicates),
|
|
43
44
|
].map((one) => identify(one, tags)), file?.rules, call.rules);
|
|
44
45
|
const path = page ?? globalThis.location?.pathname;
|
package/dist/manifest.d.ts
CHANGED
|
@@ -29,6 +29,18 @@ export interface ManifestSource {
|
|
|
29
29
|
/** Where the module is, which names the family: `src/button/root/button-root.ts`. */
|
|
30
30
|
path?: string;
|
|
31
31
|
declarations?: Array<{
|
|
32
|
+
attributes?: Array<{
|
|
33
|
+
name: string;
|
|
34
|
+
type?: {
|
|
35
|
+
text?: string;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
38
|
+
members?: Array<{
|
|
39
|
+
kind?: string;
|
|
40
|
+
name: string;
|
|
41
|
+
readonly?: boolean;
|
|
42
|
+
static?: boolean;
|
|
43
|
+
}>;
|
|
32
44
|
cssParts?: Array<{
|
|
33
45
|
name: string;
|
|
34
46
|
}>;
|
|
@@ -52,9 +64,20 @@ export interface Tier {
|
|
|
52
64
|
* scope, and `analyze.ts` needs both in the same file.
|
|
53
65
|
*/
|
|
54
66
|
export interface Published {
|
|
67
|
+
/**
|
|
68
|
+
* Each attribute the tag takes, with the values it allows where its type
|
|
69
|
+
* is a union of string literals, and `null` where it takes any value.
|
|
70
|
+
*/
|
|
71
|
+
attributes: Map<string, Set<string> | null>;
|
|
55
72
|
/** The family, from the module's path: `button` for `src/button/root/button-root.ts`. */
|
|
56
73
|
family?: string | undefined;
|
|
57
74
|
hooks: Map<string, Set<string>>;
|
|
75
|
+
/**
|
|
76
|
+
* The attributes the element writes on itself: one named for a read-only
|
|
77
|
+
* member, as `helix-notifications` writes `unread`. Output, so never a
|
|
78
|
+
* finding when the page shows it.
|
|
79
|
+
*/
|
|
80
|
+
written: Set<string>;
|
|
58
81
|
parts: Map<string, Set<string>>;
|
|
59
82
|
}
|
|
60
83
|
/**
|
|
@@ -80,4 +103,4 @@ export declare const versionsOf: (tiers: Tier[]) => Record<string, string>;
|
|
|
80
103
|
* `--button-background-color` however close they sort, and a confident wrong
|
|
81
104
|
* suggestion costs more than none.
|
|
82
105
|
*/
|
|
83
|
-
export declare const nearest: (wrong: string, candidates: Iterable<string
|
|
106
|
+
export declare const nearest: (wrong: string, candidates: Iterable<string>, ceiling?: number) => string | undefined;
|
package/dist/manifest.js
CHANGED
|
@@ -10,9 +10,24 @@ export const index = (tiers) => {
|
|
|
10
10
|
}
|
|
11
11
|
let element = tags.get(tag);
|
|
12
12
|
if (!element) {
|
|
13
|
-
element = {
|
|
13
|
+
element = {
|
|
14
|
+
attributes: new Map(),
|
|
15
|
+
family,
|
|
16
|
+
hooks: new Map(),
|
|
17
|
+
parts: new Map(),
|
|
18
|
+
written: new Set(),
|
|
19
|
+
};
|
|
14
20
|
tags.set(tag, element);
|
|
15
21
|
}
|
|
22
|
+
for (const member of declaration.members ?? []) {
|
|
23
|
+
if (member.readonly && !member.static && member.kind === 'field') {
|
|
24
|
+
element.written.add(member.name);
|
|
25
|
+
element.written.add(member.name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const attribute of declaration.attributes ?? []) {
|
|
29
|
+
element.attributes.set(attribute.name, allowed(attribute.type?.text));
|
|
30
|
+
}
|
|
16
31
|
for (const [field, into] of [
|
|
17
32
|
[declaration.cssParts, element.parts],
|
|
18
33
|
[declaration.cssProperties, element.hooks],
|
|
@@ -28,6 +43,17 @@ export const index = (tiers) => {
|
|
|
28
43
|
}
|
|
29
44
|
return tags;
|
|
30
45
|
};
|
|
46
|
+
const allowed = (type) => {
|
|
47
|
+
if (!type) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const literals = [...type.matchAll(/'([^']*)'/g)].map((match) => match[1]);
|
|
51
|
+
const rest = type
|
|
52
|
+
.replace(/'[^']*'/g, '')
|
|
53
|
+
.replace(/\b(undefined|null)\b/g, '')
|
|
54
|
+
.replace(/[|\s]/g, '');
|
|
55
|
+
return literals.length > 0 && rest === '' ? new Set(literals) : null;
|
|
56
|
+
};
|
|
31
57
|
export const versionsOf = (tiers) => {
|
|
32
58
|
const versions = {};
|
|
33
59
|
for (const tier of tiers) {
|
|
@@ -38,9 +64,9 @@ export const versionsOf = (tiers) => {
|
|
|
38
64
|
}
|
|
39
65
|
return versions;
|
|
40
66
|
};
|
|
41
|
-
export const nearest = (wrong, candidates) => {
|
|
67
|
+
export const nearest = (wrong, candidates, ceiling = Math.floor(wrong.length / 3)) => {
|
|
42
68
|
let best;
|
|
43
|
-
let distance = Math.floor(wrong.length / 3);
|
|
69
|
+
let distance = Math.min(ceiling, Math.floor(wrong.length / 3));
|
|
44
70
|
for (const candidate of candidates) {
|
|
45
71
|
const found = within(wrong, candidate, distance);
|
|
46
72
|
if (found !== undefined && found < distance + 1) {
|
package/dist/report.d.ts
CHANGED
|
@@ -87,7 +87,7 @@ export interface Finding {
|
|
|
87
87
|
tag: string;
|
|
88
88
|
}
|
|
89
89
|
/** Every fault class, stable across versions. */
|
|
90
|
-
export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'manifest-skew', 'unforwarded-part', 'unknown-tag'];
|
|
90
|
+
export declare const KINDS: readonly ['dead-hook', 'dead-part', 'duplicate-registration', 'invalid-value', 'manifest-skew', 'unforwarded-part', 'unknown-attribute', 'unknown-tag'];
|
|
91
91
|
/** A fault class. */
|
|
92
92
|
export type Kind = (typeof KINDS)[number];
|
|
93
93
|
/** How serious a finding is. */
|
package/dist/report.js
CHANGED
package/dist/rules.js
CHANGED
|
@@ -4,8 +4,10 @@ export const SHIPPED_RULES = {
|
|
|
4
4
|
'dead-hook': 'error',
|
|
5
5
|
'dead-part': 'error',
|
|
6
6
|
'duplicate-registration': 'error',
|
|
7
|
+
'invalid-value': 'error',
|
|
7
8
|
'manifest-skew': 'error',
|
|
8
9
|
'unforwarded-part': 'warn',
|
|
10
|
+
'unknown-attribute': 'error',
|
|
9
11
|
'unknown-tag': 'error',
|
|
10
12
|
};
|
|
11
13
|
const SEVERITIES = new Set(['error', 'info', 'off', 'warn']);
|
package/dist/usage.js
CHANGED
|
@@ -114,32 +114,49 @@ export const styled = (rules, tags, answered, values = true) => {
|
|
|
114
114
|
hookValues: {},
|
|
115
115
|
parts: {},
|
|
116
116
|
};
|
|
117
|
+
const listing = new Map();
|
|
118
|
+
for (const [tag, element] of tags) {
|
|
119
|
+
for (const property of element.hooks.keys()) {
|
|
120
|
+
listing.set(property, [...(listing.get(property) ?? []), tag]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const count = (located, tag, property) => {
|
|
124
|
+
bump(under(counted.hooks, tag, () => ({})), property);
|
|
125
|
+
if (!values) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const value = (located.rule.style?.getPropertyValue?.(property) ?? '').trim().slice(0, 200);
|
|
129
|
+
if (value !== '') {
|
|
130
|
+
bump(under(under(counted.hookValues, tag, () => ({})), property, () => ({})), value);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
117
133
|
for (const located of rules) {
|
|
118
134
|
const selector = located.rule.selectorText;
|
|
119
|
-
if (!selector
|
|
135
|
+
if (!selector) {
|
|
120
136
|
continue;
|
|
121
137
|
}
|
|
122
|
-
for (const { names, tag } of parts(selector)) {
|
|
138
|
+
for (const { names, tag } of selector.includes('helix-') ? parts(selector) : []) {
|
|
123
139
|
for (const name of names) {
|
|
124
140
|
if (tags.get(tag)?.parts.has(name)) {
|
|
125
141
|
bump(under(counted.parts, tag, () => ({})), name);
|
|
126
142
|
}
|
|
127
143
|
}
|
|
128
144
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (value !== '') {
|
|
139
|
-
bump(under(under(counted.hookValues, tag, () => ({})), property, () => ({})), value);
|
|
140
|
-
}
|
|
145
|
+
const named = targets(selector).filter((tag) => tags.has(tag));
|
|
146
|
+
for (const property of Array.from(located.rule.style ?? [])) {
|
|
147
|
+
if (!property.startsWith('--')) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (named.length > 0) {
|
|
151
|
+
for (const tag of named) {
|
|
152
|
+
if (tags.get(tag)?.hooks.has(property) || answered.get(tag)?.has(property)) {
|
|
153
|
+
count(located, tag, property);
|
|
141
154
|
}
|
|
142
155
|
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const tag of listing.get(property) ?? []) {
|
|
159
|
+
count(located, tag, property);
|
|
143
160
|
}
|
|
144
161
|
}
|
|
145
162
|
}
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '0.
|
|
1
|
+
export const VERSION = '0.138.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.
|
|
4
|
+
"version": "0.138.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ross Sheppard <ross.sheppard@fusion.dev>",
|
|
7
7
|
"repository": {
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
".": {
|
|
17
17
|
"types": "./dist/index.d.ts",
|
|
18
18
|
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./ci": {
|
|
21
|
+
"types": "./dist/ci.d.ts",
|
|
22
|
+
"default": "./dist/ci.js"
|
|
19
23
|
}
|
|
20
24
|
},
|
|
21
25
|
"files": [
|
|
@@ -29,7 +33,7 @@
|
|
|
29
33
|
},
|
|
30
34
|
"bugs": "https://github.com/project-fusion/fusion/issues",
|
|
31
35
|
"devDependencies": {
|
|
32
|
-
"@fusion.dev/helix-testing": "0.
|
|
36
|
+
"@fusion.dev/helix-testing": "0.138.0",
|
|
33
37
|
"@types/bun": "^1.3.0"
|
|
34
38
|
},
|
|
35
39
|
"homepage": "https://fusion.dev",
|