@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.
Files changed (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/audit-structure.mjs +103 -0
  5. package/src/bundle-codebase.mjs +626 -0
  6. package/src/check-images.mjs +125 -0
  7. package/src/coverage/clean.mjs +32 -0
  8. package/src/coverage/merge-istanbul.mjs +161 -0
  9. package/src/coverage/next-start-cov.mjs +38 -0
  10. package/src/coverage/report-global.mjs +90 -0
  11. package/src/coverage/report-suite.mjs +148 -0
  12. package/src/coverage/src-filter.mjs +50 -0
  13. package/src/dashboard/collectors/code.mjs +104 -0
  14. package/src/dashboard/collectors/composition-meta.mjs +295 -0
  15. package/src/dashboard/collectors/composition-transitions.mjs +0 -0
  16. package/src/dashboard/collectors/composition.mjs +360 -0
  17. package/src/dashboard/collectors/coverage.mjs +98 -0
  18. package/src/dashboard/collectors/deps.mjs +187 -0
  19. package/src/dashboard/collectors/entities.mjs +147 -0
  20. package/src/dashboard/collectors/graph.mjs +105 -0
  21. package/src/dashboard/collectors/lint.mjs +117 -0
  22. package/src/dashboard/collectors/routing.mjs +82 -0
  23. package/src/dashboard/collectors/security.mjs +182 -0
  24. package/src/dashboard/collectors/storybook.mjs +33 -0
  25. package/src/dashboard/config.mjs +15 -0
  26. package/src/dashboard/render/client.mjs +178 -0
  27. package/src/dashboard/render/components.mjs +247 -0
  28. package/src/dashboard/render/composition.mjs +192 -0
  29. package/src/dashboard/render/styles.mjs +217 -0
  30. package/src/dashboard/render/template.mjs +283 -0
  31. package/src/dashboard/utils/exec.mjs +29 -0
  32. package/src/dashboard/utils/format.mjs +32 -0
  33. package/src/dashboard/utils/fs.mjs +48 -0
  34. package/src/e2e-server-guard.mjs +283 -0
  35. package/src/optimize-images.mjs +231 -0
  36. package/src/quality-dashboard.mjs +291 -0
  37. package/src/security-scan.mjs +267 -0
  38. package/src/test-outline.mjs +98 -0
@@ -0,0 +1,104 @@
1
+ /** 1. Code overview — file/line counts grouped by language. */
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { ROOT } from '../config.mjs';
6
+ import { walk } from '../utils/fs.mjs';
7
+
8
+ // Extensions we treat as text and count lines for.
9
+ const TEXT_EXT = new Set([
10
+ '.ts',
11
+ '.tsx',
12
+ '.js',
13
+ '.jsx',
14
+ '.mjs',
15
+ '.cjs',
16
+ '.json',
17
+ '.css',
18
+ '.scss',
19
+ '.md',
20
+ '.mdx',
21
+ '.yml',
22
+ '.yaml',
23
+ '.html',
24
+ '.txt',
25
+ '.svg',
26
+ ]);
27
+ // Subset considered "source code" (not config/docs/assets).
28
+ const CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss']);
29
+
30
+ const LANG_NAME = {
31
+ '.ts': 'TypeScript',
32
+ '.tsx': 'TypeScript (JSX)',
33
+ '.js': 'JavaScript',
34
+ '.jsx': 'JavaScript (JSX)',
35
+ '.mjs': 'ES Modules',
36
+ '.cjs': 'CommonJS',
37
+ '.json': 'JSON',
38
+ '.css': 'CSS',
39
+ '.scss': 'SCSS',
40
+ '.md': 'Markdown',
41
+ '.mdx': 'MDX',
42
+ '.yml': 'YAML',
43
+ '.yaml': 'YAML',
44
+ '.html': 'HTML',
45
+ '.svg': 'SVG',
46
+ '.txt': 'Text',
47
+ };
48
+
49
+ export function collectCode() {
50
+ const files = walk(ROOT);
51
+ const byExt = {};
52
+ let totalFiles = 0,
53
+ totalLines = 0,
54
+ codeFiles = 0,
55
+ codeLines = 0;
56
+ let blank = 0,
57
+ comment = 0;
58
+
59
+ for (const f of files) {
60
+ const ext = path.extname(f).toLowerCase();
61
+ if (!TEXT_EXT.has(ext)) {
62
+ continue;
63
+ }
64
+ let content;
65
+ try {
66
+ content = readFileSync(f, 'utf8');
67
+ } catch {
68
+ continue;
69
+ }
70
+ const lines = content.length ? content.split(/\r?\n/) : [];
71
+ const e = (byExt[ext] ||= { files: 0, lines: 0 });
72
+ e.files++;
73
+ e.lines += lines.length;
74
+ totalFiles++;
75
+ totalLines += lines.length;
76
+ if (CODE_EXT.has(ext)) {
77
+ codeFiles++;
78
+ codeLines += lines.length;
79
+ for (const ln of lines) {
80
+ const t = ln.trim();
81
+ if (!t) {
82
+ blank++;
83
+ } else if (/^(\/\/|\/\*|\*|\*\/|<!--|#)/.test(t)) {
84
+ comment++;
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ const languages = Object.entries(byExt)
91
+ .map(([ext, v]) => ({ ext, name: LANG_NAME[ext] || ext, files: v.files, lines: v.lines }))
92
+ .sort((a, b) => b.lines - a.lines);
93
+
94
+ return {
95
+ totalFiles,
96
+ totalLines,
97
+ codeFiles,
98
+ codeLines,
99
+ blank,
100
+ comment,
101
+ codeOnly: Math.max(0, codeLines - blank - comment),
102
+ languages,
103
+ };
104
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Component-composition static analysis — the per-file parsing the composition
3
+ * collector leans on (kept separate so collectComposition stays orchestration).
4
+ *
5
+ * Everything here is regex/string scanning over comment-stripped source: it never
6
+ * executes the code. The exported `extract*` helpers pull out exactly the fields
7
+ * the dashboard prints in each box (description / props / state / events / API);
8
+ * the binding/JSX helpers feed the collector's "renders" edge detection.
9
+ */
10
+
11
+ export function stripComments(code) {
12
+ return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
13
+ }
14
+
15
+ /** JSX-bearing .tsx/.jsx file → treat as a React component module. */
16
+ export function isComponentFile(file, code) {
17
+ if (!/\.(tsx|jsx)$/.test(file)) {
18
+ return false;
19
+ }
20
+ return /<\/[A-Za-z][\w.]*>/.test(code) || /<[A-Za-z][\w.]*[^>]*\/>/.test(code) || /<>/.test(code);
21
+ }
22
+
23
+ /** Local binding names introduced by an `import <clause> from …` statement. */
24
+ export function parseBindings(clause) {
25
+ const names = [];
26
+ const ns = clause.match(/\*\s*as\s+([A-Za-z_$][\w$]*)/);
27
+ if (ns) {
28
+ names.push(ns[1]);
29
+ }
30
+ const braced = clause.match(/\{([^}]*)}/);
31
+ if (braced) {
32
+ for (const part of braced[1].split(',')) {
33
+ const seg = part.trim();
34
+ if (!seg) {
35
+ continue;
36
+ }
37
+ const m = seg.match(/[\w$]+\s+as\s+([A-Za-z_$][\w$]*)/) || seg.match(/^([A-Za-z_$][\w$]*)/);
38
+ if (m) {
39
+ names.push(m[1]);
40
+ }
41
+ }
42
+ }
43
+ const head = clause.replace(/\{[^}]*}/g, '').replace(/\*\s*as\s+[\w$]+/g, '');
44
+ const def = head.match(/^[\s,]*([A-Za-z_$][\w$]*)/);
45
+ if (def) {
46
+ names.push(def[1]);
47
+ }
48
+ return names;
49
+ }
50
+
51
+ /** Capitalised JSX tag roots actually used in the file (<Foo …> and <NS.Foo …>). */
52
+ export function usedJsxTags(code) {
53
+ const set = new Set();
54
+ const re = /<([A-Z][A-Za-z0-9_]*)[\s/>.]/g;
55
+ let m;
56
+ while ((m = re.exec(code))) {
57
+ set.add(m[1]);
58
+ }
59
+ return set;
60
+ }
61
+
62
+ /** Substring between the matching delimiters, starting at the opener at `open`. */
63
+ function sliceBalanced(s, open, oc, cc) {
64
+ let depth = 0;
65
+ for (let i = open; i < s.length; i++) {
66
+ const c = s[i];
67
+ if (c === oc) {
68
+ depth++;
69
+ } else if (c === cc) {
70
+ depth--;
71
+ if (depth === 0) {
72
+ return s.slice(open + 1, i);
73
+ }
74
+ }
75
+ }
76
+ return '';
77
+ }
78
+
79
+ /** Exported component name (first PascalCase export), else the file basename. */
80
+ export function primaryName(code, relPath) {
81
+ const fn = code.match(/export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Z]\w*)/);
82
+ if (fn) {
83
+ return fn[1];
84
+ }
85
+ const cn = code.match(/export\s+const\s+([A-Z]\w*)\s*[:=]/);
86
+ if (cn) {
87
+ return cn[1];
88
+ }
89
+ return relPath
90
+ .split('/')
91
+ .pop()
92
+ .replace(/\.[jt]sx?$/, '');
93
+ }
94
+
95
+ function cleanDoc(s) {
96
+ const t = s
97
+ .replace(/^\s*\*\s?/gm, ' ')
98
+ .replace(/\{@link\s+([^}|]+)(?:\|[^}]+)?}/g, '$1')
99
+ .replace(/@\w+/g, ' ')
100
+ .replace(/\s+/g, ' ')
101
+ .trim();
102
+ if (!t) {
103
+ return '';
104
+ }
105
+ const twoSentences = t
106
+ .split(/(?<=\.)\s/)
107
+ .slice(0, 2)
108
+ .join(' ');
109
+ const out = twoSentences || t;
110
+ return out.length > 200 ? out.slice(0, 197).trimEnd() + '…' : out;
111
+ }
112
+
113
+ /** Leading JSDoc block that precedes the named component (else the last block). */
114
+ export function extractDescription(raw, name) {
115
+ const blocks = [...raw.matchAll(/\/\*\*([\s\S]*?)\*\//g)];
116
+ if (!blocks.length) {
117
+ return '';
118
+ }
119
+ const nameRe = new RegExp('(?:function|const)\\s+' + name + '\\b');
120
+ for (const b of blocks) {
121
+ const after = raw.slice(b.index + b[0].length, b.index + b[0].length + 140);
122
+ if (nameRe.test(after) || /^\s*export\s+default/.test(after)) {
123
+ return cleanDoc(b[1]);
124
+ }
125
+ }
126
+ return cleanDoc(blocks[blocks.length - 1][1]);
127
+ }
128
+
129
+ /** Parse a TS object/interface body into [{ name, type }]. */
130
+ function parseTypeBody(body) {
131
+ const out = [];
132
+ for (const raw of body.split(';')) {
133
+ const seg = raw.trim();
134
+ if (!seg || seg.startsWith('//')) {
135
+ continue;
136
+ }
137
+ const m = seg.match(/^(?:readonly\s+)?([A-Za-z_]\w*)\s*\??\s*:\s*([\s\S]+)$/);
138
+ if (m) {
139
+ out.push({ name: m[1], type: m[2].replace(/\s+/g, ' ').trim() });
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function propsFromNamedType(code, typeName) {
146
+ const iface = new RegExp('interface\\s+' + typeName + '\\s*(?:extends[^{]+)?\\{').exec(code);
147
+ if (iface) {
148
+ return parseTypeBody(sliceBalanced(code, iface.index + iface[0].length - 1, '{', '}'));
149
+ }
150
+ const ty = new RegExp('type\\s+' + typeName + '\\s*=\\s*\\{').exec(code);
151
+ if (ty) {
152
+ return parseTypeBody(sliceBalanced(code, ty.index + ty[0].length - 1, '{', '}'));
153
+ }
154
+ return [];
155
+ }
156
+
157
+ /** Best-effort prop types for the destructured names from a component signature. */
158
+ function typesForParams(code, params, destrLen) {
159
+ const inlineObj = params.match(/}\s*:\s*\{([\s\S]*)\}\s*$/);
160
+ if (inlineObj) {
161
+ return Object.fromEntries(parseTypeBody(inlineObj[1]).map((p) => [p.name, p.type]));
162
+ }
163
+ const named = params.slice(destrLen).match(/^\s*:\s*([A-Za-z_]\w*)/);
164
+ if (named) {
165
+ return Object.fromEntries(propsFromNamedType(code, named[1]).map((p) => [p.name, p.type]));
166
+ }
167
+ return {};
168
+ }
169
+
170
+ /** The component's props (interface) — destructured names + best-effort types. */
171
+ export function extractProps(code, name) {
172
+ let open = -1;
173
+ const fn = new RegExp('function\\s+' + name + '\\s*\\(').exec(code);
174
+ if (fn) {
175
+ open = fn.index + fn[0].length - 1;
176
+ } else {
177
+ const arrow = new RegExp(
178
+ 'const\\s+' + name + '\\s*(?::\\s*[\\w.]+(?:<[^>]*>)?)?\\s*=\\s*(?:async\\s*)?\\(',
179
+ ).exec(code);
180
+ if (arrow) {
181
+ open = arrow.index + arrow[0].length - 1;
182
+ } else {
183
+ const generic = new RegExp('const\\s+' + name + '\\s*:\\s*[\\w.]+<\\s*([A-Za-z_]\\w*)').exec(
184
+ code,
185
+ );
186
+ return generic ? propsFromNamedType(code, generic[1]) : [];
187
+ }
188
+ }
189
+ const params = sliceBalanced(code, open, '(', ')').trim();
190
+ if (!params) {
191
+ return [];
192
+ }
193
+ const destr = params.match(/^\{([\s\S]*?)\}\s*(?::|$)/);
194
+ if (destr) {
195
+ const names = destr[1]
196
+ .split(',')
197
+ .map((s) => s.trim().split(/[:=]/)[0].trim())
198
+ .filter((n) => /^(?:\.\.\.)?[A-Za-z_]/.test(n))
199
+ .map((n) => n.replace(/^\.\.\./, '…'));
200
+ const types = typesForParams(code, params, destr[0].length);
201
+ return names.map((n) => ({ name: n, type: types[n] || '' }));
202
+ }
203
+ const named = params.match(/:\s*([A-Za-z_]\w*)\s*$/);
204
+ return named ? propsFromNamedType(code, named[1]) : [];
205
+ }
206
+
207
+ export function extractState(code) {
208
+ const out = [];
209
+ const seen = new Set();
210
+ const push = (nm, hook) => {
211
+ if (nm && !seen.has(nm)) {
212
+ seen.add(nm);
213
+ out.push({ name: nm, hook });
214
+ }
215
+ };
216
+ let m;
217
+ const stateRe = /const\s*\[\s*([A-Za-z_]\w*)\s*,[^\]]*\]\s*=\s*(useState|useReducer)\b/g;
218
+ while ((m = stateRe.exec(code))) {
219
+ push(m[1], m[2]);
220
+ }
221
+ const otherRe = /const\s+([A-Za-z_]\w*)\s*=\s*(useRef|useContext)\b/g;
222
+ while ((m = otherRe.exec(code))) {
223
+ push(m[1], m[2]);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ export function extractEvents(code, props) {
229
+ const callbacks = props.map((p) => p.name).filter((n) => /^on[A-Z]/.test(n));
230
+ const tracked = [];
231
+ const seen = new Set();
232
+ let m;
233
+ const re = /trackEvent\(\s*['"]([^'"]+)['"]/g;
234
+ while ((m = re.exec(code))) {
235
+ if (!seen.has(m[1])) {
236
+ seen.add(m[1]);
237
+ tracked.push(m[1]);
238
+ }
239
+ }
240
+ return { callbacks, tracked };
241
+ }
242
+
243
+ /**
244
+ * Same-origin navigation targets a file points at — the raw path-ish strings from
245
+ * `href="/…"`, `href={'/…'}`, `href={`/…`}` and `router.push/replace('/…')` /
246
+ * `redirect('/…')`. Query/hash are stripped; external (http/mailto/tel), protocol-
247
+ * relative (`//`) and pure-hash links are dropped (they are not page→page hops).
248
+ * Template interpolations (`${…}`) are kept verbatim — the collector turns them
249
+ * into a wildcard segment when matching against dynamic routes.
250
+ */
251
+ export function extractNavHrefs(code) {
252
+ const out = new Set();
253
+ const add = (raw) => {
254
+ const s = String(raw).trim().split('#')[0].split('?')[0];
255
+ if (s.startsWith('/') && !s.startsWith('//')) {
256
+ out.add(s);
257
+ }
258
+ };
259
+ let m;
260
+ const hrefRe = /\bhref\s*=\s*\{?\s*(['"`])([^'"`]*)\1/g;
261
+ while ((m = hrefRe.exec(code))) {
262
+ add(m[2]);
263
+ }
264
+ const navRe = /\b(?:router\s*\.\s*(?:push|replace)|redirect)\s*\(\s*(['"`])([^'"`]*)\1/g;
265
+ while ((m = navRe.exec(code))) {
266
+ add(m[2]);
267
+ }
268
+ return [...out];
269
+ }
270
+
271
+ export function extractExports(code) {
272
+ const names = new Set();
273
+ let m;
274
+ const declRe = /export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_]\w*)/g;
275
+ while ((m = declRe.exec(code))) {
276
+ names.add(m[1]);
277
+ }
278
+ const braceRe = /export\s*(?:type\s*)?\{([^}]*)\}/g;
279
+ while ((m = braceRe.exec(code))) {
280
+ for (const part of m[1].split(',')) {
281
+ const seg = part
282
+ .trim()
283
+ .split(/\s+as\s+/)
284
+ .pop()
285
+ .trim();
286
+ if (/^[A-Za-z_]\w*$/.test(seg)) {
287
+ names.add(seg);
288
+ }
289
+ }
290
+ }
291
+ if (/export\s+default/.test(code)) {
292
+ names.add('default');
293
+ }
294
+ return [...names];
295
+ }