@mifort-solutions/qmetrix 1.0.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/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/audit-structure.mjs +103 -0
- package/src/bundle-codebase.mjs +626 -0
- package/src/check-images.mjs +125 -0
- package/src/coverage/clean.mjs +32 -0
- package/src/coverage/merge-istanbul.mjs +161 -0
- package/src/coverage/next-start-cov.mjs +38 -0
- package/src/coverage/report-global.mjs +90 -0
- package/src/coverage/report-suite.mjs +148 -0
- package/src/coverage/src-filter.mjs +50 -0
- package/src/dashboard/collectors/code.mjs +104 -0
- package/src/dashboard/collectors/composition-meta.mjs +295 -0
- package/src/dashboard/collectors/composition-transitions.mjs +0 -0
- package/src/dashboard/collectors/composition.mjs +360 -0
- package/src/dashboard/collectors/coverage.mjs +98 -0
- package/src/dashboard/collectors/deps.mjs +187 -0
- package/src/dashboard/collectors/entities.mjs +147 -0
- package/src/dashboard/collectors/graph.mjs +105 -0
- package/src/dashboard/collectors/lint.mjs +117 -0
- package/src/dashboard/collectors/routing.mjs +82 -0
- package/src/dashboard/collectors/security.mjs +182 -0
- package/src/dashboard/collectors/storybook.mjs +33 -0
- package/src/dashboard/config.mjs +15 -0
- package/src/dashboard/render/client.mjs +178 -0
- package/src/dashboard/render/components.mjs +247 -0
- package/src/dashboard/render/composition.mjs +192 -0
- package/src/dashboard/render/styles.mjs +217 -0
- package/src/dashboard/render/template.mjs +283 -0
- package/src/dashboard/utils/exec.mjs +29 -0
- package/src/dashboard/utils/format.mjs +32 -0
- package/src/dashboard/utils/fs.mjs +48 -0
- package/src/e2e-server-guard.mjs +283 -0
- package/src/optimize-images.mjs +231 -0
- package/src/quality-dashboard.mjs +291 -0
- package/src/security-scan.mjs +267 -0
- package/src/test-outline.mjs +98 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6. Component composition — an app-rooted containment tree of feature widgets.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard draws this as nested rectangles: one box per app-router root
|
|
5
|
+
* (page.tsx / layout.tsx) with every feature component it renders nested inside,
|
|
6
|
+
* recursively, following "renders" relationships (a JSX <Tag>, a Next default
|
|
7
|
+
* re-export, or a `dynamic()`/`lazy()` import whose binding is a component).
|
|
8
|
+
* Design-system widgets from src/common are deliberately NOT shown — this view is
|
|
9
|
+
* about how the *features* compose, not the shared UI kit.
|
|
10
|
+
*
|
|
11
|
+
* Each box carries the metadata the renderer prints next to it (description,
|
|
12
|
+
* props, state, events, public API) — see collectors/composition-meta.mjs for the
|
|
13
|
+
* extraction. Feature barrels (`@/features/x` → index.ts) are followed through to
|
|
14
|
+
* the concrete component file. Cycles are broken per branch; a component rendered
|
|
15
|
+
* by several parents is intentionally duplicated under each (containment tree, not
|
|
16
|
+
* a DAG). Feature components never reached from a page are surfaced in a separate
|
|
17
|
+
* "unattached" list so dead/unused components are not silently dropped.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
import { SRC } from '../config.mjs';
|
|
23
|
+
import { resolveImport } from './graph.mjs';
|
|
24
|
+
import { rel, walk } from '../utils/fs.mjs';
|
|
25
|
+
import {
|
|
26
|
+
extractDescription,
|
|
27
|
+
extractEvents,
|
|
28
|
+
extractExports,
|
|
29
|
+
extractProps,
|
|
30
|
+
extractState,
|
|
31
|
+
isComponentFile,
|
|
32
|
+
parseBindings,
|
|
33
|
+
primaryName,
|
|
34
|
+
stripComments,
|
|
35
|
+
usedJsxTags,
|
|
36
|
+
} from './composition-meta.mjs';
|
|
37
|
+
import { buildTransitions, siteHost } from './composition-transitions.mjs';
|
|
38
|
+
|
|
39
|
+
const APP_DIR = path.join(SRC, 'app');
|
|
40
|
+
const FEATURES_DIR = path.join(SRC, 'features');
|
|
41
|
+
const COMMON_DIR = path.join(SRC, 'common');
|
|
42
|
+
|
|
43
|
+
const CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
44
|
+
const EXCLUDE_RE = /\.(test|spec|stories)\.[jt]sx?$/;
|
|
45
|
+
const APP_ROOT_RE = /(^|\/)(page|layout)\.[jt]sx?$/;
|
|
46
|
+
const INDEX_RE = /(^|\/)index\.tsx?$/;
|
|
47
|
+
const MAX_DEPTH = 9;
|
|
48
|
+
|
|
49
|
+
function layerOf(abs) {
|
|
50
|
+
if (abs === APP_DIR || abs.startsWith(APP_DIR + path.sep)) {
|
|
51
|
+
return 'app';
|
|
52
|
+
}
|
|
53
|
+
if (abs.startsWith(FEATURES_DIR + path.sep)) {
|
|
54
|
+
return 'feature';
|
|
55
|
+
}
|
|
56
|
+
if (abs.startsWith(COMMON_DIR + path.sep)) {
|
|
57
|
+
return 'common';
|
|
58
|
+
}
|
|
59
|
+
return 'other';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const featureOf = (relPath) => relPath.match(/^src\/features\/([^/]+)\//)?.[1] ?? null;
|
|
63
|
+
const appKind = (relPath) => (/(^|\/)layout\.[jt]sx?$/.test(relPath) ? 'layout' : 'page');
|
|
64
|
+
const routeOf = (relPath) => {
|
|
65
|
+
const r = relPath.replace(/^src\/app/, '').replace(/\/(page|layout)\.[jt]sx?$/, '');
|
|
66
|
+
return r === '' ? '/' : r;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Index every (production) code file under app / features / common. */
|
|
70
|
+
function indexFiles() {
|
|
71
|
+
const fileByAbs = new Map();
|
|
72
|
+
for (const dir of [APP_DIR, FEATURES_DIR, COMMON_DIR]) {
|
|
73
|
+
for (const abs of walk(dir)) {
|
|
74
|
+
if (!CODE_RE.test(abs) || abs.endsWith('.d.ts') || EXCLUDE_RE.test(abs)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
let raw = '';
|
|
78
|
+
try {
|
|
79
|
+
raw = readFileSync(abs, 'utf8');
|
|
80
|
+
} catch {
|
|
81
|
+
raw = '';
|
|
82
|
+
}
|
|
83
|
+
fileByAbs.set(abs, {
|
|
84
|
+
abs,
|
|
85
|
+
rel: rel(abs),
|
|
86
|
+
layer: layerOf(abs),
|
|
87
|
+
raw,
|
|
88
|
+
code: stripComments(raw),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return fileByAbs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** feature → Set of names it re-exports through index.ts (its public API). */
|
|
96
|
+
function computePublicNames(fileByAbs) {
|
|
97
|
+
const map = new Map();
|
|
98
|
+
for (const f of fileByAbs.values()) {
|
|
99
|
+
if (f.layer === 'feature' && /\/index\.ts$/.test(f.rel)) {
|
|
100
|
+
const feat = f.rel.match(/^src\/features\/([^/]+)\//)?.[1];
|
|
101
|
+
if (feat) {
|
|
102
|
+
map.set(feat, new Set(extractExports(f.code)));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return map;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The component names a file "renders": JSX tags + default re-export + lazy. */
|
|
110
|
+
function renderedNames(code) {
|
|
111
|
+
const names = usedJsxTags(code);
|
|
112
|
+
const def = code.match(/export\s+default\s+([A-Za-z_]\w*)\s*;/);
|
|
113
|
+
if (def) {
|
|
114
|
+
names.add(def[1]);
|
|
115
|
+
}
|
|
116
|
+
let m;
|
|
117
|
+
const asDefaultRe = /export\s*\{([^}]*)\}/g;
|
|
118
|
+
while ((m = asDefaultRe.exec(code))) {
|
|
119
|
+
for (const part of m[1].split(',')) {
|
|
120
|
+
const as = part.trim().match(/^([A-Za-z_]\w*)\s+as\s+default$/);
|
|
121
|
+
if (as) {
|
|
122
|
+
names.add(as[1]);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return names;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function collectComposition() {
|
|
130
|
+
const fileByAbs = indexFiles();
|
|
131
|
+
const publicNames = computePublicNames(fileByAbs);
|
|
132
|
+
const components = {}; // id (rel path) → serialisable descriptor
|
|
133
|
+
const descCache = new Map();
|
|
134
|
+
const barrelCache = new Map();
|
|
135
|
+
const childCache = new Map();
|
|
136
|
+
|
|
137
|
+
// ── Component descriptors (cached by abs path) ──
|
|
138
|
+
function descriptorFor(abs) {
|
|
139
|
+
if (descCache.has(abs)) {
|
|
140
|
+
return descCache.get(abs);
|
|
141
|
+
}
|
|
142
|
+
const f = fileByAbs.get(abs);
|
|
143
|
+
if (!f) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const cname = primaryName(f.code, f.rel);
|
|
147
|
+
const props = extractProps(f.code, cname);
|
|
148
|
+
const feature = f.layer === 'feature' ? featureOf(f.rel) : null;
|
|
149
|
+
const isPublic = f.layer === 'feature' && (publicNames.get(feature)?.has(cname) ?? false);
|
|
150
|
+
// App-layer files split into route roots (page/layout) and plain helpers
|
|
151
|
+
// (providers.tsx, error.tsx …) that happen to be rendered by a root.
|
|
152
|
+
const isAppRoot = f.layer === 'app' && APP_ROOT_RE.test(f.rel);
|
|
153
|
+
const role =
|
|
154
|
+
f.layer === 'app' ? (isAppRoot ? appKind(f.rel) : 'app') : isPublic ? 'public' : 'internal';
|
|
155
|
+
const d = {
|
|
156
|
+
id: f.rel,
|
|
157
|
+
name: f.layer === 'app' ? (isAppRoot ? routeOf(f.rel) : cname) : cname,
|
|
158
|
+
component: cname,
|
|
159
|
+
file: f.rel.split('/').pop(),
|
|
160
|
+
rel: f.rel,
|
|
161
|
+
layer: f.layer,
|
|
162
|
+
feature,
|
|
163
|
+
kind: f.layer === 'app' ? (isAppRoot ? appKind(f.rel) : 'component') : 'component',
|
|
164
|
+
role,
|
|
165
|
+
public: isPublic,
|
|
166
|
+
description: extractDescription(f.raw, cname),
|
|
167
|
+
props,
|
|
168
|
+
state: extractState(f.code),
|
|
169
|
+
events: extractEvents(f.code, props),
|
|
170
|
+
exports: extractExports(f.code),
|
|
171
|
+
};
|
|
172
|
+
descCache.set(abs, d);
|
|
173
|
+
components[d.id] = d;
|
|
174
|
+
return d;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Feature barrel: exported name → concrete source file ──
|
|
178
|
+
function barrelReexports(indexAbs) {
|
|
179
|
+
if (barrelCache.has(indexAbs)) {
|
|
180
|
+
return barrelCache.get(indexAbs);
|
|
181
|
+
}
|
|
182
|
+
const map = new Map();
|
|
183
|
+
const f = fileByAbs.get(indexAbs);
|
|
184
|
+
if (f) {
|
|
185
|
+
const re = /export\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
186
|
+
let m;
|
|
187
|
+
while ((m = re.exec(f.code))) {
|
|
188
|
+
const target = resolveImport(m[2], indexAbs);
|
|
189
|
+
if (!target) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
for (const part of m[1].split(',')) {
|
|
193
|
+
const seg = part.trim();
|
|
194
|
+
const as = seg.match(/\s+as\s+([A-Za-z_]\w*)/);
|
|
195
|
+
const exported = as ? as[1] : seg.split(/\s/)[0];
|
|
196
|
+
if (exported) {
|
|
197
|
+
map.set(exported, target);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
barrelCache.set(indexAbs, map);
|
|
203
|
+
return map;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── "Renders" edges: a rendered binding → its concrete component file ──
|
|
207
|
+
function childTargets(abs) {
|
|
208
|
+
if (childCache.has(abs)) {
|
|
209
|
+
return childCache.get(abs);
|
|
210
|
+
}
|
|
211
|
+
const f = fileByAbs.get(abs);
|
|
212
|
+
const out = [];
|
|
213
|
+
if (f && /\.(tsx|jsx)$/.test(f.rel)) {
|
|
214
|
+
const rendered = renderedNames(f.code);
|
|
215
|
+
if (rendered.size) {
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
// Resolve a binding against an import spec (following barrels), then keep
|
|
218
|
+
// it iff it's a shown app/feature component (common widgets are omitted).
|
|
219
|
+
const consider = (b, spec) => {
|
|
220
|
+
let real = resolveImport(spec, abs);
|
|
221
|
+
if (real && fileByAbs.has(real) && INDEX_RE.test(fileByAbs.get(real).rel)) {
|
|
222
|
+
real = barrelReexports(real).get(b);
|
|
223
|
+
}
|
|
224
|
+
if (!real || !fileByAbs.has(real) || real === abs || seen.has(real)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const layer = fileByAbs.get(real).layer;
|
|
228
|
+
if (layer === 'common' || layer === 'other') {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
seen.add(real);
|
|
232
|
+
out.push(real);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
let m;
|
|
236
|
+
const fromRe = /\bimport\s+(type\s+)?([^;]*?)\s+from\s*["']([^"']+)["']/g;
|
|
237
|
+
while ((m = fromRe.exec(f.code))) {
|
|
238
|
+
if (m[1]) {
|
|
239
|
+
continue; // type-only import — never a render
|
|
240
|
+
}
|
|
241
|
+
for (const b of parseBindings(m[2])) {
|
|
242
|
+
if (rendered.has(b)) {
|
|
243
|
+
consider(b, m[3]);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const dynRe =
|
|
248
|
+
/(?:const|let|var)\s+([A-Za-z_]\w*)\s*=\s*(?:[\w.]*\.)?(?:dynamic|lazy)\s*\(\s*(?:async\s*)?\(\s*\)\s*=>\s*import\(\s*["']([^"']+)["']/g;
|
|
249
|
+
while ((m = dynRe.exec(f.code))) {
|
|
250
|
+
if (rendered.has(m[1])) {
|
|
251
|
+
consider(m[1], m[2]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
childCache.set(abs, out);
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Build one containment tree (cycles broken per branch) ──
|
|
261
|
+
function buildTree(abs, ancestry) {
|
|
262
|
+
const d = descriptorFor(abs);
|
|
263
|
+
if (!d) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const node = { id: d.id, children: [] };
|
|
267
|
+
if (ancestry.size >= MAX_DEPTH) {
|
|
268
|
+
return node;
|
|
269
|
+
}
|
|
270
|
+
const next = new Set(ancestry).add(abs);
|
|
271
|
+
for (const childAbs of childTargets(abs)) {
|
|
272
|
+
if (ancestry.has(childAbs) || childAbs === abs) {
|
|
273
|
+
const cd = descriptorFor(childAbs);
|
|
274
|
+
if (cd) {
|
|
275
|
+
node.children.push({ id: cd.id, children: [], cycle: true });
|
|
276
|
+
}
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const sub = buildTree(childAbs, next);
|
|
280
|
+
if (sub) {
|
|
281
|
+
node.children.push(sub);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return node;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── The forest, rooted at app pages/layouts ──
|
|
288
|
+
const roots = [...fileByAbs.values()]
|
|
289
|
+
.filter((f) => f.layer === 'app' && APP_ROOT_RE.test(f.rel))
|
|
290
|
+
.sort((a, b) => {
|
|
291
|
+
const ra = routeOf(a.rel);
|
|
292
|
+
const rb = routeOf(b.rel);
|
|
293
|
+
return ra === rb ? appKind(a.rel).localeCompare(appKind(b.rel)) : ra.localeCompare(rb);
|
|
294
|
+
})
|
|
295
|
+
.map((f) => buildTree(f.abs, new Set()))
|
|
296
|
+
.filter(Boolean);
|
|
297
|
+
|
|
298
|
+
const reached = new Set();
|
|
299
|
+
const collectIds = (node) => {
|
|
300
|
+
reached.add(node.id);
|
|
301
|
+
(node.children || []).forEach(collectIds);
|
|
302
|
+
};
|
|
303
|
+
roots.forEach(collectIds);
|
|
304
|
+
|
|
305
|
+
const unattached = findUnattached(fileByAbs, reached, buildTree, collectIds);
|
|
306
|
+
const fileByRel = new Map([...fileByAbs.values()].map((f) => [f.rel, f]));
|
|
307
|
+
const transitions = buildTransitions(components, roots, fileByRel);
|
|
308
|
+
return {
|
|
309
|
+
available: roots.length > 0 || unattached.length > 0,
|
|
310
|
+
components,
|
|
311
|
+
roots,
|
|
312
|
+
unattached,
|
|
313
|
+
transitions,
|
|
314
|
+
host: siteHost(),
|
|
315
|
+
stats: stats(components, roots, unattached),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Feature components never reached from a page → flat roots (absorbing nests). */
|
|
320
|
+
function findUnattached(fileByAbs, reached, buildTree, collectIds) {
|
|
321
|
+
const candidates = [...fileByAbs.values()]
|
|
322
|
+
.filter(
|
|
323
|
+
(f) =>
|
|
324
|
+
f.layer === 'feature' &&
|
|
325
|
+
!INDEX_RE.test(f.rel) &&
|
|
326
|
+
isComponentFile(f.rel, f.code) &&
|
|
327
|
+
!reached.has(f.rel),
|
|
328
|
+
)
|
|
329
|
+
.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
330
|
+
const unattached = [];
|
|
331
|
+
const claimed = new Set(reached);
|
|
332
|
+
for (const f of candidates) {
|
|
333
|
+
if (claimed.has(f.rel)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const tree = buildTree(f.abs, new Set());
|
|
337
|
+
if (tree) {
|
|
338
|
+
collectIds(tree);
|
|
339
|
+
const mark = (n) => {
|
|
340
|
+
claimed.add(n.id);
|
|
341
|
+
(n.children || []).forEach(mark);
|
|
342
|
+
};
|
|
343
|
+
mark(tree);
|
|
344
|
+
unattached.push(tree);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return unattached;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stats(components, roots, unattached) {
|
|
351
|
+
const countBoxes = (nodes) => nodes.reduce((acc, n) => acc + 1 + countBoxes(n.children || []), 0);
|
|
352
|
+
const featureDescs = Object.values(components).filter((c) => c.layer === 'feature');
|
|
353
|
+
return {
|
|
354
|
+
pages: roots.filter((r) => components[r.id]?.kind === 'page').length,
|
|
355
|
+
layouts: roots.filter((r) => components[r.id]?.kind === 'layout').length,
|
|
356
|
+
featureComponents: featureDescs.length,
|
|
357
|
+
features: [...new Set(featureDescs.map((c) => c.feature))].filter(Boolean).sort(),
|
|
358
|
+
boxes: countBoxes(roots) + countBoxes(unattached),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** 2. Test coverage — per suite (unit / e2e / storybook) plus their global merge.
|
|
2
|
+
*
|
|
3
|
+
* Collect-only: this reads whatever `npm run coverage:*` last wrote to
|
|
4
|
+
* dist/reports/coverage/<suite>/coverage-summary.json (see dev/scripts/coverage/* and
|
|
5
|
+
* .config/jest.config.mjs). It never runs jest itself — when no summary exists the
|
|
6
|
+
* section is marked missing with the command that generates it.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { ROOT } from '../config.mjs';
|
|
12
|
+
import { rel } from '../utils/fs.mjs';
|
|
13
|
+
|
|
14
|
+
const SUITES = [
|
|
15
|
+
{ key: 'unit', label: 'Unit' },
|
|
16
|
+
{ key: 'e2e', label: 'E2E' },
|
|
17
|
+
{ key: 'storybook', label: 'Storybook' },
|
|
18
|
+
{ key: 'global', label: 'Global' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Istanbul emits the string "Unknown" for pct when a category has zero measurable
|
|
22
|
+
// items; coerce anything non-numeric to null so renderers show an em-dash.
|
|
23
|
+
const pctOf = (m) => (typeof m?.pct === 'number' ? m.pct : null);
|
|
24
|
+
|
|
25
|
+
const summaryPath = (key) =>
|
|
26
|
+
path.join(ROOT, 'dist', 'reports', 'coverage', key, 'coverage-summary.json');
|
|
27
|
+
|
|
28
|
+
// Istanbul's html reporter writes a browsable index.html next to the summary —
|
|
29
|
+
// the per-file / per-folder drill-down the dashboard links to.
|
|
30
|
+
const reportIndexPath = (key) => path.join(ROOT, 'dist', 'reports', 'coverage', key, 'index.html');
|
|
31
|
+
|
|
32
|
+
/** Link to a suite's HTML report, relative to the dashboard output dir when known. */
|
|
33
|
+
function reportHrefFor(key, outDir) {
|
|
34
|
+
const idx = reportIndexPath(key);
|
|
35
|
+
if (!existsSync(idx)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (!outDir) {
|
|
39
|
+
return rel(idx);
|
|
40
|
+
}
|
|
41
|
+
return path.relative(outDir, idx).split(path.sep).join('/');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readSuite({ key, label }, outDir) {
|
|
45
|
+
const p = summaryPath(key);
|
|
46
|
+
if (!existsSync(p)) {
|
|
47
|
+
return { key, label, available: false };
|
|
48
|
+
}
|
|
49
|
+
let data;
|
|
50
|
+
try {
|
|
51
|
+
data = JSON.parse(readFileSync(p, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return { key, label, available: false, note: `${key}: summary could not be parsed.` };
|
|
54
|
+
}
|
|
55
|
+
const total = data.total || {};
|
|
56
|
+
const files = Object.entries(data)
|
|
57
|
+
.filter(([k]) => k !== 'total')
|
|
58
|
+
.map(([file, m]) => ({ file: rel(path.resolve(ROOT, file)), lines: pctOf(m.lines) }))
|
|
59
|
+
.sort((a, b) => (a.lines ?? 101) - (b.lines ?? 101));
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
label,
|
|
63
|
+
available: true,
|
|
64
|
+
statements: pctOf(total.statements),
|
|
65
|
+
branches: pctOf(total.branches),
|
|
66
|
+
functions: pctOf(total.functions),
|
|
67
|
+
lines: pctOf(total.lines),
|
|
68
|
+
fileCount: files.length,
|
|
69
|
+
files,
|
|
70
|
+
reportHref: reportHrefFor(key, outDir),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {string} [outDir] directory the dashboard html is written to (for relative report links). */
|
|
75
|
+
export function collectCoverage(outDir) {
|
|
76
|
+
const suites = SUITES.map((s) => readSuite(s, outDir));
|
|
77
|
+
const byKey = Object.fromEntries(suites.map((s) => [s.key, s]));
|
|
78
|
+
// Headline (chip + bars + per-file table) prefers the global merge, else unit.
|
|
79
|
+
const head = byKey.global?.available ? byKey.global : byKey.unit;
|
|
80
|
+
const available = suites.some((s) => s.available);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
available,
|
|
84
|
+
note: available
|
|
85
|
+
? 'Per-suite coverage over the full src/ tree. Run `npm run coverage:all` to refresh e2e / storybook / global.'
|
|
86
|
+
: 'No coverage artifact found. Run `npm run coverage:unit` (or `npm run coverage:all`) to generate it.',
|
|
87
|
+
// Headline fields consumed by the existing summary chip + gauge bars.
|
|
88
|
+
statements: head?.statements ?? null,
|
|
89
|
+
branches: head?.branches ?? null,
|
|
90
|
+
functions: head?.functions ?? null,
|
|
91
|
+
lines: head?.lines ?? null,
|
|
92
|
+
headLabel: head?.label ?? null,
|
|
93
|
+
files: head?.files ?? [],
|
|
94
|
+
reportHref: head?.reportHref ?? null,
|
|
95
|
+
// Per-suite breakdown (files[] omitted to keep the JSON dump small).
|
|
96
|
+
suites: suites.map(({ files, ...rest }) => rest),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/** 4. Dependencies + audit — package.json deps enriched with git/npm metadata.
|
|
2
|
+
*
|
|
3
|
+
* Collect-only for the vulnerability/outdated columns: they read artifacts rather
|
|
4
|
+
* than running npm. Generate them with:
|
|
5
|
+
* npm audit --json > dist/reports/npm-audit.json
|
|
6
|
+
* npm outdated --json > dist/reports/npm-outdated.json
|
|
7
|
+
* The inventory itself (names, versions, why/when added) is read from package.json
|
|
8
|
+
* and `git log` over package.json.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { ROOT, SCRIPTS_DIR } from '../config.mjs';
|
|
14
|
+
import { exec, log } from '../utils/exec.mjs';
|
|
15
|
+
|
|
16
|
+
/** description field from an installed package's own package.json (what it is). */
|
|
17
|
+
function pkgDescription(name) {
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(
|
|
20
|
+
readFileSync(path.join(ROOT, 'node_modules', name, 'package.json'), 'utf8'),
|
|
21
|
+
);
|
|
22
|
+
return (j.description || '').trim();
|
|
23
|
+
} catch {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* For each dependency name, find the git commit that first introduced it into
|
|
30
|
+
* package.json — gives us "when added" (date) and "why / which task" (subject).
|
|
31
|
+
* One `git log -p` pass over package.json, oldest-first; first time we see a
|
|
32
|
+
* line added for a known dep name wins.
|
|
33
|
+
*/
|
|
34
|
+
function gitDepHistory(names) {
|
|
35
|
+
const want = new Set(names);
|
|
36
|
+
const info = {};
|
|
37
|
+
const r = exec('git log --reverse --date=short --format="@@C@@%h|%ad|%s" -p -- package.json', {
|
|
38
|
+
timeout: 120_000,
|
|
39
|
+
});
|
|
40
|
+
if (r.code !== 0 || !r.stdout) {
|
|
41
|
+
return info;
|
|
42
|
+
}
|
|
43
|
+
let cur = null;
|
|
44
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
45
|
+
if (line.startsWith('@@C@@')) {
|
|
46
|
+
const [hash, date, ...subj] = line.slice(5).split('|');
|
|
47
|
+
cur = { hash, date, subject: subj.join('|') };
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!cur) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const m = /^\+\s*"([^"]+)"\s*:/.exec(line); // an added "name": ... line in a hunk
|
|
54
|
+
if (m && want.has(m[1]) && !info[m[1]]) {
|
|
55
|
+
info[m[1]] = { ...cur };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return info;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const AUDIT_ARTIFACT = path.join(ROOT, 'dist', 'reports', 'npm-audit.json');
|
|
62
|
+
const OUTDATED_ARTIFACT = path.join(ROOT, 'dist', 'reports', 'npm-outdated.json');
|
|
63
|
+
const AUDIT_HINT = 'npm audit --json > dist/reports/npm-audit.json';
|
|
64
|
+
const OUTDATED_HINT = 'npm outdated --json > dist/reports/npm-outdated.json';
|
|
65
|
+
|
|
66
|
+
/** Parse a saved `npm audit --json` file into a JSON object, or null. */
|
|
67
|
+
function readJsonArtifact(file) {
|
|
68
|
+
if (!existsSync(file)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(file, 'utf8') || 'null');
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function collectDeps(importedExternals) {
|
|
79
|
+
let pkg = {};
|
|
80
|
+
try {
|
|
81
|
+
pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
82
|
+
} catch {}
|
|
83
|
+
|
|
84
|
+
const prodNames = Object.keys(pkg.dependencies || {});
|
|
85
|
+
const devNames = Object.keys(pkg.devDependencies || {});
|
|
86
|
+
const prodCount = prodNames.length;
|
|
87
|
+
const devCount = devNames.length;
|
|
88
|
+
|
|
89
|
+
// npm audit — read the saved artifact (collect-only).
|
|
90
|
+
let audit = { available: false, note: `Not generated. Run: ${AUDIT_HINT}` };
|
|
91
|
+
const vulnByPkg = {}; // name -> severity (direct or transitive entry present in the tree)
|
|
92
|
+
const auditJson = readJsonArtifact(AUDIT_ARTIFACT);
|
|
93
|
+
if (auditJson) {
|
|
94
|
+
const v = auditJson.metadata?.vulnerabilities;
|
|
95
|
+
if (v) {
|
|
96
|
+
audit = {
|
|
97
|
+
available: true,
|
|
98
|
+
info: v.info || 0,
|
|
99
|
+
low: v.low || 0,
|
|
100
|
+
moderate: v.moderate || 0,
|
|
101
|
+
high: v.high || 0,
|
|
102
|
+
critical: v.critical || 0,
|
|
103
|
+
total: v.total || 0,
|
|
104
|
+
deps: auditJson.metadata?.dependencies?.total ?? null,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
for (const [name, entry] of Object.entries(auditJson.vulnerabilities || {})) {
|
|
108
|
+
if (entry?.severity) {
|
|
109
|
+
vulnByPkg[name] = entry.severity;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// npm outdated — read the saved artifact (collect-only). `npm outdated --json`
|
|
115
|
+
// emits {} when everything is current, a {name:{current,wanted,latest}} map when
|
|
116
|
+
// packages are behind, or {"error":{code,summary,…}} when the registry call failed
|
|
117
|
+
// (e.g. offline / ECONNRESET). Only the first two are usable — an error blob must
|
|
118
|
+
// NOT be read as "0 need update", which is what made the section look illogical.
|
|
119
|
+
const outdatedByPkg = {};
|
|
120
|
+
const outdatedJson = readJsonArtifact(OUTDATED_ARTIFACT);
|
|
121
|
+
const outdatedError =
|
|
122
|
+
outdatedJson && outdatedJson.error && typeof outdatedJson.error === 'object'
|
|
123
|
+
? outdatedJson.error
|
|
124
|
+
: null;
|
|
125
|
+
const outdatedAvailable = outdatedJson != null && !outdatedError;
|
|
126
|
+
if (outdatedAvailable) {
|
|
127
|
+
for (const [name, m] of Object.entries(outdatedJson)) {
|
|
128
|
+
if (!m || typeof m !== 'object') {
|
|
129
|
+
continue;
|
|
130
|
+
} // skip stray non-package keys
|
|
131
|
+
outdatedByPkg[name] = { current: m.current, wanted: m.wanted, latest: m.latest };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// when + why each dep was added (git), and an optional curated notes override.
|
|
136
|
+
log('Reading dependency history (git) …');
|
|
137
|
+
const gitInfo = gitDepHistory([...prodNames, ...devNames]);
|
|
138
|
+
let notes = {};
|
|
139
|
+
try {
|
|
140
|
+
notes = JSON.parse(readFileSync(path.join(SCRIPTS_DIR, 'dependency-notes.json'), 'utf8'));
|
|
141
|
+
} catch {}
|
|
142
|
+
|
|
143
|
+
const build = (names, type) =>
|
|
144
|
+
names.map((name) => {
|
|
145
|
+
const n = notes[name] || {};
|
|
146
|
+
const g = gitInfo[name] || {};
|
|
147
|
+
const out = outdatedByPkg[name] || null;
|
|
148
|
+
const version = (type === 'prod' ? pkg.dependencies : pkg.devDependencies)[name];
|
|
149
|
+
return {
|
|
150
|
+
name,
|
|
151
|
+
type,
|
|
152
|
+
version,
|
|
153
|
+
imported: importedExternals.has(name),
|
|
154
|
+
description: n.description || pkgDescription(name) || '',
|
|
155
|
+
reason: n.reason || g.subject || '',
|
|
156
|
+
task: n.task || '',
|
|
157
|
+
added: n.added || g.date || '',
|
|
158
|
+
addedHash: g.hash || '',
|
|
159
|
+
outdated: out,
|
|
160
|
+
needsUpdate: !!(out && out.latest && out.current !== out.latest),
|
|
161
|
+
vuln: vulnByPkg[name] || null,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const packages = [...build(prodNames, 'prod'), ...build(devNames, 'dev')].sort((a, b) =>
|
|
166
|
+
a.name.localeCompare(b.name),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
prodCount,
|
|
171
|
+
devCount,
|
|
172
|
+
packages,
|
|
173
|
+
audit,
|
|
174
|
+
outdatedAvailable,
|
|
175
|
+
outdatedNote: outdatedAvailable
|
|
176
|
+
? null
|
|
177
|
+
: outdatedError
|
|
178
|
+
? `npm outdated could not reach the registry (${outdatedError.code || 'error'}: ${(outdatedError.summary || 'network error').split('\n')[0]}). Re-run: ${OUTDATED_HINT}`
|
|
179
|
+
: `Not generated. Run: ${OUTDATED_HINT}`,
|
|
180
|
+
outdatedCount: packages.filter((p) => p.needsUpdate).length,
|
|
181
|
+
// Direct dependencies (in package.json) flagged by npm audit. This is a subset of
|
|
182
|
+
// audit.total, which counts every advisory path including transitive packages —
|
|
183
|
+
// hence vulnCount ≤ audit.total (they measure different things).
|
|
184
|
+
vulnCount: packages.filter((p) => p.vuln).length,
|
|
185
|
+
engines: pkg.engines || null,
|
|
186
|
+
};
|
|
187
|
+
}
|