@fusion.dev/helix-diagnostics 0.136.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 +100 -22
- package/dist/analyze.d.ts +19 -2
- package/dist/analyze.js +260 -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/config.d.ts +71 -0
- package/dist/config.js +126 -0
- package/dist/console.d.ts +8 -1
- package/dist/console.js +37 -15
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +46 -0
- package/dist/index.d.ts +29 -19
- package/dist/index.js +61 -17
- package/dist/manifest.d.ts +39 -1
- package/dist/manifest.js +40 -3
- package/dist/report.d.ts +132 -10
- package/dist/report.js +11 -0
- package/dist/rules.d.ts +7 -7
- package/dist/rules.js +16 -4
- package/dist/usage.d.ts +8 -2
- package/dist/usage.js +42 -9
- package/dist/version.d.ts +9 -0
- package/dist/version.js +1 -0
- package/package.json +6 -2
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/config.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type Category, type Configuration, type Layer, type Report } from './report.js';
|
|
2
|
+
import { type Rules } from './rules.js';
|
|
3
|
+
/** Everything a project can set. Every field is optional; see `DEFAULTS`. */
|
|
4
|
+
export interface Config {
|
|
5
|
+
/** The lowest level each category prints at in the console, or `off`. */
|
|
6
|
+
console?: Partial<Configuration['console']>;
|
|
7
|
+
/** How much detail goes with what is sent. */
|
|
8
|
+
detail?: Partial<Configuration['detail']>;
|
|
9
|
+
/** Where sending happens. Production takes `{ sample }`, from 0 to 1. */
|
|
10
|
+
environments?: Partial<Configuration['environments']>;
|
|
11
|
+
/**
|
|
12
|
+
* The project: its key, which opts it in to sending, and its own name and
|
|
13
|
+
* labels. `tags` are free, and `fusion` marks Fusion's own projects.
|
|
14
|
+
*/
|
|
15
|
+
project?: {
|
|
16
|
+
key?: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
tags?: string[];
|
|
19
|
+
};
|
|
20
|
+
/** Severities by kind; see `Rules`. */
|
|
21
|
+
rules?: Rules;
|
|
22
|
+
/** Each category, sent or withheld. Nothing is sent without a key. */
|
|
23
|
+
send?: Partial<Record<Category, boolean>>;
|
|
24
|
+
}
|
|
25
|
+
/** The shipped defaults. */
|
|
26
|
+
export declare const DEFAULTS: {
|
|
27
|
+
readonly console: {
|
|
28
|
+
readonly coverage: 'info';
|
|
29
|
+
readonly findings: 'info';
|
|
30
|
+
readonly usage: 'debug';
|
|
31
|
+
};
|
|
32
|
+
readonly detail: {
|
|
33
|
+
readonly evidence: 'full';
|
|
34
|
+
readonly hookValues: true;
|
|
35
|
+
readonly pages: 'patterns';
|
|
36
|
+
};
|
|
37
|
+
readonly environments: {
|
|
38
|
+
readonly ci: true;
|
|
39
|
+
readonly development: true;
|
|
40
|
+
readonly production: false;
|
|
41
|
+
};
|
|
42
|
+
readonly send: {
|
|
43
|
+
readonly assist: true;
|
|
44
|
+
readonly configuration: true;
|
|
45
|
+
readonly coverage: true;
|
|
46
|
+
readonly findings: true;
|
|
47
|
+
readonly performance: false;
|
|
48
|
+
readonly runtime: true;
|
|
49
|
+
readonly usage: true;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
/** Throws on a setting that names nothing, or a value it cannot take. */
|
|
53
|
+
export declare const checkConfig: (config: Config | undefined, layer: Exclude<Layer, 'shipped'>) => void;
|
|
54
|
+
/** The settings in effect, from the defaults, the file, and the call. */
|
|
55
|
+
export interface Resolved {
|
|
56
|
+
configuration: Omit<Configuration, 'overrides' | 'silenced'>;
|
|
57
|
+
project: Report['project'];
|
|
58
|
+
/** Each category's sending, and which place decided it. */
|
|
59
|
+
send: Record<Category, {
|
|
60
|
+
decidedBy: Layer;
|
|
61
|
+
on: boolean;
|
|
62
|
+
}>;
|
|
63
|
+
}
|
|
64
|
+
/** The three places, merged, with a later one winning field by field. */
|
|
65
|
+
export declare const resolveConfig: (file: Config | undefined, call: Config | undefined) => Resolved;
|
|
66
|
+
/**
|
|
67
|
+
* A page's path as a pattern: each segment that names one record, a number,
|
|
68
|
+
* a UUID, or a long run of hex or mixed letters and digits, becomes `:id`.
|
|
69
|
+
* `/orders/1042/items/9f8e7d6c5b4a` reads `/orders/:id/items/:id`.
|
|
70
|
+
*/
|
|
71
|
+
export declare const pagePattern: (path: string) => string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { nearest } from './manifest.js';
|
|
2
|
+
import { CATEGORIES } from './report.js';
|
|
3
|
+
import { checkRules } from './rules.js';
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
console: { coverage: 'info', findings: 'info', usage: 'debug' },
|
|
6
|
+
detail: { evidence: 'full', hookValues: true, pages: 'patterns' },
|
|
7
|
+
environments: { ci: true, development: true, production: false },
|
|
8
|
+
send: {
|
|
9
|
+
assist: true,
|
|
10
|
+
configuration: true,
|
|
11
|
+
coverage: true,
|
|
12
|
+
findings: true,
|
|
13
|
+
performance: false,
|
|
14
|
+
runtime: true,
|
|
15
|
+
usage: true,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const ALLOWED = {
|
|
19
|
+
console: {
|
|
20
|
+
coverage: ['info', 'off'],
|
|
21
|
+
findings: ['error', 'warn', 'info', 'off'],
|
|
22
|
+
usage: ['debug', 'off'],
|
|
23
|
+
},
|
|
24
|
+
detail: {
|
|
25
|
+
evidence: ['full', 'selector', 'none'],
|
|
26
|
+
hookValues: [true, false],
|
|
27
|
+
pages: ['patterns', 'full', 'none'],
|
|
28
|
+
},
|
|
29
|
+
environments: { ci: [true, false], development: [true, false], production: 'sample' },
|
|
30
|
+
project: { key: 'string', name: 'string', tags: 'tags' },
|
|
31
|
+
send: Object.fromEntries(CATEGORIES.map((category) => [category, [true, false]])),
|
|
32
|
+
};
|
|
33
|
+
const shown = (value) => typeof value === 'string' ? `\`${value}\`` : String(value);
|
|
34
|
+
export const checkConfig = (config, layer) => {
|
|
35
|
+
if (config === undefined) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
|
39
|
+
throw new Error(`The ${layer} is not a configuration object.`);
|
|
40
|
+
}
|
|
41
|
+
for (const [section, value] of Object.entries(config)) {
|
|
42
|
+
if (section === 'rules') {
|
|
43
|
+
checkRules(value, layer);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const allowed = ALLOWED[section];
|
|
47
|
+
if (!allowed) {
|
|
48
|
+
const close = nearest(section, [...Object.keys(ALLOWED), 'rules']);
|
|
49
|
+
throw new Error(`The ${layer} sets \`${section}\`, which is no setting.` +
|
|
50
|
+
(close ? ` The closest is \`${close}\`.` : ''));
|
|
51
|
+
}
|
|
52
|
+
for (const [name, one] of Object.entries(value)) {
|
|
53
|
+
const takes = allowed[name];
|
|
54
|
+
if (takes === undefined) {
|
|
55
|
+
const close = nearest(name, Object.keys(allowed));
|
|
56
|
+
throw new Error(`The ${layer} sets \`${section}.${name}\`, which is no setting.` +
|
|
57
|
+
(close ? ` The closest is \`${section}.${close}\`.` : ''));
|
|
58
|
+
}
|
|
59
|
+
const fits = takes === 'string'
|
|
60
|
+
? typeof one === 'string'
|
|
61
|
+
: takes === 'tags'
|
|
62
|
+
? Array.isArray(one) && one.every((tag) => typeof tag === 'string')
|
|
63
|
+
: takes === 'sample'
|
|
64
|
+
? one === false ||
|
|
65
|
+
(typeof one === 'object' &&
|
|
66
|
+
one !== null &&
|
|
67
|
+
typeof one.sample === 'number' &&
|
|
68
|
+
one.sample >= 0 &&
|
|
69
|
+
one.sample <= 1)
|
|
70
|
+
: takes.includes(one);
|
|
71
|
+
if (!fits) {
|
|
72
|
+
const expected = takes === 'string'
|
|
73
|
+
? 'a string'
|
|
74
|
+
: takes === 'tags'
|
|
75
|
+
? 'a list of strings'
|
|
76
|
+
: takes === 'sample'
|
|
77
|
+
? '`false`, or `{ sample }` from 0 to 1'
|
|
78
|
+
: takes.map(shown).join(', ');
|
|
79
|
+
throw new Error(`The ${layer} sets \`${section}.${name}\` to \`${JSON.stringify(one)}\`. It takes ${expected}.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
export const resolveConfig = (file, call) => {
|
|
85
|
+
checkConfig(file, 'file');
|
|
86
|
+
checkConfig(call, 'argument');
|
|
87
|
+
const merged = (section, base) => ({
|
|
88
|
+
...base,
|
|
89
|
+
...(file?.[section] ?? {}),
|
|
90
|
+
...(call?.[section] ?? {}),
|
|
91
|
+
});
|
|
92
|
+
const send = Object.fromEntries(CATEGORIES.map((category) => {
|
|
93
|
+
const fromCall = call?.send?.[category];
|
|
94
|
+
const fromFile = file?.send?.[category];
|
|
95
|
+
return [
|
|
96
|
+
category,
|
|
97
|
+
fromCall !== undefined
|
|
98
|
+
? { decidedBy: 'argument', on: fromCall }
|
|
99
|
+
: fromFile !== undefined
|
|
100
|
+
? { decidedBy: 'file', on: fromFile }
|
|
101
|
+
: { decidedBy: 'shipped', on: DEFAULTS.send[category] },
|
|
102
|
+
];
|
|
103
|
+
}));
|
|
104
|
+
const project = { ...(file?.project ?? {}), ...(call?.project ?? {}) };
|
|
105
|
+
return {
|
|
106
|
+
configuration: {
|
|
107
|
+
console: merged('console', { ...DEFAULTS.console }),
|
|
108
|
+
detail: merged('detail', { ...DEFAULTS.detail }),
|
|
109
|
+
environments: merged('environments', { ...DEFAULTS.environments }),
|
|
110
|
+
},
|
|
111
|
+
project: {
|
|
112
|
+
...(project.name === undefined ? {} : { name: project.name }),
|
|
113
|
+
tags: project.tags ?? [],
|
|
114
|
+
},
|
|
115
|
+
send,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
export const pagePattern = (path) => path
|
|
119
|
+
.split('/')
|
|
120
|
+
.map((segment) => /^\d+$/.test(segment) ||
|
|
121
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment) ||
|
|
122
|
+
/^[0-9a-f]{8,}$/i.test(segment) ||
|
|
123
|
+
(segment.length >= 12 && /\d/.test(segment) && /^[A-Za-z0-9_-]+$/.test(segment))
|
|
124
|
+
? ':id'
|
|
125
|
+
: segment)
|
|
126
|
+
.join('/');
|
package/dist/console.d.ts
CHANGED
|
@@ -27,5 +27,12 @@
|
|
|
27
27
|
import type { Report } from './report.js';
|
|
28
28
|
/** The console methods this sink calls. A test hands in its own. */
|
|
29
29
|
export type ConsoleSink = Pick<Console, 'debug' | 'error' | 'group' | 'groupCollapsed' | 'groupEnd' | 'info' | 'warn'>;
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Writes a report and hands it back, so a caller can chain a second sink.
|
|
32
|
+
*
|
|
33
|
+
* **Each category prints as the project's `console` setting says**: findings
|
|
34
|
+
* at or above a severity, coverage and usage on or off. The heading counts
|
|
35
|
+
* every finding whatever is printed under it, and the ones the rules turned
|
|
36
|
+
* off, so a quiet console never reads as a clean page.
|
|
37
|
+
*/
|
|
31
38
|
export declare const report: (result: Report, to?: ConsoleSink) => Report;
|
package/dist/console.js
CHANGED
|
@@ -7,41 +7,63 @@ const entry = (finding) => {
|
|
|
7
7
|
return lines.join('\n');
|
|
8
8
|
};
|
|
9
9
|
const counted = (count, noun) => `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
10
|
+
const RANK = { error: 3, info: 1, warn: 2 };
|
|
10
11
|
export const report = (result, to = console) => {
|
|
11
|
-
const { coverage, findings } = result;
|
|
12
|
+
const { configuration, coverage, findings } = result;
|
|
13
|
+
const levels = configuration.console;
|
|
12
14
|
const bySeverity = (severity) => findings.filter((finding) => finding.severity === severity).length;
|
|
13
15
|
const errors = bySeverity('error');
|
|
14
16
|
const warnings = bySeverity('warn');
|
|
17
|
+
const off = configuration.silenced.reduce((total, one) => total + one.count, 0);
|
|
15
18
|
const missed = coverage.sheets - coverage.read;
|
|
16
|
-
const
|
|
19
|
+
const counts = [
|
|
20
|
+
counted(errors, 'error'),
|
|
21
|
+
counted(warnings, 'warning'),
|
|
22
|
+
`${bySeverity('info')} info`,
|
|
23
|
+
...(off > 0 ? [`${off} turned off`] : []),
|
|
24
|
+
];
|
|
25
|
+
const heading = findings.length === 0 && off === 0
|
|
17
26
|
? 'helix diagnostics: no findings'
|
|
18
|
-
: `helix diagnostics: ${
|
|
27
|
+
: `helix diagnostics: ${counts.join(', ')}`;
|
|
19
28
|
if (errors + warnings > 0 || missed > 0) {
|
|
20
29
|
to.group(heading);
|
|
21
30
|
}
|
|
22
31
|
else {
|
|
23
32
|
to.groupCollapsed(heading);
|
|
24
33
|
}
|
|
25
|
-
|
|
34
|
+
if (levels.coverage !== 'off') {
|
|
35
|
+
to.info(`read ${coverage.read} of ${counted(coverage.sheets, 'stylesheet')} across ${counted(coverage.roots, 'shadow root')}` +
|
|
36
|
+
(result.run.page ? ` on ${result.run.page}` : ''));
|
|
37
|
+
}
|
|
26
38
|
if (missed > 0) {
|
|
27
39
|
to.warn([
|
|
28
40
|
`${counted(missed, 'stylesheet')} could not be read, so ${missed === 1 ? 'it was' : 'they were'} not checked:`,
|
|
29
41
|
...coverage.unreadable.map((sheet) => `${sheet.sheet} (${sheet.reason})`),
|
|
30
42
|
].join('\n'));
|
|
31
43
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
if (off > 0 && levels.coverage !== 'off') {
|
|
45
|
+
to.info(configuration.silenced
|
|
46
|
+
.map((one) => `${counted(one.count, one.kind)} turned off by the ${one.layer}`)
|
|
47
|
+
.join('\n'));
|
|
48
|
+
}
|
|
49
|
+
if (levels.findings !== 'off') {
|
|
50
|
+
const floor = RANK[levels.findings];
|
|
51
|
+
for (const finding of findings) {
|
|
52
|
+
if (RANK[finding.severity] >= floor) {
|
|
53
|
+
to[finding.severity](entry(finding));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
35
56
|
}
|
|
36
|
-
|
|
37
|
-
|
|
57
|
+
if (levels.usage !== 'off') {
|
|
58
|
+
const { counted: how, hooks, parts, tags, unlisted } = result.usage;
|
|
59
|
+
const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
|
|
60
|
+
const elements = Object.values(tags).reduce((total, one) => total + one, 0);
|
|
61
|
+
const others = Object.keys(unlisted).length;
|
|
62
|
+
const versions = result.versions.helix;
|
|
63
|
+
to.debug(`usage (${how}): ${counted(Object.keys(tags).length, 'tag')} in ${counted(elements, 'element')}, ${counted(sum(parts), 'part')} styled, ${counted(sum(hooks), 'hook')} set` +
|
|
64
|
+
(others > 0 ? `, and ${counted(others, 'helix- tag')} no manifest lists` : '') +
|
|
65
|
+
(versions.length > 0 ? `; Helix ${versions.join(' and ')}` : ''));
|
|
38
66
|
}
|
|
39
|
-
const { counted: how, hooks, parts, tags, unlisted } = result.usage;
|
|
40
|
-
const sum = (counts) => Object.values(counts).reduce((total, one) => total + Object.keys(one).length, 0);
|
|
41
|
-
const elements = Object.values(tags).reduce((total, one) => total + one, 0);
|
|
42
|
-
const others = Object.keys(unlisted).length;
|
|
43
|
-
to.debug(`usage (${how}): ${counted(Object.keys(tags).length, 'tag')} in ${counted(elements, 'element')}, ${counted(sum(parts), 'part')} styled, ${counted(sum(hooks), 'hook')} set` +
|
|
44
|
-
(others > 0 ? `, and ${counted(others, 'helix- tag')} no manifest lists` : ''));
|
|
45
67
|
to.groupEnd();
|
|
46
68
|
return result;
|
|
47
69
|
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a finding is about, who fixes it, and which finding it is from one run
|
|
3
|
+
* to the next.
|
|
4
|
+
*
|
|
5
|
+
* **The fingerprint is what turns findings into issues.** Without it, a
|
|
6
|
+
* dashboard can count findings per run and cannot say whether the one in this
|
|
7
|
+
* run is the one from last week, fixed, or back again.
|
|
8
|
+
*/
|
|
9
|
+
import type { Published } from './manifest.js';
|
|
10
|
+
import type { Identified, Raw } from './report.js';
|
|
11
|
+
/**
|
|
12
|
+
* A selector with what a build changes taken out, so the same rule in the
|
|
13
|
+
* next build reads the same.
|
|
14
|
+
*
|
|
15
|
+
* **Hashed class names**, which CSS Modules write and every bundler hashes its
|
|
16
|
+
* own way:
|
|
17
|
+
*
|
|
18
|
+
* - Next.js: `trace-graph-module__lFhy0q__traceFrame`, to `trace-graph-module__traceFrame`.
|
|
19
|
+
* - webpack's default: `card__title___3xYz1`, to `card__title`.
|
|
20
|
+
* - Vite's default: `_title_1a2b3_7`, to `_title`.
|
|
21
|
+
*
|
|
22
|
+
* webpack's is taken out first: its `___` would otherwise read as the end of
|
|
23
|
+
* a Next.js hash. And spacing, which a minifier rewrites.
|
|
24
|
+
*/
|
|
25
|
+
export declare const normalize: (subject: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* A finding's identity across runs: its kind, its tag, and its normalized
|
|
28
|
+
* subject. **The stylesheet is left out**, since a bundler puts a content
|
|
29
|
+
* hash in a file's name, so the same mistake in two stylesheets is one issue
|
|
30
|
+
* seen twice, which is how a reader thinks of it.
|
|
31
|
+
*/
|
|
32
|
+
export declare const fingerprintOf: (finding: Pick<Raw, 'kind' | 'subject' | 'tag'>) => string;
|
|
33
|
+
/** A finding with its category, owner, family, and fingerprint. */
|
|
34
|
+
export declare const identify: (finding: Raw, tags: Map<string, Published>) => Identified;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const CATEGORY = {
|
|
2
|
+
'dead-hook': 'styling',
|
|
3
|
+
'dead-part': 'styling',
|
|
4
|
+
'duplicate-registration': 'integrity',
|
|
5
|
+
'invalid-value': 'markup',
|
|
6
|
+
'manifest-skew': 'integrity',
|
|
7
|
+
'unforwarded-part': 'styling',
|
|
8
|
+
'unknown-attribute': 'markup',
|
|
9
|
+
'unknown-tag': 'integrity',
|
|
10
|
+
};
|
|
11
|
+
const OWNER = {
|
|
12
|
+
'dead-hook': 'consumer',
|
|
13
|
+
'dead-part': 'consumer',
|
|
14
|
+
'duplicate-registration': 'consumer',
|
|
15
|
+
'invalid-value': 'consumer',
|
|
16
|
+
'manifest-skew': 'consumer',
|
|
17
|
+
'unforwarded-part': 'helix',
|
|
18
|
+
'unknown-attribute': 'consumer',
|
|
19
|
+
'unknown-tag': 'consumer',
|
|
20
|
+
};
|
|
21
|
+
export const normalize = (subject) => subject
|
|
22
|
+
.replace(/___[A-Za-z0-9_-]{5}\b/g, '')
|
|
23
|
+
.replace(/__[A-Za-z0-9_-]{6}__/g, '__')
|
|
24
|
+
.replace(/\b(_[A-Za-z][A-Za-z0-9-]*)_[a-z0-9]{5}_\d+\b/g, '$1')
|
|
25
|
+
.replace(/\s+/g, ' ')
|
|
26
|
+
.trim();
|
|
27
|
+
const hash = (text) => {
|
|
28
|
+
let one = 0xdeadbeef;
|
|
29
|
+
let two = 0x41c6ce57;
|
|
30
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
31
|
+
const code = text.charCodeAt(at);
|
|
32
|
+
one = Math.imul(one ^ code, 2654435761);
|
|
33
|
+
two = Math.imul(two ^ code, 1597334677);
|
|
34
|
+
}
|
|
35
|
+
one = Math.imul(one ^ (one >>> 16), 2246822507) ^ Math.imul(two ^ (two >>> 13), 3266489909);
|
|
36
|
+
two = Math.imul(two ^ (two >>> 16), 2246822507) ^ Math.imul(one ^ (one >>> 13), 3266489909);
|
|
37
|
+
return (4294967296 * (2097151 & two) + (one >>> 0)).toString(16).padStart(14, '0');
|
|
38
|
+
};
|
|
39
|
+
export const fingerprintOf = (finding) => hash(`${finding.kind}\u0000${finding.tag}\u0000${normalize(finding.subject)}`);
|
|
40
|
+
export const identify = (finding, tags) => ({
|
|
41
|
+
...finding,
|
|
42
|
+
category: CATEGORY[finding.kind],
|
|
43
|
+
family: tags.get(finding.tag)?.family,
|
|
44
|
+
fingerprint: fingerprintOf(finding),
|
|
45
|
+
owner: OWNER[finding.kind],
|
|
46
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -28,19 +28,25 @@
|
|
|
28
28
|
* stylesheet in the page and has no business in a production bundle.
|
|
29
29
|
*/
|
|
30
30
|
export { collect } from './collect.js';
|
|
31
|
+
export { type Config, DEFAULTS, pagePattern } from './config.js';
|
|
31
32
|
export { report } from './console.js';
|
|
33
|
+
export { fingerprintOf, normalize } from './identity.js';
|
|
32
34
|
export type { ManifestSource, Published, Tier } from './manifest.js';
|
|
33
35
|
export { index, nearest } from './manifest.js';
|
|
34
|
-
export type { Coverage, Finding, Kind, Layer, Origin, Report, Severity, Unreadable, Usage, } from './report.js';
|
|
35
|
-
export { KINDS } from './report.js';
|
|
36
|
+
export type { Category, Configuration, Coverage, Environment, Finding, FindingCategory, Kind, Layer, Origin, Owner, Report, Severity, Unreadable, Usage, } from './report.js';
|
|
37
|
+
export { CATEGORIES, KINDS } from './report.js';
|
|
36
38
|
export { type Override, type Rules, SHIPPED_RULES } from './rules.js';
|
|
37
39
|
export { type Watcher, watch } from './usage.js';
|
|
40
|
+
export { VERSION } from './version.js';
|
|
41
|
+
import { type Config } from './config.js';
|
|
38
42
|
import type { Tier } from './manifest.js';
|
|
39
|
-
import type { Report } from './report.js';
|
|
40
|
-
import { type Rules } from './rules.js';
|
|
43
|
+
import type { Environment, Report } from './report.js';
|
|
41
44
|
import { type Watcher } from './usage.js';
|
|
42
|
-
/**
|
|
43
|
-
|
|
45
|
+
/**
|
|
46
|
+
* What to look at, what to compare it against, and the settings for this
|
|
47
|
+
* call, which win over the project's file field by field.
|
|
48
|
+
*/
|
|
49
|
+
export interface Options extends Config {
|
|
44
50
|
/**
|
|
45
51
|
* `duplicateRegistrations` from `@fusion.dev/helix-core`, when it is installed.
|
|
46
52
|
*
|
|
@@ -53,10 +59,16 @@ export interface Options {
|
|
|
53
59
|
*/
|
|
54
60
|
duplicates?: () => string[];
|
|
55
61
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
62
|
+
* Where this run happens, as the project declares it. **Never inferred**:
|
|
63
|
+
* a bundler's `development` condition misfires both ways, and a run whose
|
|
64
|
+
* environment nobody declared is never sent.
|
|
58
65
|
*/
|
|
59
|
-
|
|
66
|
+
environment?: Environment | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* The project's own file, `helix-diagnostics.json`, parsed. The build reads
|
|
69
|
+
* the file, since the page cannot: import the JSON and pass it here.
|
|
70
|
+
*/
|
|
71
|
+
file?: Config | undefined;
|
|
60
72
|
/**
|
|
61
73
|
* The document to scan. Defaults to the ambient one.
|
|
62
74
|
*
|
|
@@ -65,14 +77,11 @@ export interface Options {
|
|
|
65
77
|
*/
|
|
66
78
|
from?: Document;
|
|
67
79
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* ```ts
|
|
72
|
-
* diagnose({ tiers, rules: { 'unforwarded-part': 'off', 'dead-hook': { severity: 'warn', tags: ['helix-card'] } } })
|
|
73
|
-
* ```
|
|
80
|
+
* The page's path, for a router that knows its own pattern, such as
|
|
81
|
+
* `/products/[id]`. Otherwise read from the location and made a pattern, as
|
|
82
|
+
* `detail.pages` says.
|
|
74
83
|
*/
|
|
75
|
-
|
|
84
|
+
page?: string | undefined;
|
|
76
85
|
/**
|
|
77
86
|
* The manifests to check against, one per installed tier.
|
|
78
87
|
*
|
|
@@ -88,7 +97,7 @@ export interface Options {
|
|
|
88
97
|
watcher?: Watcher | undefined;
|
|
89
98
|
}
|
|
90
99
|
/**
|
|
91
|
-
* One
|
|
100
|
+
* One run over the page.
|
|
92
101
|
*
|
|
93
102
|
* **It returns coverage and findings together and offers no way to have one
|
|
94
103
|
* without the other.** A collector that cannot read cross-origin sheets reports
|
|
@@ -100,6 +109,7 @@ export interface Options {
|
|
|
100
109
|
* loop: sheets that arrive later are found by calling it again.
|
|
101
110
|
*
|
|
102
111
|
* **A manifest from another version is refused**, with one finding that says
|
|
103
|
-
* so in place of every other:
|
|
112
|
+
* so in place of every other: by version where both sides carry one, and
|
|
113
|
+
* otherwise by a part the manifest does not list rendering in the page.
|
|
104
114
|
*/
|
|
105
|
-
export declare const diagnose: (
|
|
115
|
+
export declare const diagnose: (options: Options) => Report;
|
package/dist/index.js
CHANGED
|
@@ -1,38 +1,82 @@
|
|
|
1
1
|
export { collect } from './collect.js';
|
|
2
|
+
export { DEFAULTS, pagePattern } from './config.js';
|
|
2
3
|
export { report } from './console.js';
|
|
4
|
+
export { fingerprintOf, normalize } from './identity.js';
|
|
3
5
|
export { index, nearest } from './manifest.js';
|
|
4
|
-
export { KINDS } from './report.js';
|
|
6
|
+
export { CATEGORIES, KINDS } from './report.js';
|
|
5
7
|
export { SHIPPED_RULES } from './rules.js';
|
|
6
8
|
export { watch } from './usage.js';
|
|
7
|
-
|
|
9
|
+
export { VERSION } from './version.js';
|
|
10
|
+
import { analyze, markup, registrations, skew, versionSkew } from './analyze.js';
|
|
8
11
|
import { collect } from './collect.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
12
|
+
import { pagePattern, resolveConfig } from './config.js';
|
|
13
|
+
import { identify } from './identity.js';
|
|
14
|
+
import { index, versionsOf } from './manifest.js';
|
|
15
|
+
import { applyRules, overridesOf } from './rules.js';
|
|
16
|
+
import { listed, loadedVersions, styled, walk } from './usage.js';
|
|
17
|
+
import { VERSION } from './version.js';
|
|
18
|
+
const runId = () => globalThis.crypto?.randomUUID?.() ??
|
|
19
|
+
`${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
|
20
|
+
export const diagnose = (options) => {
|
|
21
|
+
const started = performance.now();
|
|
22
|
+
const at = new Date().toISOString();
|
|
23
|
+
const { duplicates, environment, file, from, page, tiers, watcher, ...call } = options;
|
|
13
24
|
if (tiers.length === 0) {
|
|
14
25
|
throw new Error('diagnose() needs at least one manifest. Without one every part name is unknown and every finding would be noise.');
|
|
15
26
|
}
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
const resolved = resolveConfig(file, call);
|
|
28
|
+
const { detail } = resolved.configuration;
|
|
18
29
|
const document_ = from ?? globalThis.document;
|
|
19
|
-
const { answered, coverage, rules: located } = collect(document_);
|
|
30
|
+
const { answered, coverage, readers, rules: located, used } = collect(document_);
|
|
20
31
|
const tags = index(tiers);
|
|
21
|
-
const
|
|
32
|
+
const counted = watcher ? watcher.counts() : walk(document_);
|
|
33
|
+
const seen = loadedVersions();
|
|
34
|
+
const manifests = versionsOf(tiers);
|
|
35
|
+
const byVersion = versionSkew(manifests, seen);
|
|
36
|
+
const known = seen.length > 0 && Object.keys(manifests).length > 0;
|
|
37
|
+
const skewed = applyRules((byVersion.length > 0 ? byVersion : known ? [] : skew(tags, document_)).map((one) => identify(one, tags)), file?.rules, call.rules);
|
|
22
38
|
const judged = skewed.findings.length > 0
|
|
23
|
-
? { findings: [], silenced:
|
|
24
|
-
: applyRules([
|
|
39
|
+
? { findings: [], silenced: [] }
|
|
40
|
+
: applyRules([
|
|
41
|
+
...analyze(located, tags, document_, answered, used, readers),
|
|
42
|
+
...markup(tags, document_),
|
|
43
|
+
...registrations(document_, duplicates),
|
|
44
|
+
].map((one) => identify(one, tags)), file?.rules, call.rules);
|
|
45
|
+
const path = page ?? globalThis.location?.pathname;
|
|
46
|
+
const shownPage = path === undefined || detail.pages === 'none'
|
|
47
|
+
? undefined
|
|
48
|
+
: detail.pages === 'patterns'
|
|
49
|
+
? pagePattern(path)
|
|
50
|
+
: path;
|
|
25
51
|
return {
|
|
52
|
+
configuration: {
|
|
53
|
+
...resolved.configuration,
|
|
54
|
+
overrides: overridesOf(file?.rules, call.rules),
|
|
55
|
+
silenced: [...skewed.silenced, ...judged.silenced],
|
|
56
|
+
},
|
|
26
57
|
coverage,
|
|
27
58
|
findings: [...skewed.findings, ...judged.findings],
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
59
|
+
project: resolved.project,
|
|
60
|
+
run: {
|
|
61
|
+
at,
|
|
62
|
+
duration: Math.round(performance.now() - started),
|
|
63
|
+
id: runId(),
|
|
64
|
+
source: 'page',
|
|
65
|
+
...(environment === undefined ? {} : { environment }),
|
|
66
|
+
...(shownPage === undefined ? {} : { page: shownPage }),
|
|
31
67
|
},
|
|
68
|
+
schema: 1,
|
|
32
69
|
usage: {
|
|
33
|
-
...listed(
|
|
34
|
-
...styled(located, tags, answered),
|
|
70
|
+
...listed(counted, tags),
|
|
71
|
+
...styled(located, tags, answered, detail.hookValues),
|
|
35
72
|
counted: watcher ? 'watched' : 'walked',
|
|
36
73
|
},
|
|
74
|
+
versions: { collector: VERSION, helix: seen, manifests, rules: VERSION },
|
|
75
|
+
withheld: Object.entries(resolved.send)
|
|
76
|
+
.filter(([, sent]) => !sent.on)
|
|
77
|
+
.map(([category, sent]) => ({
|
|
78
|
+
category: category,
|
|
79
|
+
decidedBy: sent.decidedBy,
|
|
80
|
+
})),
|
|
37
81
|
};
|
|
38
82
|
};
|