tempest-react-sdk 0.31.0 → 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/dist/utilities.css +133 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -278,6 +278,8 @@ Every module is re-exported from the package root — `import { Button, useDebou
|
|
|
278
278
|
| `sw` _(also subpath `tempest-react-sdk/sw`)_ | `registerServiceWorker` (opt-in `autoUpdate` poll + reload, no `vite-plugin-pwa`), `skipWaiting`, `unregisterAllServiceWorkers`, `installPushHandler`, `installNotificationClickHandler`, `installSkipWaitingListener`, `installPrecache` (app-shell offline + Navigation Preload), `installRuntimeCache` (per-route caching, incl. `rangeRequests`), `createPartialResponse` (206 range slicing), `installBackgroundSync` (offline mutation queue + `periodicsync`), `registerPeriodicSync` (main-thread periodic sync), `inspectCaches`/`clearCaches` (cache observability) — the React-free `tempest-react-sdk/sw` subpath is ideal for bundling into your own `sw.ts` |
|
|
279
279
|
| `editor` _(subpath `tempest-react-sdk/editor`, opt. peers `@tiptap/react`+`@tiptap/starter-kit`)_ | `RichTextEditor` — themed tiptap v3 rich-text editor with toolbar (tiptap externalized; install it only if you use the editor) |
|
|
280
280
|
| `vision` _(subpath `tempest-react-sdk/vision`, opt. peer `onnxruntime-web`)_ | `Classifier`, `Detector`, `Segmenter` + results/labels/preprocess — on-device ONNX vision inference, vendored from `@mauriciobenjamin700/ort-vision-sdk-web` (onnxruntime-web externalized; install it only if you use vision). Plus browser capture primitives: `useCameraStream` (rear-camera `getUserMedia` + classified errors + `retry()`), `computeImageLuminance`/`isLuminanceAcceptable`/`LowLuminanceError`/`LUMINANCE_SAMPLE_MAX_EDGE` (BT.709 brightness), `useLiveLuminance` (live rAF sampling) |
|
|
281
|
+
| `tabular` _(subpath `tempest-react-sdk/tabular`, opt. peer `onnxruntime-web`)_ | `TabularPredictor`, `useTabularPredictor` — scikit-learn models exported by `tempest-fastapi-sdk`'s `export_sklearn_to_onnx`, running in the browser with no inference server. Offline by design: `fetchModelBytes`/`isModelCached`/`cacheModelBytes`/`clearModelCache` (Cache Storage, cache-first) plus `configureOrtAssets`/`ortAssetUrls`/`ORT_WASM_ASSETS` for the runtime's WebAssembly binaries, which ONNX Runtime Web fetches at session creation. Import the default `onnxruntime-web` entry — the WebGPU build has no `ai.onnx.ml` kernels |
|
|
282
|
+
| `imaging` _(subpath `tempest-react-sdk/imaging`, no deps)_ | `resizeImage`, `cropImage`, `rotateImage`, `flipImage`, `compressToTarget` (binary-searches quality into a byte budget), `createThumbnails` (several sizes from one decode), `decodeImage`/`readImageInfo`, `encodeImage`, `supportsImageType`/`bestSupportedType`, plus `useImagePreview`/`useImageProcessing`. Canvas only — decodes with EXIF orientation applied (phone photos arrive upright), reports the format actually produced (asking for AVIF where there is no encoder silently returns PNG), and re-encoding drops EXIF, which is the privacy step as much as the bandwidth one |
|
|
281
283
|
| `audio` | `createAudioPlayer`, `playAudio`, `stopAudio`, `useAudio` |
|
|
282
284
|
| `offline` _(peer: `dexie`)_ | `createOfflineStore`, `createOfflineSync` (outbox + delta-pull + watermark engine, now with `subscribe`/`getState`/`dispose` + cross-tab `crossTab`), `useOfflineSync`/`useSyncStatus` (reactive hooks), `lastWriteWins`/`higherVersionWins` (conflict resolvers), types: `OfflineStore`, `OfflineStoreConfig`, `ListOptions`, `OfflineSync`, `OfflineSyncConfig`, `OutboxEntry`, `PullPage`, `SyncPhase`, `SyncState`, `SyncRunSummary`, `SyncTrigger`, `SyncStatus`, `SyncTone`, `WatermarkStore` |
|
|
283
285
|
| `error-boundary` | `ErrorBoundary`, `useErrorHandler`, types: `ErrorBoundaryProps`, `ErrorBoundaryRenderProps` |
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Which source files the design analysis is allowed to judge. Generated code and
|
|
2
|
+
// type declarations are the author's output, not the author's writing: reporting
|
|
3
|
+
// a 4000-line `icons.generated.ts` as "too big to read" buries the one component
|
|
4
|
+
// that really is.
|
|
5
|
+
import { readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
const SKIP_DIRS = new Set([
|
|
9
|
+
"node_modules",
|
|
10
|
+
"dist",
|
|
11
|
+
"build",
|
|
12
|
+
"out",
|
|
13
|
+
"coverage",
|
|
14
|
+
"public",
|
|
15
|
+
"vendor",
|
|
16
|
+
".git",
|
|
17
|
+
".vite",
|
|
18
|
+
".next",
|
|
19
|
+
".turbo",
|
|
20
|
+
".cache",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs"];
|
|
24
|
+
|
|
25
|
+
/** Above this size a file is treated as generated, not authored. */
|
|
26
|
+
const MAX_BYTES = 256 * 1024;
|
|
27
|
+
|
|
28
|
+
/** Cap on files opened, so `tempest doctor` stays fast on a huge tree. */
|
|
29
|
+
const MAX_FILES = 1200;
|
|
30
|
+
|
|
31
|
+
/** True for a path the analysis must not judge. */
|
|
32
|
+
export function isExcluded(path) {
|
|
33
|
+
return (
|
|
34
|
+
path.endsWith(".d.ts") ||
|
|
35
|
+
/\.generated\.[jt]sx?$/.test(path) ||
|
|
36
|
+
/[/\\]generated[/\\]/.test(path)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Collect the project's own source files.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} params
|
|
44
|
+
* @param {string} params.root - Project root; relative targets resolve against it.
|
|
45
|
+
* @param {string[]} [params.targets] - Positional paths from the command line.
|
|
46
|
+
* @param {number} [params.maxFiles]
|
|
47
|
+
* @returns {{ files: string[], skipped: Array<{ path: string, reason: string }>, truncated: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
export function collectSources({ root, targets = ["src", "app"], maxFiles = MAX_FILES }) {
|
|
50
|
+
const files = new Set();
|
|
51
|
+
const skipped = [];
|
|
52
|
+
let truncated = false;
|
|
53
|
+
|
|
54
|
+
const consider = (path) => {
|
|
55
|
+
if (!EXTENSIONS.some((ext) => path.endsWith(ext)) || files.has(path)) return;
|
|
56
|
+
if (isExcluded(path)) return;
|
|
57
|
+
if (files.size >= maxFiles) {
|
|
58
|
+
truncated = true;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let size;
|
|
62
|
+
try {
|
|
63
|
+
size = statSync(path).size;
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (size > MAX_BYTES) {
|
|
68
|
+
skipped.push({ path, reason: "larger than 256 KB — treated as generated" });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
files.add(path);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const walk = (dir) => {
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
78
|
+
} catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
83
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
84
|
+
const full = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) walk(full);
|
|
86
|
+
else if (entry.isFile()) consider(full);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
for (const target of targets) {
|
|
91
|
+
const full = resolve(root, target);
|
|
92
|
+
let stat;
|
|
93
|
+
try {
|
|
94
|
+
stat = statSync(full);
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (stat.isDirectory()) walk(full);
|
|
99
|
+
else if (stat.isFile()) consider(full);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { files: [...files].sort((a, b) => a.localeCompare(b)), skipped, truncated };
|
|
103
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// The finding vocabulary of the design analysis, and how loud each one is.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here is `error`, and that is deliberate. A size limit is a heuristic:
|
|
4
|
+
// `Calendar` and `ImageCropper` in this very SDK exceed 150 lines for a reason
|
|
5
|
+
// written in their JSDoc. Failing `tempest doctor`'s exit code on a heuristic
|
|
6
|
+
// would make the command unusable in CI for any existing project, and the first
|
|
7
|
+
// fix people reach for is silencing the tool. `warn` shows up, someone decides.
|
|
8
|
+
//
|
|
9
|
+
// The hard gates stay where they belong: `no-explicit-any` as an ESLint `error`
|
|
10
|
+
// and `tsc --noEmit` in CI.
|
|
11
|
+
|
|
12
|
+
/** Severity of each finding code. */
|
|
13
|
+
export const SEVERITY = {
|
|
14
|
+
"file-lines": "warn",
|
|
15
|
+
"function-lines": "warn",
|
|
16
|
+
"hook-lines": "warn",
|
|
17
|
+
"props-count": "warn",
|
|
18
|
+
"param-count": "warn",
|
|
19
|
+
"explicit-any": "warn",
|
|
20
|
+
"ts-ignore": "warn",
|
|
21
|
+
"fetch-in-component": "warn",
|
|
22
|
+
"empty-catch": "warn",
|
|
23
|
+
"inline-style-literal": "warn",
|
|
24
|
+
"marker-without-reason": "warn",
|
|
25
|
+
acknowledged: "info",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Default thresholds — the numbers documented in docs/design/limits.md. */
|
|
29
|
+
export const LIMITS = {
|
|
30
|
+
componentFileLines: 150,
|
|
31
|
+
moduleFileLines: 200,
|
|
32
|
+
functionLines: 80,
|
|
33
|
+
hookLines: 100,
|
|
34
|
+
props: 7,
|
|
35
|
+
params: 3,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a finding.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} code - A key of {@link SEVERITY}.
|
|
42
|
+
* @param {object} params
|
|
43
|
+
* @param {string} params.file - Path as it should be reported (project-relative).
|
|
44
|
+
* @param {number} params.line - 1-based line.
|
|
45
|
+
* @param {string} params.message - What is over the line, and what to do about it.
|
|
46
|
+
* @param {object} [params.extra] - Extra fields carried through to the reporter.
|
|
47
|
+
* @returns {{ code: string, severity: string, file: string, line: number, message: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function finding(code, { file, line, message, extra = {} }) {
|
|
50
|
+
return { code, severity: SEVERITY[code] ?? "warn", file, line, message, ...extra };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sort findings by severity, then file, then line — the reporting order. */
|
|
54
|
+
export function sortFindings(findings) {
|
|
55
|
+
const rank = { error: 0, warn: 1, info: 2 };
|
|
56
|
+
return [...findings].sort(
|
|
57
|
+
(a, b) =>
|
|
58
|
+
(rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) ||
|
|
59
|
+
a.file.localeCompare(b.file) ||
|
|
60
|
+
a.line - b.line ||
|
|
61
|
+
a.code.localeCompare(b.code),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Count findings per severity. */
|
|
66
|
+
export function countBySeverity(findings) {
|
|
67
|
+
const counts = { warn: 0, info: 0 };
|
|
68
|
+
for (const f of findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1;
|
|
69
|
+
return counts;
|
|
70
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Locate the functions in a masked source file and measure them. No TypeScript
|
|
2
|
+
// parser on purpose: `doctor` must run in a project that has not installed
|
|
3
|
+
// anything yet, and the numbers it reports (body length, parameter count, prop
|
|
4
|
+
// count) are exactly the ones a brace scan can get right.
|
|
5
|
+
import { countCodeLines, lineAt, matchPair } from "./mask.mjs";
|
|
6
|
+
|
|
7
|
+
const DECLARATIONS = [
|
|
8
|
+
/\b(export\s+(?:default\s+)?)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*(?:<[^(){}]*>)?\s*\(/g,
|
|
9
|
+
/\b(export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;()]*)?=\s*(?:async\s+)?(?:function\s*(?:<[^(){}]*>)?\s*)?\(/g,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// Destructured keys that are plumbing, not decisions: they forward the caller's
|
|
13
|
+
// DOM attributes instead of adding a case to the component. Counting them made
|
|
14
|
+
// every SDK primitive that spreads `...rest` look like two components — the
|
|
15
|
+
// loudest false positive in the first run of this rule.
|
|
16
|
+
const PLUMBING_KEYS = new Set(["className", "children", "style", "id", "ref", "key"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Split a parameter or member list at top-level separators, ignoring nested
|
|
20
|
+
* groups.
|
|
21
|
+
*
|
|
22
|
+
* Angle brackets count as a group so `Record<string, number>` stays one item, but
|
|
23
|
+
* the `>` of an arrow (`=>`) does not — a default value like `onPick = () => {}`
|
|
24
|
+
* otherwise drove the depth negative and every later comma read as a new
|
|
25
|
+
* parameter. The depth is clamped at zero for the same reason: one unbalanced
|
|
26
|
+
* `>` must not turn the rest of the list into phantom items.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} text
|
|
29
|
+
* @param {string} [separator]
|
|
30
|
+
* @returns {string[]}
|
|
31
|
+
*/
|
|
32
|
+
export function splitTopLevel(text, separator = ",") {
|
|
33
|
+
const parts = [];
|
|
34
|
+
let depth = 0;
|
|
35
|
+
let current = "";
|
|
36
|
+
let previous = "";
|
|
37
|
+
for (const ch of text) {
|
|
38
|
+
const isArrowTail = ch === ">" && previous === "=";
|
|
39
|
+
if ("([{<".includes(ch)) depth += 1;
|
|
40
|
+
else if (")]}>".includes(ch) && !isArrowTail) depth = Math.max(0, depth - 1);
|
|
41
|
+
if (ch === separator && depth === 0) {
|
|
42
|
+
parts.push(current);
|
|
43
|
+
current = "";
|
|
44
|
+
previous = ch;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
current += ch;
|
|
48
|
+
previous = ch;
|
|
49
|
+
}
|
|
50
|
+
if (current.trim()) parts.push(current);
|
|
51
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Keys of an inline destructured object pattern that represent a real choice.
|
|
56
|
+
*
|
|
57
|
+
* `{ variant, size, className, children, ...rest }` counts 2: the rest element
|
|
58
|
+
* and the DOM plumbing forward the caller's attributes rather than adding a case
|
|
59
|
+
* the component has to handle.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} param - First parameter text.
|
|
62
|
+
* @returns {number} Key count, or 0 when the parameter is not a pattern.
|
|
63
|
+
*/
|
|
64
|
+
export function destructuredKeyCount(param) {
|
|
65
|
+
const open = param.indexOf("{");
|
|
66
|
+
if (open !== 0) return 0;
|
|
67
|
+
const close = matchPair(param, open, "{", "}");
|
|
68
|
+
if (close === -1) return 0;
|
|
69
|
+
return splitTopLevel(param.slice(open + 1, close)).filter((key) => {
|
|
70
|
+
if (key.startsWith("...")) return false;
|
|
71
|
+
const name = key.split(/[:=]/)[0].trim();
|
|
72
|
+
return !PLUMBING_KEYS.has(name);
|
|
73
|
+
}).length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Find the body of a callable whose parameter list closes at `closeParen`.
|
|
78
|
+
*
|
|
79
|
+
* Returns null for an expression-bodied arrow (`const f = (a) => a + 1`): it has
|
|
80
|
+
* no block to measure, and any such function is already below every limit.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} masked
|
|
83
|
+
* @param {number} closeParen
|
|
84
|
+
* @returns {{ start: number, end: number } | null}
|
|
85
|
+
*/
|
|
86
|
+
function findBody(masked, closeParen) {
|
|
87
|
+
for (let i = closeParen + 1; i < masked.length; i += 1) {
|
|
88
|
+
const ch = masked[i];
|
|
89
|
+
if (ch === "{") {
|
|
90
|
+
const end = matchPair(masked, i, "{", "}");
|
|
91
|
+
return end === -1 ? null : { start: i, end };
|
|
92
|
+
}
|
|
93
|
+
if (ch === ";" || ch === "\n") {
|
|
94
|
+
if (ch === ";") return null;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!/[\s=>:|&?[\]<>(),."'`\w$]/.test(ch)) return null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify a declaration by name and file kind.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} name
|
|
106
|
+
* @param {boolean} isTsx
|
|
107
|
+
* @returns {"hook" | "component" | "function"}
|
|
108
|
+
*/
|
|
109
|
+
export function classify(name, isTsx) {
|
|
110
|
+
if (/^use[A-Z]/.test(name)) return "hook";
|
|
111
|
+
if (isTsx && /^[A-Z]/.test(name)) return "component";
|
|
112
|
+
return "function";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Measure every function in a masked file.
|
|
117
|
+
*
|
|
118
|
+
* `bodyLines` spans the opening brace through the closing one, blank and
|
|
119
|
+
* comment-only lines excluded — the same thing `max-lines-per-function` with
|
|
120
|
+
* `skipBlankLines` + `skipComments` counts, minus the signature line.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} masked - Output of `maskSource`.
|
|
123
|
+
* @param {object} [options]
|
|
124
|
+
* @param {boolean} [options.isTsx] - Whether components may be detected.
|
|
125
|
+
* @returns {Array<{ name: string, kind: string, line: number, bodyLines: number,
|
|
126
|
+
* params: string[], destructuredProps: number, exported: boolean }>}
|
|
127
|
+
*/
|
|
128
|
+
export function findFunctions(masked, { isTsx = false } = {}) {
|
|
129
|
+
const found = new Map();
|
|
130
|
+
|
|
131
|
+
for (const pattern of DECLARATIONS) {
|
|
132
|
+
const re = new RegExp(pattern.source, "g");
|
|
133
|
+
let match;
|
|
134
|
+
while ((match = re.exec(masked))) {
|
|
135
|
+
const name = match[2];
|
|
136
|
+
const exported = Boolean(match[1]);
|
|
137
|
+
const openParen = masked.indexOf("(", match.index + match[0].length - 1);
|
|
138
|
+
if (openParen === -1) continue;
|
|
139
|
+
const closeParen = matchPair(masked, openParen, "(", ")");
|
|
140
|
+
if (closeParen === -1) continue;
|
|
141
|
+
const body = findBody(masked, closeParen);
|
|
142
|
+
if (!body) continue;
|
|
143
|
+
if (found.has(body.start)) continue;
|
|
144
|
+
|
|
145
|
+
const params = splitTopLevel(masked.slice(openParen + 1, closeParen));
|
|
146
|
+
found.set(body.start, {
|
|
147
|
+
name,
|
|
148
|
+
kind: classify(name, isTsx),
|
|
149
|
+
exported,
|
|
150
|
+
line: lineAt(masked, match.index),
|
|
151
|
+
bodyLines: countCodeLines(masked.slice(body.start, body.end + 1)),
|
|
152
|
+
params,
|
|
153
|
+
destructuredProps: params.length === 1 ? destructuredKeyCount(params[0]) : 0,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return [...found.values()].sort((a, b) => a.line - b.line);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Member count of every `…Props` type/interface in the file, keyed by the type
|
|
163
|
+
* name.
|
|
164
|
+
*
|
|
165
|
+
* A union (`type XProps = { as: "a" } | { as: "b"; href: string }`) is skipped:
|
|
166
|
+
* its member count belongs to the branches, not to the alias — and a union is the
|
|
167
|
+
* shape the docs recommend precisely to keep each branch small.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} masked
|
|
170
|
+
* @returns {Map<string, { count: number, line: number }>}
|
|
171
|
+
*/
|
|
172
|
+
export function findPropsTypes(masked) {
|
|
173
|
+
const out = new Map();
|
|
174
|
+
const re = /\b(?:interface|type)\s+([A-Za-z_$][\w$]*Props)\b([^{;]*)/g;
|
|
175
|
+
let match;
|
|
176
|
+
while ((match = re.exec(masked))) {
|
|
177
|
+
const open = masked.indexOf("{", match.index + match[0].length - 1);
|
|
178
|
+
if (open === -1) continue;
|
|
179
|
+
const close = matchPair(masked, open, "{", "}");
|
|
180
|
+
if (close === -1) continue;
|
|
181
|
+
if (/^\s*[|&]/.test(masked.slice(close + 1))) continue;
|
|
182
|
+
const members = splitTopLevel(masked.slice(open + 1, close), ";").filter((member) =>
|
|
183
|
+
/^(readonly\s+)?\[?["']?[\w$]+["']?\]?\??\s*[:(]/.test(member),
|
|
184
|
+
);
|
|
185
|
+
out.set(match[1], { count: members.length, line: lineAt(masked, match.index) });
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classify,
|
|
5
|
+
destructuredKeyCount,
|
|
6
|
+
findFunctions,
|
|
7
|
+
findPropsTypes,
|
|
8
|
+
splitTopLevel,
|
|
9
|
+
} from "./functions.mjs";
|
|
10
|
+
import { maskSource } from "./mask.mjs";
|
|
11
|
+
|
|
12
|
+
const scan = (source) => findFunctions(maskSource(source).masked, { isTsx: true });
|
|
13
|
+
|
|
14
|
+
describe("splitTopLevel", () => {
|
|
15
|
+
it("keeps a generic type argument as one item", () => {
|
|
16
|
+
expect(splitTopLevel("a: Record<string, number>, b: string")).toEqual([
|
|
17
|
+
"a: Record<string, number>",
|
|
18
|
+
"b: string",
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("does not count the > of an arrow default as a closing bracket", () => {
|
|
23
|
+
expect(splitTopLevel("onPick = () => {}, other, third")).toEqual([
|
|
24
|
+
"onPick = () => {}",
|
|
25
|
+
"other",
|
|
26
|
+
"third",
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("splits members on a custom separator", () => {
|
|
31
|
+
expect(splitTopLevel("a: string; b: number;", ";")).toEqual(["a: string", "b: number"]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("destructuredKeyCount", () => {
|
|
36
|
+
it("ignores the rest element and DOM plumbing", () => {
|
|
37
|
+
expect(destructuredKeyCount("{ variant, size, className, children, ...rest }")).toBe(2);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("counts keys carrying defaults once", () => {
|
|
41
|
+
expect(destructuredKeyCount('{ variant = "info", size = "md" }')).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns 0 for a non-pattern parameter", () => {
|
|
45
|
+
expect(destructuredKeyCount("props: CardProps")).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("classify", () => {
|
|
50
|
+
it("recognises hooks, components and plain functions", () => {
|
|
51
|
+
expect(classify("useOrders", false)).toBe("hook");
|
|
52
|
+
expect(classify("OrderTable", true)).toBe("component");
|
|
53
|
+
expect(classify("OrderTable", false)).toBe("function");
|
|
54
|
+
expect(classify("sortBy", true)).toBe("function");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("findFunctions", () => {
|
|
59
|
+
it("measures a declaration body in code lines, blank lines excluded", () => {
|
|
60
|
+
const [fn] = scan(
|
|
61
|
+
["export function a() {", " const x = 1;", "", " return x;", "}"].join("\n"),
|
|
62
|
+
);
|
|
63
|
+
expect(fn.name).toBe("a");
|
|
64
|
+
expect(fn.exported).toBe(true);
|
|
65
|
+
expect(fn.bodyLines).toBe(4);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("measures an arrow assigned to a const", () => {
|
|
69
|
+
const [fn] = scan("const helper = (a, b) => {\n return a + b;\n};");
|
|
70
|
+
expect(fn.name).toBe("helper");
|
|
71
|
+
expect(fn.exported).toBe(false);
|
|
72
|
+
expect(fn.params).toEqual(["a", "b"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("skips an expression-bodied arrow", () => {
|
|
76
|
+
expect(scan("const double = (n) => n * 2;")).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("counts one destructured parameter as one parameter", () => {
|
|
80
|
+
const [fn] = scan("export function Card({ a, b, c }: CardProps) {\n return null;\n}");
|
|
81
|
+
expect(fn.params).toHaveLength(1);
|
|
82
|
+
expect(fn.destructuredProps).toBe(3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("handles a generic component signature", () => {
|
|
86
|
+
const [fn] = scan(
|
|
87
|
+
"export function List<T>({ items, render }: ListProps<T>) {\n return null;\n}",
|
|
88
|
+
);
|
|
89
|
+
expect(fn.name).toBe("List");
|
|
90
|
+
expect(fn.params).toHaveLength(1);
|
|
91
|
+
expect(fn.destructuredProps).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reports each function once", () => {
|
|
95
|
+
const found = scan("export function a() {\n return 1;\n}\nfunction b() {\n return 2;\n}");
|
|
96
|
+
expect(found.map((f) => f.name)).toEqual(["a", "b"]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("findPropsTypes", () => {
|
|
101
|
+
it("counts members of an interface", () => {
|
|
102
|
+
const types = findPropsTypes(
|
|
103
|
+
maskSource("interface CardProps {\n a: string;\n b?: number;\n}").masked,
|
|
104
|
+
);
|
|
105
|
+
expect(types.get("CardProps").count).toBe(2);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("counts members of a type literal", () => {
|
|
109
|
+
const types = findPropsTypes(maskSource("type CardProps = {\n a: string;\n};").masked);
|
|
110
|
+
expect(types.get("CardProps").count).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("skips a union alias", () => {
|
|
114
|
+
const types = findPropsTypes(
|
|
115
|
+
maskSource('type CardProps = { as: "a" } | { as: "b"; href: string };').masked,
|
|
116
|
+
);
|
|
117
|
+
expect(types.has("CardProps")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not count a nested object as a separate member", () => {
|
|
121
|
+
const types = findPropsTypes(
|
|
122
|
+
maskSource("interface CardProps {\n meta: { a: string; b: string };\n c: number;\n}")
|
|
123
|
+
.masked,
|
|
124
|
+
);
|
|
125
|
+
expect(types.get("CardProps").count).toBe(2);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Public surface of the design analysis: read the project's sources, run the
|
|
2
|
+
// rules over each one, and report the numbers the caller needs to say something
|
|
3
|
+
// useful — how big the median file is, what went over a limit, and which limits
|
|
4
|
+
// were waived on purpose.
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { relative } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { collectSources } from "./collect.mjs";
|
|
9
|
+
import { countBySeverity, LIMITS, sortFindings } from "./findings.mjs";
|
|
10
|
+
import { scanFile } from "./scan.mjs";
|
|
11
|
+
|
|
12
|
+
export { LIMITS, SEVERITY } from "./findings.mjs";
|
|
13
|
+
export { scanFile } from "./scan.mjs";
|
|
14
|
+
|
|
15
|
+
/** Median of a numeric list, or 0 when empty. */
|
|
16
|
+
function median(values) {
|
|
17
|
+
if (values.length === 0) return 0;
|
|
18
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
19
|
+
const mid = Math.floor(sorted.length / 2);
|
|
20
|
+
return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run the design analysis over a project.
|
|
25
|
+
*
|
|
26
|
+
* @param {object} params
|
|
27
|
+
* @param {string} params.root - Project root.
|
|
28
|
+
* @param {string[]} [params.targets] - Paths to analyze (default: `src`, `app`).
|
|
29
|
+
* @param {object} [params.limits] - Overrides for {@link LIMITS}.
|
|
30
|
+
* @param {number} [params.maxFiles]
|
|
31
|
+
* @returns {{
|
|
32
|
+
* findings: object[],
|
|
33
|
+
* waivers: Array<{ file: string, code: string, reason: string }>,
|
|
34
|
+
* counts: { warn: number, info: number },
|
|
35
|
+
* stats: { files: number, codeLines: number, medianLines: number, largest: { file: string, lines: number } | null },
|
|
36
|
+
* skipped: Array<{ file: string, reason: string }>,
|
|
37
|
+
* truncated: boolean
|
|
38
|
+
* }}
|
|
39
|
+
*/
|
|
40
|
+
export function analyzeDesign({ root, targets, limits = {}, maxFiles } = {}) {
|
|
41
|
+
const collected = collectSources({ root, targets, maxFiles });
|
|
42
|
+
const findings = [];
|
|
43
|
+
const waivers = [];
|
|
44
|
+
const lineCounts = [];
|
|
45
|
+
let largest = null;
|
|
46
|
+
let codeLines = 0;
|
|
47
|
+
|
|
48
|
+
for (const path of collected.files) {
|
|
49
|
+
const file = relative(root, path) || path;
|
|
50
|
+
let source;
|
|
51
|
+
try {
|
|
52
|
+
source = readFileSync(path, "utf8");
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const result = scanFile({ file, source, limits });
|
|
57
|
+
findings.push(...result.findings);
|
|
58
|
+
waivers.push(...result.waivers.map((w) => ({ file, ...w })));
|
|
59
|
+
lineCounts.push(result.codeLines);
|
|
60
|
+
codeLines += result.codeLines;
|
|
61
|
+
if (!largest || result.codeLines > largest.lines) {
|
|
62
|
+
largest = { file, lines: result.codeLines };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sorted = sortFindings(findings);
|
|
67
|
+
return {
|
|
68
|
+
findings: sorted,
|
|
69
|
+
waivers,
|
|
70
|
+
counts: countBySeverity(sorted),
|
|
71
|
+
stats: {
|
|
72
|
+
files: collected.files.length,
|
|
73
|
+
codeLines,
|
|
74
|
+
medianLines: median(lineCounts),
|
|
75
|
+
largest,
|
|
76
|
+
},
|
|
77
|
+
skipped: collected.skipped.map((s) => ({ file: relative(root, s.path), reason: s.reason })),
|
|
78
|
+
truncated: collected.truncated,
|
|
79
|
+
};
|
|
80
|
+
}
|