@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,217 @@
|
|
|
1
|
+
/** Dashboard CSS — the full <style> body, kept apart from the markup builders. */
|
|
2
|
+
|
|
3
|
+
/** Primary brand colour, used as the CSS accent variable. */
|
|
4
|
+
export const BRAND = '#1377FE';
|
|
5
|
+
|
|
6
|
+
export const STYLES = `
|
|
7
|
+
:root{--brand:${BRAND};--bg:#0b1120;--panel:#111a2e;--panel2:#0e1626;--line:#1e2a44;--ink:#e6edf7;--muted:#8aa0c2;--ok:#16a34a;--warn:#d97706;--bad:#dc2626}
|
|
8
|
+
*{box-sizing:border-box}
|
|
9
|
+
body{margin:0;font:14px/1.55 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;background:var(--bg);color:var(--ink)}
|
|
10
|
+
a{color:#5ea2ff;text-decoration:none}a:hover{text-decoration:underline}
|
|
11
|
+
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;color:#bcd2f5;background:#0c1424;padding:1px 5px;border-radius:5px}
|
|
12
|
+
pre{background:#0c1424;border:1px solid var(--line);border-radius:8px;padding:10px;overflow:auto;font-size:12px;color:#cdd9ee}
|
|
13
|
+
header{padding:28px 32px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#0e1730,#0b1120)}
|
|
14
|
+
header h1{margin:0 0 4px;font-size:22px}
|
|
15
|
+
header .sub{color:var(--muted);font-size:13px}
|
|
16
|
+
.wrap{max-width:1180px;margin:0 auto;padding:24px 32px 60px}
|
|
17
|
+
.chips{display:flex;flex-wrap:wrap;gap:12px;margin:22px 0 6px}
|
|
18
|
+
.chip{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px;min-width:120px}
|
|
19
|
+
.chip span{display:block;font-size:24px;font-weight:700;line-height:1.1}
|
|
20
|
+
.chip label{display:block;color:var(--muted);font-size:12px;margin-top:2px}
|
|
21
|
+
.chip.good span{color:#4ade80}.chip.bad span{color:#f87171}.chip.warn span{color:#fbbf24}
|
|
22
|
+
h2{font-size:15px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);margin:34px 0 12px;border-bottom:1px solid var(--line);padding-bottom:8px}
|
|
23
|
+
.grid{display:grid;gap:16px}
|
|
24
|
+
.grid.two{grid-template-columns:1fr 1fr}
|
|
25
|
+
.grid.three{grid-template-columns:1fr 1fr 1fr}
|
|
26
|
+
.grid.scanners{grid-template-columns:1fr 1fr 1fr}
|
|
27
|
+
@media(max-width:980px){.grid.three{grid-template-columns:1fr}}
|
|
28
|
+
@media(max-width:820px){.grid.two,.grid.scanners{grid-template-columns:1fr}}
|
|
29
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 18px}
|
|
30
|
+
.card-h{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
31
|
+
.card h3{margin:0;font-size:15px}
|
|
32
|
+
.tool{color:var(--muted);font-size:12px;margin:2px 0 12px}
|
|
33
|
+
table.mini{width:100%;border-collapse:collapse;margin-top:8px;font-size:12.5px}
|
|
34
|
+
table.mini th{ text-align:left;color:var(--muted);font-weight:600;border-bottom:1px solid var(--line);padding:6px 8px}
|
|
35
|
+
table.mini td{padding:5px 8px;border-bottom:1px solid #16203a}
|
|
36
|
+
table.mini td.r,table.mini th.r{text-align:right}
|
|
37
|
+
.muted{color:var(--muted)}
|
|
38
|
+
.ok-msg{color:#4ade80;font-weight:600}.bad-msg{color:#f87171;font-weight:600}
|
|
39
|
+
.sevrow{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0}
|
|
40
|
+
.sev{background:color-mix(in srgb,var(--c) 18%,transparent);color:var(--c);border:1px solid color-mix(in srgb,var(--c) 40%,transparent);border-radius:999px;padding:2px 10px;font-size:12px;font-weight:600}
|
|
41
|
+
.badge{font-size:11px;font-weight:700;border-radius:999px;padding:3px 10px;white-space:nowrap}
|
|
42
|
+
.badge.ok{background:rgba(22,163,74,.18);color:#4ade80;border:1px solid rgba(22,163,74,.4)}
|
|
43
|
+
.badge.warn{background:rgba(217,119,6,.16);color:#fbbf24;border:1px solid rgba(217,119,6,.4)}
|
|
44
|
+
/* security scanner — per-finding "why it's flagged" list */
|
|
45
|
+
.sec-findings{margin-top:10px}
|
|
46
|
+
.sec-findings>summary{color:var(--muted)}
|
|
47
|
+
ul.findings{list-style:none;margin:8px 0 0;padding:0;font-size:12.5px}
|
|
48
|
+
ul.findings li{padding:6px 0;border-top:1px solid #16203a;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
|
49
|
+
ul.findings li:first-child{border-top:0}
|
|
50
|
+
ul.findings .fr{color:#dbe6f8}
|
|
51
|
+
ul.findings .fd{flex-basis:100%;font-size:11.5px;margin-top:1px}
|
|
52
|
+
.gauge{margin:10px 0}
|
|
53
|
+
.gauge-h{display:flex;justify-content:space-between;font-size:12.5px;margin-bottom:4px}
|
|
54
|
+
.track{height:9px;background:#0c1424;border-radius:999px;overflow:hidden}
|
|
55
|
+
.fill{height:100%;border-radius:999px;transition:width .6s}
|
|
56
|
+
details{margin-top:10px}summary{cursor:pointer;color:var(--muted);font-size:12.5px}
|
|
57
|
+
.links{margin-top:12px;font-size:12.5px;color:var(--muted)}
|
|
58
|
+
/* coverage section — compact, ~1/3 width */
|
|
59
|
+
.cov-card{max-width:380px}
|
|
60
|
+
/* eslint problem detail */
|
|
61
|
+
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
|
|
62
|
+
.dot.e{background:var(--bad)}.dot.w{background:var(--warn)}
|
|
63
|
+
table.problems td.msg{color:#cdd9ee;white-space:normal}
|
|
64
|
+
table.problems td:first-child{white-space:nowrap}
|
|
65
|
+
/* dependency table + filters */
|
|
66
|
+
.filters{display:flex;flex-wrap:wrap;align-items:center;gap:10px 14px;margin-bottom:12px;font-size:12.5px}
|
|
67
|
+
.filters .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
|
|
68
|
+
.filters .seg button{background:transparent;color:var(--ink);border:0;border-right:1px solid var(--line);padding:5px 12px;cursor:pointer;font:inherit}
|
|
69
|
+
.filters .seg button:last-child{border-right:0}
|
|
70
|
+
.filters .seg button.on{background:var(--brand);color:#fff}
|
|
71
|
+
.filters label{display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
|
|
72
|
+
.filters input[type=search]{background:#0c1424;border:1px solid var(--line);border-radius:8px;color:var(--ink);padding:5px 10px;font:inherit;min-width:160px}
|
|
73
|
+
#dep-count{margin-left:auto}
|
|
74
|
+
.tablewrap{overflow-x:auto}
|
|
75
|
+
table.deps td{vertical-align:top}
|
|
76
|
+
table.deps td.desc{color:var(--muted);max-width:300px;white-space:normal}
|
|
77
|
+
table.deps td.why{max-width:280px;white-space:normal}
|
|
78
|
+
table.deps td.when,table.deps td.ver{white-space:nowrap}
|
|
79
|
+
.tag{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.03em;text-transform:uppercase;border-radius:5px;padding:1px 6px;vertical-align:middle}
|
|
80
|
+
.tag.prod{background:rgba(19,119,254,.16);color:#7db4ff;border:1px solid rgba(19,119,254,.4)}
|
|
81
|
+
.tag.dev{background:rgba(168,85,247,.16);color:#c89bf5;border:1px solid rgba(168,85,247,.4)}
|
|
82
|
+
.tag.task{background:rgba(100,116,139,.18);color:#a7b6cf;border:1px solid rgba(100,116,139,.4);text-transform:none;font-weight:600}
|
|
83
|
+
.imp{font-size:10px;color:#4ade80;border:1px solid rgba(22,163,74,.4);border-radius:5px;padding:1px 5px}
|
|
84
|
+
.upd{color:#fbbf24;font-weight:600}
|
|
85
|
+
.ok-dot{color:#4ade80}
|
|
86
|
+
#graph{width:100%;height:460px;background:var(--panel2);border:1px solid var(--line);border-radius:14px;display:block;cursor:grab}
|
|
87
|
+
.legend{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px;font-size:12px;color:var(--muted)}
|
|
88
|
+
.legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:middle;background:var(--c)}
|
|
89
|
+
/* expandable trees: file graph, routing, composition */
|
|
90
|
+
.tree{font-size:12.5px;margin-top:6px}
|
|
91
|
+
.tree details,.tree-dir,.rt-dir,.tree-file.det{margin-top:0}
|
|
92
|
+
.tree-dir>summary,.rt-dir>summary,.tree-file.det>summary{list-style:none;cursor:pointer;color:var(--ink);font-size:12.5px;padding:3px 6px;border-radius:6px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
93
|
+
.tree-dir>summary::-webkit-details-marker,.rt-dir>summary::-webkit-details-marker,.tree-file.det>summary::-webkit-details-marker{display:none}
|
|
94
|
+
.tree-dir>summary:hover,.rt-dir>summary:hover,.tree-file.det>summary:hover{background:#0e1830}
|
|
95
|
+
.tree-dir>summary::before,.rt-dir>summary::before,.tree-file.det>summary::before{content:'▸';color:var(--muted);font-size:10px;display:inline-block;width:9px;transition:transform .15s}
|
|
96
|
+
.tree-dir[open]>summary::before,.rt-dir[open]>summary::before,.tree-file.det[open]>summary::before{transform:rotate(90deg)}
|
|
97
|
+
.tree-body{margin-left:15px;border-left:1px solid var(--line);padding-left:10px}
|
|
98
|
+
.tree-file{padding:3px 6px 3px 21px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
99
|
+
.ico{font-size:12px;filter:grayscale(.2)}
|
|
100
|
+
.fan{font-size:10px;border-radius:5px;padding:0 5px;border:1px solid var(--line);color:var(--muted);white-space:nowrap}
|
|
101
|
+
.fan.in{color:#7db4ff}.fan.out{color:#4ade80}
|
|
102
|
+
.rel{font-size:12px;margin:3px 0;line-height:1.8}
|
|
103
|
+
.rel code{margin:1px 2px}
|
|
104
|
+
/* routing badges */
|
|
105
|
+
.rt{font-size:10px;font-weight:700;text-transform:uppercase;border-radius:5px;padding:1px 6px;color:var(--c);background:color-mix(in srgb,var(--c) 16%,transparent);border:1px solid color-mix(in srgb,var(--c) 40%,transparent)}
|
|
106
|
+
.seg{font-weight:600}
|
|
107
|
+
.seg.dyn{color:#fbbf24}.seg.group{color:#c89bf5}
|
|
108
|
+
.url{background:#0c1424;color:#9fb8e6}
|
|
109
|
+
.rt-leaf{padding:3px 6px 3px 21px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
|
110
|
+
/* ER / data-model diagram */
|
|
111
|
+
.er-canvas{display:flex;flex-wrap:wrap;gap:18px;align-items:flex-start;margin-top:6px}
|
|
112
|
+
.er-table{background:var(--panel2);border:1px solid var(--line);border-radius:10px;min-width:240px;overflow:hidden;box-shadow:0 1px 0 rgba(0,0,0,.2)}
|
|
113
|
+
.er-h{background:linear-gradient(180deg,#16223e,#101a30);padding:8px 12px;font-weight:700;border-bottom:2px solid var(--brand);display:flex;align-items:center;gap:8px}
|
|
114
|
+
.er-cols{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
115
|
+
.er-cols td{padding:4px 10px;border-bottom:1px solid #16203a;vertical-align:top}
|
|
116
|
+
.er-cols tr:last-child td{border-bottom:0}
|
|
117
|
+
.er-k{width:30px}
|
|
118
|
+
.er-c{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#cfe0fb}
|
|
119
|
+
.er-t{color:var(--muted);text-align:right;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;white-space:nowrap}
|
|
120
|
+
.req{color:#f87171;margin-left:2px}
|
|
121
|
+
.kb{font-size:9px;font-weight:800;border-radius:4px;padding:1px 4px}
|
|
122
|
+
.kb.pk{background:rgba(234,179,8,.18);color:#facc15;border:1px solid rgba(234,179,8,.5)}
|
|
123
|
+
.kb.fk{background:rgba(168,85,247,.18);color:#c89bf5;border:1px solid rgba(168,85,247,.5)}
|
|
124
|
+
.er-rls{font-size:9px;font-weight:700;border-radius:5px;padding:1px 6px;background:rgba(22,163,74,.18);color:#4ade80;border:1px solid rgba(22,163,74,.4);margin-left:auto}
|
|
125
|
+
.er-rls.pub{background:rgba(217,119,6,.16);color:#fbbf24;border-color:rgba(217,119,6,.4)}
|
|
126
|
+
.er-idx{padding:6px 10px;font-size:11px;color:var(--muted);border-top:1px solid #16203a;display:flex;flex-wrap:wrap;gap:8px}
|
|
127
|
+
.er-bk{padding:8px 12px;font-size:12px;color:var(--muted)}
|
|
128
|
+
.er-rels{margin-top:14px;font-size:12.5px}
|
|
129
|
+
.er-table.bucket{min-width:200px}
|
|
130
|
+
/* ── component composition (browser windows + physical nesting) ── */
|
|
131
|
+
.comp-subh{font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);margin:18px 0 9px}
|
|
132
|
+
.comp-legend{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;margin:10px 0 8px;font-size:12px;color:var(--muted)}
|
|
133
|
+
.comp-legend .sw{display:inline-flex;align-items:center;gap:6px}
|
|
134
|
+
.comp-legend i{width:12px;height:12px;border-radius:4px;display:inline-block;border:1px solid rgba(255,255,255,.14)}
|
|
135
|
+
.comp-legend i.role-page{background:#86efac}
|
|
136
|
+
.comp-legend i.role-layout{background:#7dd3fc}
|
|
137
|
+
.comp-legend i.role-app{background:#a5b4fc}
|
|
138
|
+
.comp-legend i.role-public{background:#22c55e}
|
|
139
|
+
.comp-legend i.role-internal{background:#2b3a57}
|
|
140
|
+
.comp-leg-arrow{color:#5e9bff;font-weight:800;font-size:14px;line-height:1}
|
|
141
|
+
.comp-controls{display:flex;gap:8px;margin:0 0 10px}
|
|
142
|
+
.comp-controls button{font:inherit;font-size:12px;cursor:pointer;color:var(--muted);background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:4px 11px}
|
|
143
|
+
.comp-controls button:hover{color:#e6edf7;border-color:#3a4a66}
|
|
144
|
+
/* page-flow graph */
|
|
145
|
+
.cflow-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel2);padding:16px 14px;margin:0 0 6px}
|
|
146
|
+
.cflow{position:relative;min-width:max-content;margin:0 auto}
|
|
147
|
+
.cflow-svg{position:absolute;top:0;left:0;overflow:visible;pointer-events:none;z-index:0}
|
|
148
|
+
.cflow-edge{fill:none;stroke:#3f6fb0;stroke-width:1.6;opacity:.8}
|
|
149
|
+
.cflow-layers{position:relative;z-index:1;display:flex;flex-direction:column;gap:40px}
|
|
150
|
+
.cflow-row{display:flex;justify-content:center;flex-wrap:wrap;gap:26px}
|
|
151
|
+
.cflow-node{background:#0b1322;border:1px solid #2b3b5b;border-radius:9px;min-width:158px;box-shadow:0 3px 12px rgba(0,0,0,.32)}
|
|
152
|
+
.cflow-chrome{display:flex;align-items:center;gap:7px;padding:5px 9px;background:linear-gradient(180deg,#1b2540,#131c30);border-bottom:1px solid #243150;border-radius:9px 9px 0 0}
|
|
153
|
+
.cflow-dots{display:inline-flex;gap:4px;flex:0 0 auto}
|
|
154
|
+
.cflow-dots i{width:8px;height:8px;border-radius:50%;display:inline-block}
|
|
155
|
+
.cflow-dots i:nth-child(1){background:#ff5f57}.cflow-dots i:nth-child(2){background:#febc2e}.cflow-dots i:nth-child(3){background:#28c840}
|
|
156
|
+
.cflow-url{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:#9fb8e6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
157
|
+
.cflow-meta{padding:6px 10px;display:flex;flex-direction:column;gap:2px}
|
|
158
|
+
.cflow-comp{font-size:11.5px;color:#dbe6f8;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
159
|
+
.cflow-deg{font-size:10px;color:var(--muted)}
|
|
160
|
+
.cflow-orphans{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin:0 0 6px;font-size:11.5px}
|
|
161
|
+
.cflow-orphan{color:#8aa0c2;background:#0c1424;border:1px solid var(--line);border-radius:6px;padding:1px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
|
|
162
|
+
/* nested-rectangle tree */
|
|
163
|
+
.comp-scroll{overflow:auto;max-height:900px;border:1px solid var(--line);border-radius:12px;background:var(--panel2);padding:12px}
|
|
164
|
+
.comp-tree>.comp-box:first-child{margin-top:0}
|
|
165
|
+
.comp-box{margin:7px 0;border:1px solid var(--line);border-left-width:3px;border-radius:9px;background:#0e1830}
|
|
166
|
+
.comp-nest{padding:2px 9px 8px}
|
|
167
|
+
.comp-card{background:transparent;border:0}
|
|
168
|
+
.comp-card>summary{list-style:none;cursor:pointer;padding:8px 12px;display:flex;flex-wrap:wrap;align-items:center;gap:5px 9px}
|
|
169
|
+
.comp-card>summary::-webkit-details-marker{display:none}
|
|
170
|
+
.comp-card>summary::before{content:'▸';color:var(--muted);font-size:11px;line-height:1;transition:transform .12s}
|
|
171
|
+
.comp-card[open]>summary::before{transform:rotate(90deg)}
|
|
172
|
+
.comp-name{font-weight:700;font-size:13.5px;color:#e6edf7;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
173
|
+
.comp-loc{font-size:11px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
174
|
+
.comp-tag{font-size:11.5px;color:var(--muted);font-style:italic}
|
|
175
|
+
.comp-desc{flex-basis:100%;margin:1px 0 0;font-size:12.5px;color:#9fb2d4;line-height:1.45}
|
|
176
|
+
.comp-links{flex-basis:100%;display:flex;flex-wrap:wrap;gap:5px;margin-top:4px}
|
|
177
|
+
.comp-link{font-size:10.5px;color:#7db4ff;background:#0c1a30;border:1px solid #1e3a5f;border-radius:999px;padding:1px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
178
|
+
.comp-link::before{content:'→ ';opacity:.65}
|
|
179
|
+
.comp-badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.03em;padding:1px 7px;border-radius:999px}
|
|
180
|
+
.comp-badge.role-page{background:#86efac;color:#052e16}
|
|
181
|
+
.comp-badge.role-layout{background:#7dd3fc;color:#062436}
|
|
182
|
+
.comp-badge.role-app{background:#a5b4fc;color:#1e1b4b}
|
|
183
|
+
.comp-badge.role-public{background:#15803d;color:#dcfce7}
|
|
184
|
+
.comp-badge.role-internal{background:#243049;color:#c7d4ea}
|
|
185
|
+
.comp-box.role-page{border-left-color:#86efac}
|
|
186
|
+
.comp-box.role-layout{border-left-color:#7dd3fc}
|
|
187
|
+
.comp-box.role-app{border-left-color:#a5b4fc}
|
|
188
|
+
.comp-box.role-public{border-left-color:#22c55e}
|
|
189
|
+
.comp-box.role-internal{border-left-color:#33425f}
|
|
190
|
+
.comp-meta{padding:8px 12px 11px;display:grid;gap:5px;border-top:1px solid var(--line)}
|
|
191
|
+
.comp-mrow{display:flex;gap:10px;font-size:12px;line-height:1.5}
|
|
192
|
+
.comp-mk{flex:0 0 78px;color:var(--muted);font-weight:600}
|
|
193
|
+
.comp-mv{flex:1;min-width:0;color:#c7d4ea}
|
|
194
|
+
.comp-mv code{background:#1a2742;border-radius:4px;padding:0 5px;font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
195
|
+
.comp-ty{color:#7d8cad}
|
|
196
|
+
.comp-sep{color:#54618a}
|
|
197
|
+
.comp-hk{color:#6f7fa3;font-size:10.5px;margin-left:3px}
|
|
198
|
+
.comp-ev{background:#23314e;border-radius:4px;padding:0 6px;font-size:11px;color:#a9c2ef}
|
|
199
|
+
/* page = browser window */
|
|
200
|
+
.comp-win{border-left-width:1px;border-color:#2b3b5b;background:#0b1322;box-shadow:0 8px 26px rgba(0,0,0,.42);overflow:hidden}
|
|
201
|
+
.comp-winbar{display:flex;align-items:center;gap:10px;padding:7px 11px;background:linear-gradient(180deg,#1b2540,#131c30);border-bottom:1px solid #243150}
|
|
202
|
+
.comp-dots{display:inline-flex;gap:6px;flex:0 0 auto}
|
|
203
|
+
.comp-dots i{width:11px;height:11px;border-radius:50%;display:inline-block;background:#3a4a66}
|
|
204
|
+
.comp-dots i:nth-child(1){background:#ff5f57}.comp-dots i:nth-child(2){background:#febc2e}.comp-dots i:nth-child(3){background:#28c840}
|
|
205
|
+
.comp-addr{flex:1;min-width:0;display:flex;align-items:center;gap:7px;background:#0b1322;border:1px solid #243150;border-radius:7px;padding:4px 11px;color:#9fb8e6;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}
|
|
206
|
+
.comp-addr-url{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
207
|
+
.comp-lock{width:11px;height:11px;flex:0 0 auto;opacity:.55}
|
|
208
|
+
.comp-winbody{padding:10px 11px 12px}
|
|
209
|
+
.comp-winbody>.comp-nest{padding:6px 0 0}
|
|
210
|
+
.comp-cyc{padding:8px 12px;display:flex;align-items:center;gap:9px}
|
|
211
|
+
.comp-box.is-cycle{opacity:.6;border-style:dashed}
|
|
212
|
+
.comp-unattached{margin-top:14px}
|
|
213
|
+
.comp-unattached>summary{cursor:pointer;color:var(--muted);font-size:12.5px;margin-bottom:8px}
|
|
214
|
+
/* storybook card */
|
|
215
|
+
.sb-link{font-size:13px;margin:6px 0}
|
|
216
|
+
footer{color:var(--muted);font-size:12px;text-align:center;padding:24px}
|
|
217
|
+
`;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/** Assembles the final self-contained HTML page from the collected data. */
|
|
2
|
+
import { bytes, esc, num, pct } from '../utils/format.mjs';
|
|
3
|
+
import {
|
|
4
|
+
COMPOSITION_FLOW_SCRIPT,
|
|
5
|
+
COMPOSITION_SCRIPT,
|
|
6
|
+
DEP_FILTER_SCRIPT,
|
|
7
|
+
GRAPH_SCRIPT,
|
|
8
|
+
} from './client.mjs';
|
|
9
|
+
import { compositionGraphHtml } from './composition.mjs';
|
|
10
|
+
import {
|
|
11
|
+
bar,
|
|
12
|
+
entitiesHtml,
|
|
13
|
+
fileTreeHtml,
|
|
14
|
+
routingHtml,
|
|
15
|
+
scannerCard,
|
|
16
|
+
sevBadges,
|
|
17
|
+
} from './components.mjs';
|
|
18
|
+
import { STYLES } from './styles.mjs';
|
|
19
|
+
|
|
20
|
+
export function render(d) {
|
|
21
|
+
const { meta, code, coverage, lint, deps, graph, security, routing, composition, entities } = d;
|
|
22
|
+
const bundle = d.bundle || { available: false, note: 'Codebase bundle not generated.' };
|
|
23
|
+
const storybook = d.storybook || { available: false, storyCount: 0, built: false };
|
|
24
|
+
|
|
25
|
+
const chip = (label, value, tone = '') =>
|
|
26
|
+
`<div class="chip ${tone}"><span>${value}</span><label>${label}</label></div>`;
|
|
27
|
+
|
|
28
|
+
const lintTone =
|
|
29
|
+
lint.eslint?.available && lint.eslint.errors === 0 && lint.tsc?.ok
|
|
30
|
+
? 'good'
|
|
31
|
+
: lint.eslint?.errors
|
|
32
|
+
? 'bad'
|
|
33
|
+
: 'warn';
|
|
34
|
+
const auditTone = !deps.audit.available
|
|
35
|
+
? ''
|
|
36
|
+
: deps.audit.high + deps.audit.critical > 0
|
|
37
|
+
? 'bad'
|
|
38
|
+
: deps.audit.total > 0
|
|
39
|
+
? 'warn'
|
|
40
|
+
: 'good';
|
|
41
|
+
|
|
42
|
+
// Per-suite breakdown: unit / e2e / storybook / global side by side. Suites that
|
|
43
|
+
// haven't been run yet show em-dashes rather than a misleading 0%.
|
|
44
|
+
const suiteRows = (coverage.suites || [])
|
|
45
|
+
.map((s) => {
|
|
46
|
+
const cell = (v) => (s.available ? `<td class="r">${pct(v)}</td>` : `<td class="r">—</td>`);
|
|
47
|
+
const files = s.available ? num(s.fileCount) : '—';
|
|
48
|
+
const report = s.reportHref
|
|
49
|
+
? `<a href="${esc(s.reportHref)}" target="_blank" rel="noopener">open ↗</a>`
|
|
50
|
+
: '<span class="muted">—</span>';
|
|
51
|
+
return `<tr><td>${esc(s.label)}</td>${cell(s.lines)}${cell(s.statements)}${cell(s.branches)}${cell(s.functions)}<td class="r">${files}</td><td class="r">${report}</td></tr>`;
|
|
52
|
+
})
|
|
53
|
+
.join('');
|
|
54
|
+
const suiteTable = `<table class="mini"><thead><tr><th>Suite</th><th class="r">Lines</th><th class="r">Stmts</th><th class="r">Branch</th><th class="r">Funcs</th><th class="r">Files</th><th class="r">Report</th></tr></thead><tbody>${suiteRows}</tbody></table>`;
|
|
55
|
+
|
|
56
|
+
const covReportLink = coverage.reportHref
|
|
57
|
+
? `<p class="muted">📂 <a href="${esc(coverage.reportHref)}" target="_blank" rel="noopener">Open the full ${esc(coverage.headLabel || '')} coverage report ↗</a> — drill down file-by-file / folder-by-folder.</p>`
|
|
58
|
+
: '';
|
|
59
|
+
const covBody = coverage.available
|
|
60
|
+
? `${suiteTable}
|
|
61
|
+
<p class="muted">Gauges + per-file below reflect <strong>${esc(coverage.headLabel || 'Unit')}</strong> (the widest available suite).</p>
|
|
62
|
+
${covReportLink}
|
|
63
|
+
${bar('Statements', coverage.statements)}${bar('Branches', coverage.branches)}${bar('Functions', coverage.functions)}${bar('Lines', coverage.lines)}
|
|
64
|
+
<details><summary>Per-file (lowest first, ${coverage.files.length})</summary>
|
|
65
|
+
<table class="mini"><thead><tr><th>File</th><th class="r">Lines %</th></tr></thead><tbody>
|
|
66
|
+
${coverage.files.map((f) => `<tr><td><code>${esc(f.file)}</code></td><td class="r">${pct(f.lines)}</td></tr>`).join('')}
|
|
67
|
+
</tbody></table></details>`
|
|
68
|
+
: `<p class="muted">${esc(coverage.note || 'Coverage not available.')}</p>`;
|
|
69
|
+
|
|
70
|
+
let lintBody;
|
|
71
|
+
if (lint.eslint?.available) {
|
|
72
|
+
const clean = lint.eslint.errors === 0 && lint.eslint.warnings === 0;
|
|
73
|
+
if (clean) {
|
|
74
|
+
lintBody = `<p class="ok-msg">✓ No ESLint problems — the codebase is clean.</p>`;
|
|
75
|
+
} else {
|
|
76
|
+
const problems = lint.eslint.problems || [];
|
|
77
|
+
const shown = problems.slice(0, lint.eslint.problemsShown || problems.length);
|
|
78
|
+
const sevDot = (s) => `<span class="dot ${s === 'error' ? 'e' : 'w'}" title="${s}"></span>`;
|
|
79
|
+
// Compact: filename (basename) instead of relative path; the rule id moves
|
|
80
|
+
// to the row title so the dedicated Rule column can be dropped.
|
|
81
|
+
const problemRows = shown
|
|
82
|
+
.map((p) => {
|
|
83
|
+
const fname = p.file.split('/').pop();
|
|
84
|
+
return `<tr title="${esc(p.file)} · ${esc(p.rule)}"><td>${sevDot(p.severity)}<code>${esc(fname)}</code><span class="muted">:${p.line}:${p.column}</span></td><td class="msg">${esc(p.message)}</td></tr>`;
|
|
85
|
+
})
|
|
86
|
+
.join('');
|
|
87
|
+
const moreNote =
|
|
88
|
+
problems.length > shown.length
|
|
89
|
+
? `<p class="muted">… and ${problems.length - shown.length} more (showing first ${shown.length}).</p>`
|
|
90
|
+
: '';
|
|
91
|
+
lintBody = `<div class="sevrow">
|
|
92
|
+
<span class="sev" style="--c:#dc2626">${lint.eslint.errors} errors</span>
|
|
93
|
+
<span class="sev" style="--c:#d97706">${lint.eslint.warnings} warnings</span>
|
|
94
|
+
</div>
|
|
95
|
+
<table class="mini problems"><thead><tr><th>File:line:col</th><th>What</th></tr></thead><tbody>
|
|
96
|
+
${problemRows}
|
|
97
|
+
</tbody></table>
|
|
98
|
+
${moreNote}`;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
lintBody = `<p class="muted">${esc(lint.eslint?.note || 'ESLint not run.')}</p>`;
|
|
102
|
+
}
|
|
103
|
+
const tscBody = lint.tsc?.available
|
|
104
|
+
? lint.tsc.ok
|
|
105
|
+
? `<p class="ok-msg">✓ tsc --noEmit passed — 0 type errors.</p>`
|
|
106
|
+
: `<p class="bad-msg">✗ ${lint.tsc.errors} type error(s).</p><pre>${esc(lint.tsc.sample.join('\n'))}</pre>`
|
|
107
|
+
: `<p class="muted">${esc(lint.tsc?.note || '')}</p>`;
|
|
108
|
+
|
|
109
|
+
const auditBody = deps.audit.available
|
|
110
|
+
? `<div class="sevrow">${sevBadges({ critical: deps.audit.critical, high: deps.audit.high, medium: deps.audit.moderate, low: deps.audit.low, note: deps.audit.info })}</div>
|
|
111
|
+
<p class="muted">${num(deps.audit.deps)} resolved dependencies · ${deps.audit.total} known advisories (npm audit baseline; Snyk adds licence + reachability).</p>`
|
|
112
|
+
: `<p class="muted">${esc(deps.audit.note || 'npm audit not available.')}</p>`;
|
|
113
|
+
|
|
114
|
+
const vulnColor = (s) =>
|
|
115
|
+
({ critical: '#dc2626', high: '#ea580c', moderate: '#d97706', low: '#0891b2' })[s] || '#64748b';
|
|
116
|
+
|
|
117
|
+
const depRows = deps.packages
|
|
118
|
+
.map((p) => {
|
|
119
|
+
const verCell = p.needsUpdate
|
|
120
|
+
? `${esc(p.version)} <span class="muted">→</span> <span class="upd">${esc(p.outdated.latest)}</span>`
|
|
121
|
+
: esc(p.version);
|
|
122
|
+
const statusCell = p.vuln
|
|
123
|
+
? `<span class="sev" style="--c:${vulnColor(p.vuln)}">${esc(p.vuln)}</span>`
|
|
124
|
+
: p.needsUpdate
|
|
125
|
+
? `<span class="muted">outdated</span>`
|
|
126
|
+
: `<span class="ok-dot">●</span>`;
|
|
127
|
+
const why =
|
|
128
|
+
(p.reason ? esc(p.reason) : '<span class="muted">—</span>') +
|
|
129
|
+
(p.task ? ` <span class="tag task">${esc(p.task)}</span>` : '');
|
|
130
|
+
const added = p.added
|
|
131
|
+
? `${esc(p.added)}${p.addedHash ? ` <span class="muted">${esc(p.addedHash)}</span>` : ''}`
|
|
132
|
+
: '<span class="muted">—</span>';
|
|
133
|
+
return `<tr data-type="${p.type}" data-outdated="${p.needsUpdate ? 1 : 0}" data-vuln="${p.vuln ? 1 : 0}" data-name="${esc(p.name.toLowerCase())}">
|
|
134
|
+
<td class="pkg"><code>${esc(p.name)}</code> <span class="tag ${p.type}">${p.type}</span>${p.imported ? ' <span class="imp" title="imported in source">imported</span>' : ''}</td>
|
|
135
|
+
<td class="desc">${p.description ? esc(p.description) : '<span class="muted">—</span>'}</td>
|
|
136
|
+
<td class="why">${why}</td>
|
|
137
|
+
<td class="when">${added}</td>
|
|
138
|
+
<td class="ver">${verCell}</td>
|
|
139
|
+
<td class="r">${statusCell}</td>
|
|
140
|
+
</tr>`;
|
|
141
|
+
})
|
|
142
|
+
.join('');
|
|
143
|
+
|
|
144
|
+
const depFilters = `
|
|
145
|
+
<div id="dep-filters" class="filters">
|
|
146
|
+
<div class="seg">
|
|
147
|
+
<button data-ftype="all" class="on">All <span class="muted">${deps.prodCount + deps.devCount}</span></button>
|
|
148
|
+
<button data-ftype="prod">prod <span class="muted">${deps.prodCount}</span></button>
|
|
149
|
+
<button data-ftype="dev">dev <span class="muted">${deps.devCount}</span></button>
|
|
150
|
+
</div>
|
|
151
|
+
<label${deps.outdatedAvailable ? '' : ` title="${esc(deps.outdatedNote || '')}"`}><input type="checkbox" id="f-outdated"${deps.outdatedAvailable ? '' : ' disabled'}> needs update <span class="muted">(${deps.outdatedAvailable ? deps.outdatedCount : 'n/a'})</span></label>
|
|
152
|
+
<label title="Direct dependencies flagged by npm audit — a subset of the ${deps.audit.available ? deps.audit.total : '—'} total advisories, which also count transitive packages."><input type="checkbox" id="f-vuln"> vulnerable <span class="muted">(${deps.vulnCount})</span></label>
|
|
153
|
+
<input id="f-q" type="search" placeholder="filter by name…">
|
|
154
|
+
<span class="muted" id="dep-count"></span>
|
|
155
|
+
</div>`;
|
|
156
|
+
|
|
157
|
+
const outdatedNote = deps.outdatedAvailable
|
|
158
|
+
? ''
|
|
159
|
+
: `<p class="muted" style="margin:0 0 10px">⚠ <strong>Needs-update</strong> is unavailable — ${esc(deps.outdatedNote || '')}</p>`;
|
|
160
|
+
|
|
161
|
+
const depTableHtml = `
|
|
162
|
+
${outdatedNote}
|
|
163
|
+
${depFilters}
|
|
164
|
+
<div class="tablewrap">
|
|
165
|
+
<table class="mini deps" id="dep-rows"><thead><tr>
|
|
166
|
+
<th>Package</th><th>What it is</th><th>Why / task it was added for</th><th>Added</th><th>Version</th><th class="r">Status</th>
|
|
167
|
+
</tr></thead><tbody>${depRows}</tbody></table>
|
|
168
|
+
</div>`;
|
|
169
|
+
|
|
170
|
+
return `<!doctype html>
|
|
171
|
+
<html lang="en">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="utf-8"/>
|
|
174
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
|
175
|
+
<title>Quality Dashboard — ${esc(meta.name)}</title>
|
|
176
|
+
<style>${STYLES}</style>
|
|
177
|
+
</head>
|
|
178
|
+
<body>
|
|
179
|
+
<header>
|
|
180
|
+
<h1>Quality Dashboard · ${esc(meta.name)} <span class="muted">v${esc(meta.version)}</span></h1>
|
|
181
|
+
<div class="sub">Generated ${esc(meta.generated)}${meta.repo ? ` · <a href="https://github.com/${esc(meta.repo.owner)}/${esc(meta.repo.name)}" target="_blank" rel="noopener">${esc(meta.repo.owner)}/${esc(meta.repo.name)}</a>` : ''} · branch <code>${esc(meta.branch)}</code></div>
|
|
182
|
+
</header>
|
|
183
|
+
<div class="wrap">
|
|
184
|
+
|
|
185
|
+
<div class="chips">
|
|
186
|
+
${chip('Code lines', num(code.codeLines))}
|
|
187
|
+
${chip('Source files', num(code.codeFiles))}
|
|
188
|
+
${chip('Coverage (lines)', coverage.available ? pct(coverage.lines) : '—', coverage.available ? (coverage.lines >= 80 ? 'good' : coverage.lines >= 50 ? 'warn' : 'bad') : '')}
|
|
189
|
+
${chip('Lint errors', lint.eslint?.available ? lint.eslint.errors : '—', lintTone === 'good' ? 'good' : lintTone === 'bad' ? 'bad' : 'warn')}
|
|
190
|
+
${chip('Type errors', lint.tsc?.available ? lint.tsc.errors : '—', lint.tsc?.ok ? 'good' : 'bad')}
|
|
191
|
+
${chip('Dependencies', num(deps.prodCount + deps.devCount))}
|
|
192
|
+
${chip('Audit advisories', deps.audit.available ? deps.audit.total : '—', auditTone)}
|
|
193
|
+
${chip('Modules / imports', `${graph.nodeCount}/${graph.edgeCount}`)}
|
|
194
|
+
${chip('Routes', routing.available ? routing.pageCount + routing.routeCount : '—')}
|
|
195
|
+
${chip('DB tables', entities.available ? entities.tables.length : '—')}
|
|
196
|
+
${chip('Stories', storybook.available ? storybook.storyCount : '—')}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<h2>1 · Tests, lint & types</h2>
|
|
200
|
+
<div class="grid three">
|
|
201
|
+
<div class="card"><div class="card-h"><h3>Test coverage</h3></div>${covBody}</div>
|
|
202
|
+
<div class="card"><div class="card-h"><h3>Linter (ESLint)</h3></div>${lintBody}</div>
|
|
203
|
+
<div class="card"><div class="card-h"><h3>TypeScript</h3></div>${tscBody}</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<h2>2 · Security scanners</h2>
|
|
207
|
+
<p class="muted" style="margin-top:-4px">Snyk, CodeQL and Checkmarx run in GitHub Actions and publish to the Security tab / vendor dashboards. Findings below are parsed from local SARIF artifacts when present.${security.ghSecurity ? ` · <a href="${security.ghSecurity}" target="_blank" rel="noopener">Open GitHub code scanning ↗</a>` : ''}</p>
|
|
208
|
+
<div class="grid scanners">
|
|
209
|
+
${scannerCard(security.snyk)}
|
|
210
|
+
${scannerCard(security.codeql)}
|
|
211
|
+
${scannerCard(security.checkmarx)}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<h2>3 · Dependencies</h2>
|
|
215
|
+
<div class="card">${auditBody}</div>
|
|
216
|
+
<div class="card" style="margin-top:16px">${depTableHtml}</div>
|
|
217
|
+
|
|
218
|
+
<h2>4 · File graph & relationships</h2>
|
|
219
|
+
<div class="card">
|
|
220
|
+
<p class="muted">${graph.nodeCount} internal modules · ${graph.edgeCount} import edges · ${graph.externalCount} external packages. Browse by folder; expand a file to see what it imports and what imports it.</p>
|
|
221
|
+
<div class="tree">${fileTreeHtml(graph.graph)}</div>
|
|
222
|
+
<details style="margin-top:14px"><summary>Force-directed view (drag nodes, hover for path)</summary>
|
|
223
|
+
<canvas id="graph"></canvas>
|
|
224
|
+
<div class="legend" id="legend"></div>
|
|
225
|
+
</details>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<h2>5 · Routing</h2>
|
|
229
|
+
<div class="card">
|
|
230
|
+
<p class="muted">Next.js App Router${routing.available ? ` — ${routing.pageCount} page(s) · ${routing.routeCount} API route(s)` : ''}. Expand a segment to see nested routes; dynamic <span class="seg dyn">[param]</span> segments and route <span class="seg group">(groups)</span> are highlighted.</p>
|
|
231
|
+
<div class="tree">${routingHtml(routing)}</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<h2>6 · Component composition</h2>
|
|
235
|
+
<div class="card">
|
|
236
|
+
${compositionGraphHtml(composition)}
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<h2>7 · Data model</h2>
|
|
240
|
+
<div class="card">
|
|
241
|
+
<p class="muted">Entities parsed from <code>supabase/migrations/*.sql</code>${entities.available ? ` — ${entities.tables.length} table(s)${entities.buckets.length ? ` · ${entities.buckets.length} storage bucket(s)` : ''}` : ''}. <span class="kb pk">PK</span> primary key · <span class="kb fk">FK</span> foreign key · <span class="req">*</span> NOT NULL.</p>
|
|
242
|
+
${entitiesHtml(entities)}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<h2>8 · Storybook</h2>
|
|
246
|
+
<div class="card">
|
|
247
|
+
${
|
|
248
|
+
storybook.built
|
|
249
|
+
? `<p class="sb-link">📕 <a href="${esc(storybook.href)}" target="_blank" rel="noopener">Open Storybook ↗</a> — the component workshop (design-system widgets & features), built as a static site next to this dashboard.</p>
|
|
250
|
+
<p class="muted">${num(storybook.storyCount)} story file(s) in the source tree.</p>`
|
|
251
|
+
: `<p class="muted">${num(storybook.storyCount)} story file(s) in the source tree. ${esc(storybook.note || 'Static Storybook not built next to the dashboard.')}</p>`
|
|
252
|
+
}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<h2>9 · Codebase bundle</h2>
|
|
256
|
+
<div class="card">
|
|
257
|
+
${
|
|
258
|
+
bundle.available
|
|
259
|
+
? `<p>📦 <a href="${esc(bundle.file)}" target="_blank" rel="noopener">Open the codebase bundle ↗</a> — every source file, config and report in one browsable, self-contained page (collapsible tree, full-text filter).</p>
|
|
260
|
+
<p class="muted">${num(bundle.fileCount)} files embedded · ${bytes(bundle.totalBytes)} of source · ${bytes(bundle.htmlBytes)} HTML · regenerate alone with <code>npm run bundle:report</code>.</p>`
|
|
261
|
+
: `<p class="muted">${esc(bundle.note || 'Codebase bundle not generated.')} Run <code>npm run bundle:report</code> to generate it separately.</p>`
|
|
262
|
+
}
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<footer>Generated by <code>dev/scripts/quality-dashboard.mjs</code> · ${esc(meta.generated)}</footer>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<script id="graph-data" type="application/json">${JSON.stringify(graph.graph).replace(/</g, '\\u003c')}</script>
|
|
269
|
+
<script>
|
|
270
|
+
${GRAPH_SCRIPT}
|
|
271
|
+
</script>
|
|
272
|
+
<script>
|
|
273
|
+
${COMPOSITION_SCRIPT}
|
|
274
|
+
</script>
|
|
275
|
+
<script>
|
|
276
|
+
${COMPOSITION_FLOW_SCRIPT}
|
|
277
|
+
</script>
|
|
278
|
+
<script>
|
|
279
|
+
${DEP_FILTER_SCRIPT}
|
|
280
|
+
</script>
|
|
281
|
+
</body>
|
|
282
|
+
</html>`;
|
|
283
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Shell-command execution + the progress logger. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
|
|
4
|
+
import { ROOT } from '../config.mjs';
|
|
5
|
+
|
|
6
|
+
/** Progress line printed while collectors run. */
|
|
7
|
+
export const log = (...a) => console.log('•', ...a);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run a shell command, returning {code, stdout, stderr, error} — never throws.
|
|
11
|
+
* Defaults the working directory to the repository root so callers can stay
|
|
12
|
+
* path-agnostic; pass `cwd` to override.
|
|
13
|
+
*/
|
|
14
|
+
export function exec(cmd, { cwd = ROOT, timeout = 600_000 } = {}) {
|
|
15
|
+
const r = spawnSync(cmd, {
|
|
16
|
+
cwd,
|
|
17
|
+
shell: true,
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
timeout,
|
|
20
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
21
|
+
windowsHide: true,
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
code: r.status ?? (r.error ? 1 : 0),
|
|
25
|
+
stdout: r.stdout || '',
|
|
26
|
+
stderr: r.stderr || '',
|
|
27
|
+
error: r.error || null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Pure string/number formatting helpers shared by the renderers. */
|
|
2
|
+
|
|
3
|
+
/** HTML-escape a value for safe interpolation into the report. */
|
|
4
|
+
export const esc = (s) =>
|
|
5
|
+
String(s).replace(
|
|
6
|
+
/[&<>"']/g,
|
|
7
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c],
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
/** Format a percentage (one decimal), or em-dash when missing/non-numeric. */
|
|
11
|
+
export const pct = (n) => (typeof n === 'number' && Number.isFinite(n) ? `${n.toFixed(1)}%` : '—');
|
|
12
|
+
|
|
13
|
+
/** Format an integer with thousands separators, or em-dash when missing/non-numeric. */
|
|
14
|
+
export const num = (n) => {
|
|
15
|
+
const v = Number(n);
|
|
16
|
+
return n == null || Number.isNaN(v) ? '—' : v.toLocaleString('en-US');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Format a byte count as B / KB / MB, or em-dash when missing/non-numeric. */
|
|
20
|
+
export const bytes = (n) => {
|
|
21
|
+
const v = Number(n);
|
|
22
|
+
if (n == null || Number.isNaN(v)) {
|
|
23
|
+
return '—';
|
|
24
|
+
}
|
|
25
|
+
if (v < 1024) {
|
|
26
|
+
return `${v} B`;
|
|
27
|
+
}
|
|
28
|
+
if (v < 1024 * 1024) {
|
|
29
|
+
return `${(v / 1024).toFixed(1)} KB`;
|
|
30
|
+
}
|
|
31
|
+
return `${(v / 1024 / 1024).toFixed(2)} MB`;
|
|
32
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Filesystem walking helpers shared by the collectors. */
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { ROOT } from '../config.mjs';
|
|
6
|
+
|
|
7
|
+
/** Directories never descended into when scanning the tree. */
|
|
8
|
+
export const IGNORE_DIRS = new Set([
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.git',
|
|
11
|
+
'.vercel',
|
|
12
|
+
'.turbo',
|
|
13
|
+
'coverage',
|
|
14
|
+
'reports',
|
|
15
|
+
'dist',
|
|
16
|
+
'build',
|
|
17
|
+
'out',
|
|
18
|
+
'playwright-report',
|
|
19
|
+
'test-results',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/** Recursively collect every file under `dir`, skipping IGNORE_DIRS and dotfiles. */
|
|
23
|
+
export function walk(dir, files = []) {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return files;
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
if (e.name.startsWith('.') && e.name !== '.github') {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const full = path.join(dir, e.name);
|
|
35
|
+
if (e.isDirectory()) {
|
|
36
|
+
if (IGNORE_DIRS.has(e.name)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
walk(full, files);
|
|
40
|
+
} else if (e.isFile()) {
|
|
41
|
+
files.push(full);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return files;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Project-root-relative POSIX path for display. */
|
|
48
|
+
export const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
|