tempest-react-sdk 0.31.1 → 0.32.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 +2 -0
- package/bin/lib/design/collect.mjs +103 -0
- package/bin/lib/design/findings.mjs +70 -0
- package/bin/lib/design/functions.mjs +188 -0
- package/bin/lib/design/functions.test.mjs +127 -0
- package/bin/lib/design/index.mjs +80 -0
- package/bin/lib/design/index.test.mjs +129 -0
- package/bin/lib/design/markers.mjs +76 -0
- package/bin/lib/design/markers.test.mjs +60 -0
- package/bin/lib/design/mask.mjs +209 -0
- package/bin/lib/design/mask.test.mjs +69 -0
- package/bin/lib/design/scan.mjs +229 -0
- package/bin/lib/design/scan.test.mjs +218 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +79 -2
- package/bin/tempest.mjs +83 -3
- package/dist/imaging/canvas.cjs +2 -0
- package/dist/imaging/canvas.cjs.map +1 -0
- package/dist/imaging/canvas.js +23 -0
- package/dist/imaging/canvas.js.map +1 -0
- package/dist/imaging/compress.cjs +2 -0
- package/dist/imaging/compress.cjs.map +1 -0
- package/dist/imaging/compress.js +43 -0
- package/dist/imaging/compress.js.map +1 -0
- package/dist/imaging/decode.cjs +2 -0
- package/dist/imaging/decode.cjs.map +1 -0
- package/dist/imaging/decode.js +42 -0
- package/dist/imaging/decode.js.map +1 -0
- package/dist/imaging/encode.cjs +2 -0
- package/dist/imaging/encode.cjs.map +1 -0
- package/dist/imaging/encode.js +48 -0
- package/dist/imaging/encode.js.map +1 -0
- package/dist/imaging/exceptions.cjs +2 -0
- package/dist/imaging/exceptions.cjs.map +1 -0
- package/dist/imaging/exceptions.js +26 -0
- package/dist/imaging/exceptions.js.map +1 -0
- package/dist/imaging/thumbnails.cjs +2 -0
- package/dist/imaging/thumbnails.cjs.map +1 -0
- package/dist/imaging/thumbnails.js +34 -0
- package/dist/imaging/thumbnails.js.map +1 -0
- package/dist/imaging/transform.cjs +2 -0
- package/dist/imaging/transform.cjs.map +1 -0
- package/dist/imaging/transform.js +97 -0
- package/dist/imaging/transform.js.map +1 -0
- package/dist/imaging/use-image-processing.cjs +2 -0
- package/dist/imaging/use-image-processing.cjs.map +1 -0
- package/dist/imaging/use-image-processing.js +45 -0
- package/dist/imaging/use-image-processing.js.map +1 -0
- package/dist/imaging.cjs +1 -0
- package/dist/imaging.d.ts +541 -0
- package/dist/imaging.js +9 -0
- package/dist/tabular/assets.cjs +2 -0
- package/dist/tabular/assets.cjs.map +1 -0
- package/dist/tabular/assets.js +19 -0
- package/dist/tabular/assets.js.map +1 -0
- package/dist/tabular/cache.cjs +2 -0
- package/dist/tabular/cache.cjs.map +1 -0
- package/dist/tabular/cache.js +48 -0
- package/dist/tabular/cache.js.map +1 -0
- package/dist/tabular/exceptions.cjs +2 -0
- package/dist/tabular/exceptions.cjs.map +1 -0
- package/dist/tabular/exceptions.js +30 -0
- package/dist/tabular/exceptions.js.map +1 -0
- package/dist/tabular/manifest.cjs +2 -0
- package/dist/tabular/manifest.cjs.map +1 -0
- package/dist/tabular/manifest.js +42 -0
- package/dist/tabular/manifest.js.map +1 -0
- package/dist/tabular/predictor.cjs +2 -0
- package/dist/tabular/predictor.cjs.map +1 -0
- package/dist/tabular/predictor.js +111 -0
- package/dist/tabular/predictor.js.map +1 -0
- package/dist/tabular/use-tabular-predictor.cjs +2 -0
- package/dist/tabular/use-tabular-predictor.cjs.map +1 -0
- package/dist/tabular/use-tabular-predictor.js +51 -0
- package/dist/tabular/use-tabular-predictor.js.map +1 -0
- package/dist/tabular.cjs +1 -0
- package/dist/tabular.d.ts +489 -0
- package/dist/tabular.js +7 -0
- package/package.json +11 -1
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The rules. Each one answers a question from docs/design — "is this file too
|
|
2
|
+
// big to read whole", "does this component fetch", "is an error being swallowed"
|
|
3
|
+
// — and each one is measured on the mask, never on the raw text.
|
|
4
|
+
import { finding, LIMITS } from "./findings.mjs";
|
|
5
|
+
import { findFunctions, findPropsTypes } from "./functions.mjs";
|
|
6
|
+
import { countCodeLines, lineAt, maskSource } from "./mask.mjs";
|
|
7
|
+
import { isWaived, parseMarkers } from "./markers.mjs";
|
|
8
|
+
|
|
9
|
+
/** `any` in a type position. `unknown`, `anyOf` and `Company` must not match. */
|
|
10
|
+
const ANY_TYPE = /(?<![\w$])any(?![\w$])/g;
|
|
11
|
+
|
|
12
|
+
/** A `catch` block whose body is empty once comments are masked away. */
|
|
13
|
+
const EMPTY_CATCH = /catch\s*(?:\([^)]*\))?\s*\{\s*\}/g;
|
|
14
|
+
|
|
15
|
+
/** `fetch(`/`axios` — transport inside a component file. */
|
|
16
|
+
const TRANSPORT = /\b(?:fetch\s*\(|axios\b)/g;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An inline `style={{ … }}` carrying a literal colour. Matched on the raw source,
|
|
20
|
+
* not the mask: the colour lives inside a string, and the mask exists to hide
|
|
21
|
+
* exactly that.
|
|
22
|
+
*/
|
|
23
|
+
const INLINE_STYLE_COLOR = /style\s*=\s*\{\{[^}]*#[0-9a-fA-F]{3,8}\b/g;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a masked slice looks like a type annotation rather than a value.
|
|
27
|
+
* Filters `[key: string]: any` (a real finding) from `anyOf` and from the string
|
|
28
|
+
* `"any"` — already masked — while skipping `as any` casts, counted separately.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} masked
|
|
31
|
+
* @param {number} index - Offset of the `any` token.
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isTypePosition(masked, index) {
|
|
35
|
+
const before = masked.slice(Math.max(0, index - 24), index);
|
|
36
|
+
return /[:<|&(,[]\s*$|\bas\s+$/.test(before);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Analyze one already-read source file.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {string} params.file - Project-relative path, used verbatim in findings.
|
|
44
|
+
* @param {string} params.source - Raw contents.
|
|
45
|
+
* @param {object} [params.limits] - Overrides for {@link LIMITS}.
|
|
46
|
+
* @returns {{ findings: object[], waivers: Array<{ code: string, reason: string }>,
|
|
47
|
+
* codeLines: number }}
|
|
48
|
+
*/
|
|
49
|
+
export function scanFile({ file, source, limits = {} }) {
|
|
50
|
+
const max = { ...LIMITS, ...limits };
|
|
51
|
+
const { masked, commentText } = maskSource(source);
|
|
52
|
+
const markers = parseMarkers(commentText);
|
|
53
|
+
const isTsx = file.endsWith(".tsx");
|
|
54
|
+
const isTest = /\.(test|spec)\./.test(file);
|
|
55
|
+
const findings = [];
|
|
56
|
+
|
|
57
|
+
const push = (code, line, message) => {
|
|
58
|
+
if (isWaived(markers, code)) return;
|
|
59
|
+
findings.push(finding(code, { file, line, message }));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const id of markers.unexplained) {
|
|
63
|
+
findings.push(
|
|
64
|
+
finding("marker-without-reason", {
|
|
65
|
+
file,
|
|
66
|
+
line: 1,
|
|
67
|
+
message: `@tempest-limits ${id} has no reason — write why the limit does not fit here`,
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const codeLines = countCodeLines(masked);
|
|
73
|
+
if (!isTest) {
|
|
74
|
+
const fileMax = isTsx ? max.componentFileLines : max.moduleFileLines;
|
|
75
|
+
if (codeLines > fileMax) {
|
|
76
|
+
push(
|
|
77
|
+
"file-lines",
|
|
78
|
+
1,
|
|
79
|
+
`${codeLines} lines of code (limit ${fileMax}) — extract a sub-component, a hook or a pure function`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const propsTypes = findPropsTypes(masked);
|
|
85
|
+
for (const [name, info] of propsTypes) {
|
|
86
|
+
if (info.count > max.props) {
|
|
87
|
+
push(
|
|
88
|
+
"props-count",
|
|
89
|
+
info.line,
|
|
90
|
+
`${name} has ${info.count} props (limit ${max.props}) — likely two components in one`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isTest) {
|
|
96
|
+
for (const fn of findFunctions(masked, { isTsx })) {
|
|
97
|
+
const bodyMax = fn.kind === "hook" ? max.hookLines : max.functionLines;
|
|
98
|
+
const code = fn.kind === "hook" ? "hook-lines" : "function-lines";
|
|
99
|
+
if (fn.bodyLines > bodyMax) {
|
|
100
|
+
push(
|
|
101
|
+
code,
|
|
102
|
+
fn.line,
|
|
103
|
+
`${fn.name} body is ${fn.bodyLines} lines (limit ${bodyMax}) — ${
|
|
104
|
+
fn.kind === "hook"
|
|
105
|
+
? "split into smaller hooks"
|
|
106
|
+
: "extract a pure function or a hook"
|
|
107
|
+
}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (fn.exported && fn.params.length > max.params) {
|
|
111
|
+
push(
|
|
112
|
+
"param-count",
|
|
113
|
+
fn.line,
|
|
114
|
+
`${fn.name} takes ${fn.params.length} parameters (limit ${max.params}) — pass one named object`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (fn.kind === "component" && fn.destructuredProps > max.props) {
|
|
118
|
+
push(
|
|
119
|
+
"props-count",
|
|
120
|
+
fn.line,
|
|
121
|
+
`${fn.name} destructures ${fn.destructuredProps} props (limit ${max.props}) — likely two components in one`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
findings.push(...typeEscapes({ file, source, masked, commentText, markers, isTest }));
|
|
128
|
+
findings.push(...behaviourRules({ file, source, masked, markers, isTsx, isTest }));
|
|
129
|
+
|
|
130
|
+
const waivers = [...markers.reasons].map(([code, reason]) => ({ code, reason }));
|
|
131
|
+
return { findings: findings.filter(Boolean), waivers, codeLines };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Whether an `eslint-disable` for `no-explicit-any` already covers a line.
|
|
136
|
+
*
|
|
137
|
+
* Re-reporting what the author waived through the standard mechanism is the
|
|
138
|
+
* fastest way to make a tool ignorable, so the existing pragma wins. The
|
|
139
|
+
* `@tempest-limits` marker exists for the same reason at file scope.
|
|
140
|
+
*
|
|
141
|
+
* @param {string[]} lines - Raw source lines.
|
|
142
|
+
* @param {number} line - 1-based line of the `any` token.
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
function hasEslintWaiver(lines, line) {
|
|
146
|
+
const own = lines[line - 1] ?? "";
|
|
147
|
+
const previous = lines[line - 2] ?? "";
|
|
148
|
+
const pragma = /eslint-disable(?:-next-line|-line)?[^\n]*no-explicit-any/;
|
|
149
|
+
return pragma.test(own) || pragma.test(previous);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `any` and `@ts-ignore` — the two ways to turn the compiler off. Reported in
|
|
154
|
+
* test files too: a type hole in a test is a test that proves less than it looks.
|
|
155
|
+
*
|
|
156
|
+
* @returns {object[]}
|
|
157
|
+
*/
|
|
158
|
+
function typeEscapes({ file, source, masked, commentText, markers, isTest }) {
|
|
159
|
+
const out = [];
|
|
160
|
+
if (!isWaived(markers, "explicit-any")) {
|
|
161
|
+
const lines = source.split("\n");
|
|
162
|
+
for (const match of masked.matchAll(ANY_TYPE)) {
|
|
163
|
+
if (!isTypePosition(masked, match.index)) continue;
|
|
164
|
+
const line = lineAt(masked, match.index);
|
|
165
|
+
if (hasEslintWaiver(lines, line)) continue;
|
|
166
|
+
out.push(
|
|
167
|
+
finding("explicit-any", {
|
|
168
|
+
file,
|
|
169
|
+
line,
|
|
170
|
+
message: isTest
|
|
171
|
+
? "`any` in a test — the mock proves less than it looks"
|
|
172
|
+
: "`any` turns the compiler off — use `unknown` plus validation",
|
|
173
|
+
extra: isTest ? { severity: "info" } : {},
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!isWaived(markers, "ts-ignore") && /@ts-(?:ignore|nocheck)/.test(commentText)) {
|
|
179
|
+
out.push(
|
|
180
|
+
finding("ts-ignore", {
|
|
181
|
+
file,
|
|
182
|
+
line: 1,
|
|
183
|
+
message: "@ts-ignore hides the error instead of fixing it — narrow the type",
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The three anti-patterns cheap enough to detect reliably: transport in a
|
|
192
|
+
* component, a swallowed error, and a colour hardcoded in an inline style.
|
|
193
|
+
*
|
|
194
|
+
* @returns {object[]}
|
|
195
|
+
*/
|
|
196
|
+
function behaviourRules({ file, source, masked, markers, isTsx, isTest }) {
|
|
197
|
+
const out = [];
|
|
198
|
+
const add = (code, index, message, text = masked) => {
|
|
199
|
+
if (isWaived(markers, code)) return;
|
|
200
|
+
out.push(finding(code, { file, line: lineAt(text, index), message }));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
if (isTsx && !isTest) {
|
|
204
|
+
for (const match of masked.matchAll(TRANSPORT)) {
|
|
205
|
+
add(
|
|
206
|
+
"fetch-in-component",
|
|
207
|
+
match.index,
|
|
208
|
+
"a component must not call the network — move it to a service and read it with useQuery",
|
|
209
|
+
);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const match of masked.matchAll(EMPTY_CATCH)) {
|
|
214
|
+
add(
|
|
215
|
+
"empty-catch",
|
|
216
|
+
match.index,
|
|
217
|
+
"empty catch swallows the error — handle it or let it reach the ErrorBoundary",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
for (const match of source.matchAll(INLINE_STYLE_COLOR)) {
|
|
221
|
+
add(
|
|
222
|
+
"inline-style-literal",
|
|
223
|
+
match.index,
|
|
224
|
+
"hardcoded colour in an inline style — use a --tempest-* token in a CSS Module",
|
|
225
|
+
source,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { scanFile } from "./scan.mjs";
|
|
4
|
+
|
|
5
|
+
const codes = (result) => result.findings.map((f) => f.code);
|
|
6
|
+
|
|
7
|
+
const bigComponent = (lines) =>
|
|
8
|
+
[
|
|
9
|
+
"export function Big() {",
|
|
10
|
+
...Array.from({ length: lines }, (_, i) => ` const v${i} = ${i};`),
|
|
11
|
+
" return null;",
|
|
12
|
+
"}",
|
|
13
|
+
].join("\n");
|
|
14
|
+
|
|
15
|
+
describe("scanFile — size", () => {
|
|
16
|
+
it("reports a .tsx over the component limit", () => {
|
|
17
|
+
const result = scanFile({ file: "src/Big.tsx", source: bigComponent(200) });
|
|
18
|
+
expect(codes(result)).toContain("file-lines");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses the larger limit for a .ts module", () => {
|
|
22
|
+
const source = Array.from({ length: 180 }, (_, i) => `export const v${i} = ${i};`).join(
|
|
23
|
+
"\n",
|
|
24
|
+
);
|
|
25
|
+
expect(codes(scanFile({ file: "src/tokens.ts", source }))).not.toContain("file-lines");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("does not size-check a test file", () => {
|
|
29
|
+
const result = scanFile({ file: "src/Big.test.tsx", source: bigComponent(200) });
|
|
30
|
+
expect(codes(result)).not.toContain("file-lines");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("reports a function body over the limit", () => {
|
|
34
|
+
const result = scanFile({
|
|
35
|
+
file: "src/util.ts",
|
|
36
|
+
source: bigComponent(120),
|
|
37
|
+
limits: { moduleFileLines: 10_000 },
|
|
38
|
+
});
|
|
39
|
+
expect(codes(result)).toContain("function-lines");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("gives a hook its own, larger limit", () => {
|
|
43
|
+
const source = [
|
|
44
|
+
"export function useThing() {",
|
|
45
|
+
...Array.from({ length: 120 }, (_, i) => ` const v${i} = ${i};`),
|
|
46
|
+
" return null;",
|
|
47
|
+
"}",
|
|
48
|
+
].join("\n");
|
|
49
|
+
const result = scanFile({
|
|
50
|
+
file: "src/use-thing.ts",
|
|
51
|
+
source,
|
|
52
|
+
limits: { moduleFileLines: 10_000 },
|
|
53
|
+
});
|
|
54
|
+
expect(codes(result)).toContain("hook-lines");
|
|
55
|
+
expect(codes(result)).not.toContain("function-lines");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("honours a limit override", () => {
|
|
59
|
+
const source = bigComponent(20);
|
|
60
|
+
expect(codes(scanFile({ file: "src/A.tsx", source }))).not.toContain("file-lines");
|
|
61
|
+
expect(
|
|
62
|
+
codes(scanFile({ file: "src/A.tsx", source, limits: { componentFileLines: 5 } })),
|
|
63
|
+
).toContain("file-lines");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("scanFile — shape", () => {
|
|
68
|
+
it("reports a Props type with too many members", () => {
|
|
69
|
+
const source = `interface CardProps {\n${Array.from({ length: 9 }, (_, i) => ` p${i}: string;`).join("\n")}\n}`;
|
|
70
|
+
const result = scanFile({ file: "src/Card.tsx", source });
|
|
71
|
+
expect(codes(result)).toContain("props-count");
|
|
72
|
+
expect(result.findings[0].message).toContain("CardProps has 9 props");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reports a component destructuring too many props", () => {
|
|
76
|
+
const source =
|
|
77
|
+
"export function Card({ a, b, c, d, e, f, g, h, className, ...rest }: P) {\n return null;\n}";
|
|
78
|
+
expect(codes(scanFile({ file: "src/Card.tsx", source }))).toContain("props-count");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not count plumbing towards the props limit", () => {
|
|
82
|
+
const source =
|
|
83
|
+
"export function Card({ a, b, c, className, children, style, id, ...rest }: P) {\n return null;\n}";
|
|
84
|
+
expect(codes(scanFile({ file: "src/Card.tsx", source }))).not.toContain("props-count");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reports an exported function with too many parameters", () => {
|
|
88
|
+
const source = "export function f(a, b, c, d) {\n return a;\n}";
|
|
89
|
+
expect(codes(scanFile({ file: "src/f.ts", source }))).toContain("param-count");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("leaves a private helper's parameter list alone", () => {
|
|
93
|
+
const source = "function inner(a, b, c, d, e) {\n return a;\n}";
|
|
94
|
+
expect(codes(scanFile({ file: "src/f.ts", source }))).not.toContain("param-count");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("scanFile — type escapes", () => {
|
|
99
|
+
it("reports an explicit any", () => {
|
|
100
|
+
const result = scanFile({
|
|
101
|
+
file: "src/f.ts",
|
|
102
|
+
source: "export function f(x: any) {\n return x;\n}",
|
|
103
|
+
});
|
|
104
|
+
expect(codes(result)).toContain("explicit-any");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reports an as-any cast", () => {
|
|
108
|
+
expect(codes(scanFile({ file: "src/f.ts", source: "const v = raw as any;" }))).toContain(
|
|
109
|
+
"explicit-any",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("does not match a word that merely contains any", () => {
|
|
114
|
+
const source = "const v: Company = { anyOf: 1 };\nlet u: unknown;";
|
|
115
|
+
expect(codes(scanFile({ file: "src/f.ts", source }))).not.toContain("explicit-any");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("does not match the word any inside a comment or a string", () => {
|
|
119
|
+
const source = '// accepts any shape\nconst s = "any";';
|
|
120
|
+
expect(codes(scanFile({ file: "src/f.ts", source }))).not.toContain("explicit-any");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("respects an existing eslint-disable for no-explicit-any", () => {
|
|
124
|
+
const source =
|
|
125
|
+
"// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype K = (...args: any[]) => void;";
|
|
126
|
+
expect(codes(scanFile({ file: "src/f.ts", source }))).not.toContain("explicit-any");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("downgrades any in a test file to info", () => {
|
|
130
|
+
const result = scanFile({ file: "src/f.test.ts", source: "const m = {} as any;" });
|
|
131
|
+
expect(result.findings.find((f) => f.code === "explicit-any").severity).toBe("info");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reports @ts-ignore once", () => {
|
|
135
|
+
const source = "// @ts-ignore\nconst v = 1;\n// @ts-ignore\nconst u = 2;";
|
|
136
|
+
expect(
|
|
137
|
+
codes(scanFile({ file: "src/f.ts", source })).filter((c) => c === "ts-ignore"),
|
|
138
|
+
).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("scanFile — behaviour", () => {
|
|
143
|
+
it("reports fetch inside a component file", () => {
|
|
144
|
+
const source = "export function List() {\n fetch('/api');\n return null;\n}";
|
|
145
|
+
expect(codes(scanFile({ file: "src/List.tsx", source }))).toContain("fetch-in-component");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("leaves fetch in a service alone", () => {
|
|
149
|
+
const source = "export async function list() {\n return fetch('/api');\n}";
|
|
150
|
+
expect(codes(scanFile({ file: "src/list.service.ts", source }))).not.toContain(
|
|
151
|
+
"fetch-in-component",
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("reports an empty catch, comment or not", () => {
|
|
156
|
+
const bare = "try {\n go();\n} catch {}";
|
|
157
|
+
const commented = "try {\n go();\n} catch (e) {\n // ignore\n}";
|
|
158
|
+
expect(codes(scanFile({ file: "src/a.ts", source: bare }))).toContain("empty-catch");
|
|
159
|
+
expect(codes(scanFile({ file: "src/a.ts", source: commented }))).toContain("empty-catch");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("leaves a catch that does something alone", () => {
|
|
163
|
+
const source = "try {\n go();\n} catch (e) {\n report(e);\n}";
|
|
164
|
+
expect(codes(scanFile({ file: "src/a.ts", source }))).not.toContain("empty-catch");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("reports a hardcoded colour in an inline style", () => {
|
|
168
|
+
const source = 'export function A() {\n return <div style={{ color: "#2563eb" }} />;\n}';
|
|
169
|
+
expect(codes(scanFile({ file: "src/A.tsx", source }))).toContain("inline-style-literal");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("scanFile — waivers", () => {
|
|
174
|
+
it("suppresses a waived rule and records the reason", () => {
|
|
175
|
+
const source = [
|
|
176
|
+
"/**",
|
|
177
|
+
" * Cropper.",
|
|
178
|
+
" *",
|
|
179
|
+
" * @tempest-limits file-lines — drag, zoom and canvas export share one piece",
|
|
180
|
+
" * of geometry state.",
|
|
181
|
+
" */",
|
|
182
|
+
bigComponent(200),
|
|
183
|
+
].join("\n");
|
|
184
|
+
const result = scanFile({ file: "src/Crop.tsx", source });
|
|
185
|
+
expect(codes(result)).not.toContain("file-lines");
|
|
186
|
+
expect(result.waivers).toEqual([
|
|
187
|
+
{ code: "file-lines", reason: expect.stringContaining("geometry state") },
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reports a waiver with no reason", () => {
|
|
192
|
+
const result = scanFile({
|
|
193
|
+
file: "src/Crop.tsx",
|
|
194
|
+
source: `// @tempest-limits file-lines\n${bigComponent(200)}`,
|
|
195
|
+
});
|
|
196
|
+
expect(codes(result)).toContain("marker-without-reason");
|
|
197
|
+
expect(codes(result)).not.toContain("file-lines");
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("scanFile — clean input", () => {
|
|
202
|
+
it("finds nothing in a well-shaped component", () => {
|
|
203
|
+
const source = [
|
|
204
|
+
"import { Badge } from 'tempest-react-sdk';",
|
|
205
|
+
"",
|
|
206
|
+
"interface RowProps {",
|
|
207
|
+
" label: string;",
|
|
208
|
+
" tone: 'ok' | 'bad';",
|
|
209
|
+
"}",
|
|
210
|
+
"",
|
|
211
|
+
"/** One row of the orders table. */",
|
|
212
|
+
"export function Row({ label, tone }: RowProps) {",
|
|
213
|
+
" return <Badge variant={tone}>{label}</Badge>;",
|
|
214
|
+
"}",
|
|
215
|
+
].join("\n");
|
|
216
|
+
expect(scanFile({ file: "src/Row.tsx", source }).findings).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -37,8 +37,8 @@ function installed(name, version, extra = {}) {
|
|
|
37
37
|
* that matters most — it decides whether a project "fails" the audit. Only running
|
|
38
38
|
* it the way a user does tests that.
|
|
39
39
|
*/
|
|
40
|
-
function doctor() {
|
|
41
|
-
const result = spawnSync(process.execPath, [CLI, "doctor"], {
|
|
40
|
+
function doctor(...args) {
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI, "doctor", ...args], {
|
|
42
42
|
cwd: root,
|
|
43
43
|
encoding: "utf8",
|
|
44
44
|
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" },
|
|
@@ -288,3 +288,80 @@ describe("tempest doctor — a project that does use the SDK", () => {
|
|
|
288
288
|
expect(code).toBe(0);
|
|
289
289
|
});
|
|
290
290
|
});
|
|
291
|
+
|
|
292
|
+
describe("tempest doctor — design analysis", () => {
|
|
293
|
+
beforeEach(thirdPartyApp);
|
|
294
|
+
|
|
295
|
+
it("reports nothing about design for a well-shaped app", () => {
|
|
296
|
+
const { out, code } = doctor();
|
|
297
|
+
expect(out).toContain("Design");
|
|
298
|
+
expect(out).toContain("no design problems found");
|
|
299
|
+
expect(code).toBe(0);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("names the file and the line of each finding", () => {
|
|
303
|
+
write(
|
|
304
|
+
"src/pages/Bad.tsx",
|
|
305
|
+
["export function Bad() {", " fetch('/api/orders');", " return null;", "}"].join(
|
|
306
|
+
"\n",
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
const { out } = doctor();
|
|
310
|
+
expect(out).toContain("src/pages/Bad.tsx:2");
|
|
311
|
+
expect(out).toContain("must not call the network");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("warns without failing the audit — the limits are heuristics", () => {
|
|
315
|
+
write(
|
|
316
|
+
"src/Big.tsx",
|
|
317
|
+
[
|
|
318
|
+
"export function Big() {",
|
|
319
|
+
...Array.from({ length: 200 }, (_, i) => ` const v${i} = ${i};`),
|
|
320
|
+
" return null;",
|
|
321
|
+
"}",
|
|
322
|
+
].join("\n"),
|
|
323
|
+
);
|
|
324
|
+
const { out, code } = doctor();
|
|
325
|
+
expect(out).toContain("lines of code (limit 150)");
|
|
326
|
+
expect(code).toBe(0);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("honours a written @tempest-limits waiver and says how many there are", () => {
|
|
330
|
+
write(
|
|
331
|
+
"src/Big.tsx",
|
|
332
|
+
[
|
|
333
|
+
"/**",
|
|
334
|
+
" * Cropper.",
|
|
335
|
+
" *",
|
|
336
|
+
" * @tempest-limits file-lines — drag, zoom and canvas export share one",
|
|
337
|
+
" * piece of geometry state.",
|
|
338
|
+
" */",
|
|
339
|
+
"export function Big() {",
|
|
340
|
+
...Array.from({ length: 200 }, (_, i) => ` const v${i} = ${i};`),
|
|
341
|
+
" return null;",
|
|
342
|
+
"}",
|
|
343
|
+
].join("\n"),
|
|
344
|
+
);
|
|
345
|
+
const { out } = doctor();
|
|
346
|
+
expect(out).not.toContain("lines of code (limit 150)");
|
|
347
|
+
expect(out).toContain("1 limit(s) waived with a written reason");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("reports a waiver that carries no reason", () => {
|
|
351
|
+
write("src/Big.tsx", "// @tempest-limits file-lines\nexport const a = 1;");
|
|
352
|
+
expect(doctor().out).toContain("has no reason");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("skips the whole pass with --no-design", () => {
|
|
356
|
+
write("src/pages/Bad.tsx", "export function Bad() {\n fetch('/x');\n return null;\n}");
|
|
357
|
+
const { out } = doctor("--no-design");
|
|
358
|
+
expect(out).not.toContain("must not call the network");
|
|
359
|
+
expect(out).not.toMatch(/^Design$/m);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("skips the CSS pass with --no-css", () => {
|
|
363
|
+
write("src/app.css", "a { colr: red; }");
|
|
364
|
+
const { out } = doctor("--no-css");
|
|
365
|
+
expect(out).not.toMatch(/^Stylesheets$/m);
|
|
366
|
+
});
|
|
367
|
+
});
|
package/bin/tempest.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// tempest — project CLI shipped inside tempest-react-sdk.
|
|
3
3
|
//
|
|
4
4
|
// tempest doctor health-check the current project (à la flutter doctor)
|
|
5
|
+
// [--no-css] [--no-design] skip an analysis pass
|
|
5
6
|
// tempest lint [paths…] run ESLint (report only)
|
|
6
7
|
// tempest fix [paths…] alias imports (../ → @/) + ESLint --fix + Prettier
|
|
7
8
|
// tempest format [paths…] Prettier --write
|
|
@@ -15,6 +16,7 @@ import { aliasImports } from "./lib/alias/index.mjs";
|
|
|
15
16
|
import { describeTypeScript, loadTypeScript } from "./lib/alias/typescript.mjs";
|
|
16
17
|
import { readTsconfig } from "./lib/alias/tsconfig.mjs";
|
|
17
18
|
import { analyzeCss, applyCssFixes, applyExtraction, planExtraction } from "./lib/css/index.mjs";
|
|
19
|
+
import { analyzeDesign } from "./lib/design/index.mjs";
|
|
18
20
|
import { checkLucide } from "./lib/doctor/lucide.mjs";
|
|
19
21
|
import { generateRegistry, loadIconTables } from "./lib/icons/generate.mjs";
|
|
20
22
|
import { generate } from "./lib/openapi/generate.mjs";
|
|
@@ -319,7 +321,77 @@ function cssChecks(limit = 6) {
|
|
|
319
321
|
return rows;
|
|
320
322
|
}
|
|
321
323
|
|
|
322
|
-
|
|
324
|
+
/**
|
|
325
|
+
* Doctor rows for the design analysis — the limits and anti-patterns documented
|
|
326
|
+
* under docs/design.
|
|
327
|
+
*
|
|
328
|
+
* Every row is a warning or a note, never a failure: the limits are heuristics
|
|
329
|
+
* with a written escape hatch (`@tempest-limits <rule> — reason`), and failing the
|
|
330
|
+
* exit code on a heuristic is how a tool gets silenced. The hard gates stay with
|
|
331
|
+
* ESLint (`no-explicit-any`) and `tsc --noEmit`.
|
|
332
|
+
*
|
|
333
|
+
* Findings are capped per severity for the same reason as the CSS pass: a report
|
|
334
|
+
* nobody reads to the end hides its own first line. The cap is stated, and the
|
|
335
|
+
* pointer to the full list goes with it.
|
|
336
|
+
*
|
|
337
|
+
* @param {number} [limit] - Findings shown per severity.
|
|
338
|
+
* @returns {Array<[status: string, label: string, detail?: string]>} Check rows.
|
|
339
|
+
*/
|
|
340
|
+
function designChecks(limit = 6) {
|
|
341
|
+
let analysis;
|
|
342
|
+
try {
|
|
343
|
+
analysis = analyzeDesign({ root: ROOT });
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return [["warn", "design analysis failed", String(err?.message ?? err)]];
|
|
346
|
+
}
|
|
347
|
+
if (analysis.stats.files === 0) return [];
|
|
348
|
+
|
|
349
|
+
const { files, codeLines, medianLines, largest } = analysis.stats;
|
|
350
|
+
const rows = [["section", "Design"]];
|
|
351
|
+
rows.push([
|
|
352
|
+
"info",
|
|
353
|
+
`${files} source file(s) · ${codeLines} lines of code · median ${medianLines}`,
|
|
354
|
+
largest ? `largest: ${largest.file} (${largest.lines})` : "",
|
|
355
|
+
]);
|
|
356
|
+
if (analysis.truncated) {
|
|
357
|
+
rows.push(["info", "file cap reached", "only the first 1200 source files were analyzed"]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let hidden = 0;
|
|
361
|
+
for (const severity of ["warn", "info"]) {
|
|
362
|
+
const group = analysis.findings.filter((f) => f.severity === severity);
|
|
363
|
+
for (const f of group.slice(0, limit)) {
|
|
364
|
+
rows.push([severity === "warn" ? "warn" : "info", `${f.file}:${f.line}`, f.message]);
|
|
365
|
+
}
|
|
366
|
+
hidden += Math.max(0, group.length - limit);
|
|
367
|
+
}
|
|
368
|
+
if (hidden > 0) {
|
|
369
|
+
rows.push([
|
|
370
|
+
"info",
|
|
371
|
+
`${hidden} more design finding(s) not shown`,
|
|
372
|
+
"the limits and the escape hatch are documented at /design/limits/",
|
|
373
|
+
]);
|
|
374
|
+
}
|
|
375
|
+
if (analysis.findings.length === 0) {
|
|
376
|
+
rows.push(["ok", "no design problems found"]);
|
|
377
|
+
}
|
|
378
|
+
if (analysis.waivers.length > 0) {
|
|
379
|
+
rows.push([
|
|
380
|
+
"info",
|
|
381
|
+
`${analysis.waivers.length} limit(s) waived with a written reason`,
|
|
382
|
+
"@tempest-limits markers — nothing to do",
|
|
383
|
+
]);
|
|
384
|
+
}
|
|
385
|
+
return rows;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Health-check the project.
|
|
390
|
+
*
|
|
391
|
+
* @param {string[]} [args] - Argv tail. `--no-css` and `--no-design` skip a pass.
|
|
392
|
+
* @returns {number} Exit code: 1 when a check failed, 0 otherwise.
|
|
393
|
+
*/
|
|
394
|
+
function doctor(args = []) {
|
|
323
395
|
const checks = [];
|
|
324
396
|
const pkg = readJSON(join(ROOT, "package.json"));
|
|
325
397
|
|
|
@@ -666,7 +738,10 @@ function doctor() {
|
|
|
666
738
|
}
|
|
667
739
|
|
|
668
740
|
// ── Stylesheets ───────────────────────────────────────────────────────────
|
|
669
|
-
checks.push(...cssChecks());
|
|
741
|
+
if (!args.includes("--no-css")) checks.push(...cssChecks());
|
|
742
|
+
|
|
743
|
+
// ── Design ────────────────────────────────────────────────────────────────
|
|
744
|
+
if (!args.includes("--no-design")) checks.push(...designChecks());
|
|
670
745
|
|
|
671
746
|
// ── Tooling ────────────────────────────────────────────────────────────────
|
|
672
747
|
checks.push(["section", "Tooling"]);
|
|
@@ -1250,6 +1325,11 @@ ${c.bold}Commands${c.reset}
|
|
|
1250
1325
|
${c.bold}gen icons${c.reset} Write a static icon registry from the slugs your source uses
|
|
1251
1326
|
(e.g. tempest gen icons --out src/icons.generated.ts --dir src)
|
|
1252
1327
|
|
|
1328
|
+
${c.bold}doctor options${c.reset}
|
|
1329
|
+
--no-css Skip the CSS analysis
|
|
1330
|
+
--no-design Skip the design analysis (file/function size, props count,
|
|
1331
|
+
\`any\`, transport in a component, swallowed errors)
|
|
1332
|
+
|
|
1253
1333
|
${c.bold}fix options${c.reset}
|
|
1254
1334
|
--dry-run List every CSS finding and the imports that would be rewritten;
|
|
1255
1335
|
write nothing
|
|
@@ -1281,7 +1361,7 @@ if (!cmd || cmd === "-h" || cmd === "--help" || cmd === "help") {
|
|
|
1281
1361
|
}
|
|
1282
1362
|
|
|
1283
1363
|
const commands = {
|
|
1284
|
-
doctor: () => doctor(),
|
|
1364
|
+
doctor: () => doctor(rest),
|
|
1285
1365
|
lint: () => lint(rest),
|
|
1286
1366
|
fix: () => fix(rest),
|
|
1287
1367
|
format: () => format(rest),
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require("./exceptions.cjs");function t(t,n){let r=Math.max(1,Math.round(t)),i=Math.max(1,Math.round(n));if(typeof OffscreenCanvas<`u`)return new OffscreenCanvas(r,i);if(typeof document<`u`){let e=document.createElement(`canvas`);return e.width=r,e.height=i,e}throw new e.ImagingUnavailableError(`No canvas is available here. This module needs a browser (or a worker with OffscreenCanvas); it does not run under plain Node.`)}function n(t,n){let r=t.getContext(`2d`);if(r===null)throw new e.ImagingUnavailableError(`Could not get a 2-D context from the canvas.`);return r.imageSmoothingEnabled=!0,r.imageSmoothingQuality=`high`,n!==void 0&&(r.fillStyle=n,r.fillRect(0,0,t.width,t.height)),r}function r(e,t,r,i){n(t,i).drawImage(e,r.x,r.y,r.width,r.height)}exports.createSurface=t,exports.drawScaled=r,exports.getContext=n;
|
|
2
|
+
//# sourceMappingURL=canvas.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"canvas.cjs","names":[],"sources":["../../src/imaging/canvas.ts"],"sourcesContent":["/**\n * The drawing surface, and the one thing everyone gets wrong on it.\n *\n * **JPEG has no alpha.** Encoding a transparent PNG as JPEG paints the\n * transparent pixels black. Filling the surface first is the difference\n * between a photo on a white background and one with a black hole in it.\n *\n * What is *not* here is worth recording. The received wisdom for downscaling\n * on a canvas is to halve repeatedly, because a single `drawImage` into a\n * much smaller box was said to alias. That was implemented here, and then\n * measured: on a 512 px checkerboard reduced to 32 px, the stepwise result\n * and the single high-quality draw were **pixel-identical** (standard\n * deviation 0.0 on both) in Chromium and Firefox — while stepwise cost\n * **39.19 ms against 0.13 ms** on a 4000x3000 photo, 300 times more, and\n * allocated three intermediate canvases on a device that may not have the\n * memory. Modern engines honour `imageSmoothingQuality = \"high\"`, which is\n * what this module sets. The halving was deleted rather than kept \"just in\n * case\": unmeasurable benefit at 300x the cost is not insurance, it is\n * ballast.\n */\n\nimport { ImagingUnavailableError } from \"./exceptions\";\n\n/** A canvas this module can draw on, on the main thread or in a worker. */\nexport type Surface = OffscreenCanvas | HTMLCanvasElement;\n\n/** A 2-D context from either surface kind. */\nexport type SurfaceContext = OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;\n\n/**\n * Create a drawing surface, preferring `OffscreenCanvas`.\n *\n * `OffscreenCanvas` works inside a worker, which is where a PWA wants this\n * running: resizing a 12-megapixel photo on the main thread blocks the UI\n * for tens of milliseconds per image.\n *\n * @param width Surface width in pixels.\n * @param height Surface height in pixels.\n * @returns The surface.\n * @throws {@link ImagingUnavailableError} when neither kind exists — a\n * server render, or a test environment without a canvas.\n */\nexport function createSurface(width: number, height: number): Surface {\n const safeWidth = Math.max(1, Math.round(width));\n const safeHeight = Math.max(1, Math.round(height));\n\n if (typeof OffscreenCanvas !== \"undefined\") {\n return new OffscreenCanvas(safeWidth, safeHeight);\n }\n if (typeof document !== \"undefined\") {\n const canvas = document.createElement(\"canvas\");\n canvas.width = safeWidth;\n canvas.height = safeHeight;\n return canvas;\n }\n throw new ImagingUnavailableError(\n \"No canvas is available here. This module needs a browser (or a worker \" +\n \"with OffscreenCanvas); it does not run under plain Node.\",\n );\n}\n\n/**\n * Get a 2-D context configured for image work.\n *\n * `imageSmoothingQuality = \"high\"` is the setting that makes a steep\n * downscale average its source pixels instead of sampling them sparsely.\n *\n * @param surface The surface to draw on.\n * @param background Optional fill painted before anything else.\n * @returns The context.\n * @throws {@link ImagingUnavailableError} when the context cannot be created.\n */\nexport function getContext(surface: Surface, background?: string): SurfaceContext {\n const context = surface.getContext(\"2d\") as SurfaceContext | null;\n if (context === null) {\n throw new ImagingUnavailableError(\"Could not get a 2-D context from the canvas.\");\n }\n context.imageSmoothingEnabled = true;\n context.imageSmoothingQuality = \"high\";\n if (background !== undefined) {\n context.fillStyle = background;\n context.fillRect(0, 0, surface.width, surface.height);\n }\n return context;\n}\n\n/**\n * Draw a bitmap into a surface with high-quality filtering.\n *\n * @param bitmap The source pixels.\n * @param target Destination surface.\n * @param box Where to draw inside the destination.\n * @param background Fill painted before drawing.\n */\nexport function drawScaled(\n bitmap: ImageBitmap,\n target: Surface,\n box: { x: number; y: number; width: number; height: number },\n background?: string,\n): void {\n const context = getContext(target, background);\n context.drawImage(bitmap, box.x, box.y, box.width, box.height);\n}\n"],"mappings":"oCA0CA,SAAgB,EAAc,EAAe,EAAyB,CAClE,IAAM,EAAY,KAAK,IAAI,EAAG,KAAK,MAAM,CAAK,CAAC,EACzC,EAAa,KAAK,IAAI,EAAG,KAAK,MAAM,CAAM,CAAC,EAEjD,GAAI,OAAO,gBAAoB,IAC3B,OAAO,IAAI,gBAAgB,EAAW,CAAU,EAEpD,GAAI,OAAO,SAAa,IAAa,CACjC,IAAM,EAAS,SAAS,cAAc,QAAQ,EAG9C,MAFA,GAAO,MAAQ,EACf,EAAO,OAAS,EACT,CACX,CACA,MAAM,IAAI,EAAA,wBACN,gIAEJ,CACJ,CAaA,SAAgB,EAAW,EAAkB,EAAqC,CAC9E,IAAM,EAAU,EAAQ,WAAW,IAAI,EACvC,GAAI,IAAY,KACZ,MAAM,IAAI,EAAA,wBAAwB,8CAA8C,EAQpF,MANA,GAAQ,sBAAwB,GAChC,EAAQ,sBAAwB,OAC5B,IAAe,IAAA,KACf,EAAQ,UAAY,EACpB,EAAQ,SAAS,EAAG,EAAG,EAAQ,MAAO,EAAQ,MAAM,GAEjD,CACX,CAUA,SAAgB,EACZ,EACA,EACA,EACA,EACI,CAEJ,EAD2B,EAAQ,CACnC,CAAA,CAAQ,UAAU,EAAQ,EAAI,EAAG,EAAI,EAAG,EAAI,MAAO,EAAI,MAAM,CACjE"}
|