@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,147 @@
1
+ /** 5d. Data model — tables/columns/buckets parsed from supabase/migrations/*.sql. */
2
+ import { existsSync, 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
+ /** Split a CREATE TABLE body on top-level commas (ignoring nested parens). */
9
+ function splitTopLevel(body) {
10
+ const parts = [];
11
+ let depth = 0;
12
+ let cur = '';
13
+ for (const ch of body) {
14
+ if (ch === '(') {
15
+ depth++;
16
+ } else if (ch === ')') {
17
+ depth--;
18
+ }
19
+ if (ch === ',' && depth === 0) {
20
+ parts.push(cur);
21
+ cur = '';
22
+ } else {
23
+ cur += ch;
24
+ }
25
+ }
26
+ if (cur.trim()) {
27
+ parts.push(cur);
28
+ }
29
+ return parts;
30
+ }
31
+
32
+ function parseColumns(body) {
33
+ const cols = [];
34
+ for (const raw of splitTopLevel(body)) {
35
+ const line = raw.trim().replace(/\s+/g, ' ');
36
+ if (!line) {
37
+ continue;
38
+ }
39
+ if (/^(PRIMARY|FOREIGN|CONSTRAINT|UNIQUE|CHECK)\b/i.test(line)) {
40
+ continue;
41
+ } // table-level
42
+ const m = /^"?([a-zA-Z0-9_]+)"?\s+(.+)$/.exec(line);
43
+ if (!m) {
44
+ continue;
45
+ }
46
+ const name = m[1];
47
+ const rest = m[2];
48
+ const typeM = /^([a-zA-Z0-9_]+(?:\s*\([^)]*\))?(?:\s*\[])?)/.exec(rest);
49
+ const type = (typeM ? typeM[1] : rest.split(' ')[0]).replace(/\s+/g, '');
50
+ const pk = /\bPRIMARY\s+KEY\b/i.test(rest);
51
+ const fkM = /\bREFERENCES\s+"?([a-zA-Z0-9_.]+)"?\s*(?:\(\s*"?([a-zA-Z0-9_]+)"?\s*\))?/i.exec(
52
+ rest,
53
+ );
54
+ cols.push({
55
+ name,
56
+ type,
57
+ pk,
58
+ notnull: pk || /\bNOT\s+NULL\b/i.test(rest),
59
+ fk: fkM ? { table: fkM[1].replace(/"/g, ''), col: fkM[2] || '' } : null,
60
+ });
61
+ }
62
+ return cols;
63
+ }
64
+
65
+ /** Parse CREATE TABLE / ALTER TABLE / indexes / storage buckets from migrations. */
66
+ export function collectEntities() {
67
+ const dir = path.join(ROOT, 'supabase', 'migrations');
68
+ if (!existsSync(dir)) {
69
+ return { available: false, tables: [], buckets: [] };
70
+ }
71
+ const files = walk(dir)
72
+ .filter((f) => f.toLowerCase().endsWith('.sql'))
73
+ .sort();
74
+ if (!files.length) {
75
+ return { available: false, tables: [], buckets: [] };
76
+ }
77
+
78
+ const tables = new Map();
79
+ const buckets = [];
80
+ const ensure = (name) =>
81
+ tables.get(name) || tables.set(name, { name, columns: [], rls: false, indexes: [] }).get(name);
82
+
83
+ for (const f of files) {
84
+ let sql;
85
+ try {
86
+ sql = readFileSync(f, 'utf8');
87
+ } catch {
88
+ continue;
89
+ }
90
+ const clean = sql.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '');
91
+ let m;
92
+
93
+ const ctRe =
94
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([a-zA-Z0-9_.]+)"?\s*\(([\s\S]*?)\)\s*;/gi;
95
+ while ((m = ctRe.exec(clean))) {
96
+ const t = ensure(m[1].replace(/"/g, ''));
97
+ for (const c of parseColumns(m[2])) {
98
+ if (!t.columns.find((x) => x.name === c.name)) {
99
+ t.columns.push(c);
100
+ }
101
+ }
102
+ }
103
+
104
+ const acRe =
105
+ /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?"?([a-zA-Z0-9_.]+)"?\s+ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([a-zA-Z0-9_]+)"?\s+([a-zA-Z0-9_]+(?:\s*\([^)]*\))?(?:\s*\[])?)/gi;
106
+ while ((m = acRe.exec(clean))) {
107
+ const t = ensure(m[1].replace(/"/g, ''));
108
+ if (!t.columns.find((x) => x.name === m[2])) {
109
+ t.columns.push({
110
+ name: m[2],
111
+ type: m[3].replace(/\s+/g, ''),
112
+ pk: false,
113
+ notnull: false,
114
+ fk: null,
115
+ });
116
+ }
117
+ }
118
+
119
+ const rlsRe =
120
+ /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?"?([a-zA-Z0-9_.]+)"?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
121
+ while ((m = rlsRe.exec(clean))) {
122
+ const t = tables.get(m[1].replace(/"/g, ''));
123
+ if (t) {
124
+ t.rls = true;
125
+ }
126
+ }
127
+
128
+ const idxRe =
129
+ /CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([a-zA-Z0-9_]+)"?\s+ON\s+"?([a-zA-Z0-9_.]+)"?\s*\(([^)]*)\)/gi;
130
+ while ((m = idxRe.exec(clean))) {
131
+ const t = tables.get(m[2].replace(/"/g, ''));
132
+ if (t) {
133
+ t.indexes.push({ name: m[1], cols: m[3].trim().replace(/\s+/g, ' ') });
134
+ }
135
+ }
136
+
137
+ const bkRe =
138
+ /INSERT\s+INTO\s+storage\.buckets[\s\S]*?VALUES\s*\(\s*'([^']+)'\s*,\s*'([^']+)'\s*,\s*(true|false)/gi;
139
+ while ((m = bkRe.exec(clean))) {
140
+ if (!buckets.find((b) => b.id === m[1])) {
141
+ buckets.push({ id: m[1], name: m[2], public: m[3] === 'true' });
142
+ }
143
+ }
144
+ }
145
+
146
+ return { available: true, tables: [...tables.values()], buckets };
147
+ }
@@ -0,0 +1,105 @@
1
+ /** 5. File relationship graph — internal import edges + fan-in/out. */
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { SRC } from '../config.mjs';
6
+ import { rel, walk } from '../utils/fs.mjs';
7
+
8
+ export function stripComments(code) {
9
+ return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
10
+ }
11
+
12
+ export function resolveImport(spec, fromFile) {
13
+ let base;
14
+ if (spec.startsWith('@/')) {
15
+ base = path.join(SRC, spec.slice(2));
16
+ } else if (spec.startsWith('.')) {
17
+ base = path.resolve(path.dirname(fromFile), spec);
18
+ } else {
19
+ return null;
20
+ } // external package
21
+ const tries = [
22
+ base,
23
+ base + '.ts',
24
+ base + '.tsx',
25
+ base + '.js',
26
+ base + '.jsx',
27
+ path.join(base, 'index.ts'),
28
+ path.join(base, 'index.tsx'),
29
+ path.join(base, 'index.js'),
30
+ path.join(base, 'index.jsx'),
31
+ ];
32
+ for (const t of tries) {
33
+ try {
34
+ if (statSync(t).isFile()) {
35
+ return t;
36
+ }
37
+ } catch {}
38
+ }
39
+ return null;
40
+ }
41
+
42
+ export function externalName(spec) {
43
+ if (spec.startsWith('@/') || spec.startsWith('.')) {
44
+ return null;
45
+ }
46
+ if (spec.startsWith('@')) {
47
+ return spec.split('/').slice(0, 2).join('/');
48
+ }
49
+ return spec.split('/')[0];
50
+ }
51
+
52
+ export function collectGraph() {
53
+ const files = walk(SRC).filter((f) => /\.(ts|tsx|js|jsx)$/.test(f) && !f.endsWith('.d.ts'));
54
+ const set = new Set(files);
55
+ const idOf = new Map(files.map((f, i) => [f, i]));
56
+ const importRe =
57
+ /(?:\bimport\b[\s\S]*?\bfrom\s*|\bexport\b[\s\S]*?\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)["']([^"']+)["']/g;
58
+
59
+ const nodes = files.map((f) => ({
60
+ id: idOf.get(f),
61
+ label: rel(f).replace(/^src\//, ''),
62
+ group: rel(f).split('/')[1] || 'root',
63
+ fanIn: 0,
64
+ fanOut: 0,
65
+ }));
66
+ const edges = [];
67
+ const externals = new Map();
68
+
69
+ for (const f of files) {
70
+ let code;
71
+ try {
72
+ code = stripComments(readFileSync(f, 'utf8'));
73
+ } catch {
74
+ continue;
75
+ }
76
+ const seen = new Set();
77
+ let m;
78
+ while ((m = importRe.exec(code))) {
79
+ const spec = m[1];
80
+ if (seen.has(spec)) {
81
+ continue;
82
+ }
83
+ seen.add(spec);
84
+ const ext = externalName(spec);
85
+ if (ext) {
86
+ externals.set(ext, (externals.get(ext) || 0) + 1);
87
+ continue;
88
+ }
89
+ const target = resolveImport(spec, f);
90
+ if (target && set.has(target) && target !== f) {
91
+ edges.push({ source: idOf.get(f), target: idOf.get(target) });
92
+ nodes[idOf.get(f)].fanOut++;
93
+ nodes[idOf.get(target)].fanIn++;
94
+ }
95
+ }
96
+ }
97
+
98
+ return {
99
+ nodeCount: nodes.length,
100
+ edgeCount: edges.length,
101
+ externalCount: externals.size,
102
+ importedExternals: new Set(externals.keys()),
103
+ graph: { nodes, edges },
104
+ };
105
+ }
@@ -0,0 +1,117 @@
1
+ /** 3. Linter + typecheck — collect-only.
2
+ *
3
+ * Reads artifacts produced elsewhere (the dashboard never runs eslint/tsc itself):
4
+ * - ESLint JSON → dist/reports/eslint.json
5
+ * generate with: npm run lint -- -f json -o dist/reports/eslint.json
6
+ * - tsc --noEmit → dist/reports/tsc.log
7
+ * generate with: npm run typecheck > dist/reports/tsc.log 2>&1
8
+ * When an artifact is absent the section is marked missing with the command above.
9
+ */
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ import { ROOT } from '../config.mjs';
14
+ import { rel } from '../utils/fs.mjs';
15
+
16
+ const ESLINT_ARTIFACT = path.join(ROOT, 'dist', 'reports', 'eslint.json');
17
+ const TSC_ARTIFACT = path.join(ROOT, 'dist', 'reports', 'tsc.log');
18
+
19
+ const ESLINT_HINT = 'npm run lint -- -f json -o dist/reports/eslint.json';
20
+ const TSC_HINT = 'npm run typecheck > dist/reports/tsc.log 2>&1';
21
+
22
+ /** Parse an ESLint JSON-formatter array into the shape the renderer expects. */
23
+ function parseEslint(json) {
24
+ let errors = 0,
25
+ warnings = 0;
26
+ const byRule = {};
27
+ const fileList = [];
28
+ const problems = [];
29
+ for (const f of json) {
30
+ if (!f.errorCount && !f.warningCount) {
31
+ continue;
32
+ }
33
+ errors += f.errorCount || 0;
34
+ warnings += f.warningCount || 0;
35
+ fileList.push({
36
+ file: rel(f.filePath),
37
+ errors: f.errorCount || 0,
38
+ warnings: f.warningCount || 0,
39
+ });
40
+ for (const m of f.messages || []) {
41
+ const id = m.ruleId || '(syntax)';
42
+ const b = (byRule[id] ||= { rule: id, errors: 0, warnings: 0 });
43
+ if (m.severity === 2) {
44
+ b.errors++;
45
+ } else {
46
+ b.warnings++;
47
+ }
48
+ problems.push({
49
+ file: rel(f.filePath),
50
+ line: m.line ?? 0,
51
+ column: m.column ?? 0,
52
+ rule: id,
53
+ severity: m.severity === 2 ? 'error' : 'warning',
54
+ message: m.message || '',
55
+ });
56
+ }
57
+ }
58
+ // errors first, then by file/line — this is the "where & what" detail list.
59
+ problems.sort(
60
+ (a, b) =>
61
+ (a.severity === b.severity ? 0 : a.severity === 'error' ? -1 : 1) ||
62
+ a.file.localeCompare(b.file) ||
63
+ a.line - b.line,
64
+ );
65
+ return {
66
+ available: true,
67
+ errors,
68
+ warnings,
69
+ rules: Object.values(byRule).sort((a, b) => b.errors - a.errors || b.warnings - a.warnings),
70
+ files: fileList.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings).slice(0, 40),
71
+ problems,
72
+ problemsShown: Math.min(problems.length, 200),
73
+ };
74
+ }
75
+
76
+ export function collectLint() {
77
+ const out = { eslint: null, tsc: null };
78
+
79
+ // ── ESLint ──
80
+ if (existsSync(ESLINT_ARTIFACT)) {
81
+ let json;
82
+ try {
83
+ json = JSON.parse(readFileSync(ESLINT_ARTIFACT, 'utf8'));
84
+ } catch {
85
+ json = null;
86
+ }
87
+ out.eslint = Array.isArray(json)
88
+ ? parseEslint(json)
89
+ : {
90
+ available: false,
91
+ note: `dist/reports/eslint.json is not valid JSON. Regenerate: ${ESLINT_HINT}`,
92
+ };
93
+ } else {
94
+ out.eslint = { available: false, note: `Not generated. Run: ${ESLINT_HINT}` };
95
+ }
96
+
97
+ // ── tsc --noEmit ──
98
+ if (existsSync(TSC_ARTIFACT)) {
99
+ let txt;
100
+ try {
101
+ txt = readFileSync(TSC_ARTIFACT, 'utf8');
102
+ } catch {
103
+ txt = '';
104
+ }
105
+ const tsErrors = txt.split(/\r?\n/).filter((l) => /error TS\d+:/.test(l));
106
+ out.tsc = {
107
+ available: true,
108
+ ok: tsErrors.length === 0,
109
+ errors: tsErrors.length,
110
+ sample: tsErrors.slice(0, 30),
111
+ };
112
+ } else {
113
+ out.tsc = { available: false, note: `Not generated. Run: ${TSC_HINT}` };
114
+ }
115
+
116
+ return out;
117
+ }
@@ -0,0 +1,82 @@
1
+ /** 5b. Routing — Next.js App Router tree with resolved URLs. */
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { SRC } from '../config.mjs';
6
+ import { IGNORE_DIRS } from '../utils/fs.mjs';
7
+
8
+ const ROUTE_FILE_KINDS = new Set([
9
+ 'page',
10
+ 'layout',
11
+ 'route',
12
+ 'loading',
13
+ 'error',
14
+ 'global-error',
15
+ 'not-found',
16
+ 'template',
17
+ 'default',
18
+ ]);
19
+
20
+ function routeFileKind(name) {
21
+ const base = name.replace(/\.(tsx|ts|jsx|js)$/, '');
22
+ return ROUTE_FILE_KINDS.has(base) ? base : null;
23
+ }
24
+
25
+ /** Walk src/app and build an expandable route tree with URL paths. */
26
+ export function collectRouting() {
27
+ const appDir = path.join(SRC, 'app');
28
+ if (!existsSync(appDir)) {
29
+ return { available: false };
30
+ }
31
+
32
+ const build = (dir, seg) => {
33
+ let entries = [];
34
+ try {
35
+ entries = readdirSync(dir, { withFileTypes: true });
36
+ } catch {}
37
+ const node = {
38
+ seg,
39
+ dynamic: /^\[.+]$/.test(seg),
40
+ group: /^\(.+\)$/.test(seg),
41
+ kinds: [],
42
+ children: [],
43
+ };
44
+ for (const e of entries) {
45
+ if (e.isFile()) {
46
+ const k = routeFileKind(e.name);
47
+ if (k && !node.kinds.includes(k)) {
48
+ node.kinds.push(k);
49
+ }
50
+ }
51
+ }
52
+ for (const e of entries) {
53
+ if (e.isDirectory() && !IGNORE_DIRS.has(e.name)) {
54
+ node.children.push(build(path.join(dir, e.name), e.name));
55
+ }
56
+ }
57
+ node.kinds.sort();
58
+ node.children.sort((a, b) => a.seg.localeCompare(b.seg));
59
+ return node;
60
+ };
61
+
62
+ const root = build(appDir, '');
63
+ let pageCount = 0;
64
+ let routeCount = 0;
65
+ // Compute the URL each segment maps to (route groups don't appear in the URL).
66
+ const urlize = (node, parts) => {
67
+ const next = node.seg && !node.group ? [...parts, node.seg] : parts;
68
+ node.url = '/' + next.filter(Boolean).join('/');
69
+ if (node.kinds.includes('page')) {
70
+ pageCount++;
71
+ }
72
+ if (node.kinds.includes('route')) {
73
+ routeCount++;
74
+ }
75
+ for (const c of node.children) {
76
+ urlize(c, next);
77
+ }
78
+ };
79
+ urlize(root, []);
80
+
81
+ return { available: true, root, pageCount, routeCount };
82
+ }
@@ -0,0 +1,182 @@
1
+ /** 6–9. Security scanners — local SARIF/JSON artifacts (Snyk, CodeQL, Checkmarx). */
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { ROOT } from '../config.mjs';
6
+ import { rel, walk } from '../utils/fs.mjs';
7
+
8
+ function findSarifFiles() {
9
+ const found = [];
10
+ // Primary location: dist/reports (where dev/scripts/security-scan.mjs writes). walk()
11
+ // skips dist/, so scan it explicitly.
12
+ const reportsDir = path.join(ROOT, 'dist', 'reports');
13
+ if (existsSync(reportsDir)) {
14
+ for (const name of readdirSync(reportsDir)) {
15
+ if (name.toLowerCase().endsWith('.sarif')) {
16
+ found.push(path.join(reportsDir, name));
17
+ }
18
+ }
19
+ }
20
+ // Back-compat: any *.sarif left elsewhere in the tree (e.g. downloaded from CI to root).
21
+ found.push(...walk(ROOT).filter((f) => f.toLowerCase().endsWith('.sarif')));
22
+ return found;
23
+ }
24
+
25
+ function parseSarif(file) {
26
+ let doc;
27
+ try {
28
+ doc = JSON.parse(readFileSync(file, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ const sev = { critical: 0, high: 0, medium: 0, low: 0, note: 0 };
33
+ let driver = '';
34
+ const rules = {};
35
+ const findings = [];
36
+ for (const run of doc.runs || []) {
37
+ driver = run.tool?.driver?.name || driver;
38
+ // CodeQL (and others) attach `security-severity` / level + a human-readable
39
+ // title (shortDescription / help) to the rule, not the result.
40
+ const ruleMeta = {};
41
+ for (const r of run.tool?.driver?.rules || []) {
42
+ ruleMeta[r.id] = {
43
+ score: parseFloat(r.properties?.['security-severity']),
44
+ level: (r.defaultConfiguration?.level || '').toString().toLowerCase(),
45
+ title: (r.shortDescription?.text || r.name || '').trim(),
46
+ };
47
+ }
48
+ for (const res of run.results || []) {
49
+ const meta = ruleMeta[res.ruleId] || {};
50
+ const resScore = parseFloat(res.properties?.['security-severity']);
51
+ const score = Number.isNaN(resScore) ? meta.score : resScore;
52
+ const level = (res.level || meta.level || '').toString().toLowerCase();
53
+ let bucket = 'note';
54
+ if (!Number.isNaN(score)) {
55
+ bucket = score >= 9 ? 'critical' : score >= 7 ? 'high' : score >= 4 ? 'medium' : 'low';
56
+ } else if (level === 'error') {
57
+ bucket = 'high';
58
+ } else if (level === 'warning') {
59
+ bucket = 'medium';
60
+ } else if (level === 'note') {
61
+ bucket = 'low';
62
+ }
63
+ sev[bucket]++;
64
+ const rid = res.ruleId || 'rule';
65
+ rules[rid] = (rules[rid] || 0) + 1;
66
+
67
+ // "Why this is flagged" — the rule title is the headline reason, the result
68
+ // message is the specifics; keep both (deduped) plus the offending location.
69
+ const loc = res.locations?.[0]?.physicalLocation;
70
+ const uri = loc?.artifactLocation?.uri || '';
71
+ const line = loc?.region?.startLine || null;
72
+ const msg = (res.message?.text || '').trim();
73
+ const reason = meta.title && meta.title !== msg ? meta.title : msg || meta.title;
74
+ findings.push({
75
+ rule: rid,
76
+ severity: bucket,
77
+ reason,
78
+ detail: msg && msg !== reason ? msg : '',
79
+ where: uri ? `${uri}${line ? `:${line}` : ''}` : '',
80
+ });
81
+ }
82
+ }
83
+ const total = Object.values(sev).reduce((a, b) => a + b, 0);
84
+ // Most severe first, then by rule frequency, so the rendered list leads with what matters.
85
+ const rank = { critical: 0, high: 1, medium: 2, low: 3, note: 4 };
86
+ findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
87
+ return {
88
+ driver,
89
+ sev,
90
+ total,
91
+ findings,
92
+ topRules: Object.entries(rules)
93
+ .sort((a, b) => b[1] - a[1])
94
+ .slice(0, 5),
95
+ };
96
+ }
97
+
98
+ export function collectSecurity(repo) {
99
+ const sarifFiles = findSarifFiles();
100
+ const parsed = sarifFiles
101
+ .map((f) => ({ file: rel(f), ...parseSarif(f) }))
102
+ .filter((p) => p && p.driver != null);
103
+
104
+ const matchByName = (...needles) =>
105
+ parsed.filter((p) =>
106
+ needles.some(
107
+ (n) => p.file.toLowerCase().includes(n) || (p.driver || '').toLowerCase().includes(n),
108
+ ),
109
+ );
110
+
111
+ const ghSecurity = repo
112
+ ? `https://github.com/${repo.owner}/${repo.name}/security/code-scanning`
113
+ : null;
114
+ const ciLink = (tool) =>
115
+ ghSecurity ? `${ghSecurity}?query=tool%3A${encodeURIComponent(tool)}` : null;
116
+
117
+ // ── Snyk ──
118
+ const snykReports = [...matchByName('snyk')];
119
+ // snyk test --json (non-SARIF) fallback — check dist/reports first, then repo root.
120
+ for (const name of ['snyk.json', 'snyk-deps.json', 'snyk-code.json']) {
121
+ const p = [path.join(ROOT, 'dist', 'reports', name), path.join(ROOT, name)].find((c) =>
122
+ existsSync(c),
123
+ );
124
+ if (p) {
125
+ try {
126
+ const j = JSON.parse(readFileSync(p, 'utf8'));
127
+ const arr = Array.isArray(j) ? j : j.vulnerabilities || [];
128
+ const sev = { critical: 0, high: 0, medium: 0, low: 0, note: 0 };
129
+ const findings = [];
130
+ for (const v of arr) {
131
+ const s = (v.severity || 'low').toLowerCase();
132
+ sev[s] = (sev[s] || 0) + 1;
133
+ findings.push({
134
+ rule: v.id || v.packageName || 'vuln',
135
+ severity: s,
136
+ reason: (v.title || v.id || '').trim(),
137
+ detail: v.packageName ? `${v.packageName}${v.version ? `@${v.version}` : ''}` : '',
138
+ where: '',
139
+ });
140
+ }
141
+ snykReports.push({
142
+ file: name,
143
+ driver: 'Snyk',
144
+ sev,
145
+ total: arr.length,
146
+ findings,
147
+ topRules: [],
148
+ });
149
+ } catch {}
150
+ }
151
+ }
152
+ const snyk = {
153
+ name: 'Snyk',
154
+ tool: 'Open Source + Code (SAST/SCA)',
155
+ workflow: existsSync(path.join(ROOT, '.github/workflows/snyk.yml')),
156
+ reports: snykReports,
157
+ dashboard: 'https://app.snyk.io',
158
+ ci: ciLink('snyk'),
159
+ };
160
+
161
+ // ── CodeQL ──
162
+ const codeql = {
163
+ name: 'CodeQL',
164
+ tool: 'GitHub semantic SAST (javascript-typescript)',
165
+ workflow: existsSync(path.join(ROOT, '.github/workflows/codeql.yml')),
166
+ reports: matchByName('codeql'),
167
+ ci: ciLink('CodeQL'),
168
+ dashboard: ghSecurity,
169
+ };
170
+
171
+ // ── Checkmarx ──
172
+ const checkmarx = {
173
+ name: 'Checkmarx One',
174
+ tool: 'SAST · SCA · secret detection',
175
+ workflow: existsSync(path.join(ROOT, '.github/workflows/checkmarx.yml')),
176
+ reports: matchByName('cx-results', 'checkmarx'),
177
+ ci: ciLink('Checkmarx'),
178
+ dashboard: 'https://ast.checkmarx.net',
179
+ };
180
+
181
+ return { snyk, codeql, checkmarx, ghSecurity, anySarif: parsed.length };
182
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Storybook — collect-only.
3
+ *
4
+ * Counts the story files in the source tree and checks whether a static Storybook has
5
+ * been built next to the dashboard (dist/site/storybook). It never builds Storybook
6
+ * itself; `npm run quality:dashboard` builds it first (or run `npm run build-storybook`).
7
+ */
8
+ import { existsSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { SRC } from '../config.mjs';
12
+ import { walk } from '../utils/fs.mjs';
13
+
14
+ const STORY_RE = /\.stories\.(tsx|jsx|ts|js|mdx)$/;
15
+
16
+ /**
17
+ * @param {string} outDir directory the dashboard html is written to (its `storybook/`
18
+ * subfolder is where a built static Storybook is expected).
19
+ */
20
+ export function collectStorybook(outDir) {
21
+ const storyCount = walk(SRC).filter((f) => STORY_RE.test(f)).length;
22
+ const builtIndex = path.join(outDir, 'storybook', 'index.html');
23
+ const built = existsSync(builtIndex);
24
+ return {
25
+ available: true,
26
+ storyCount,
27
+ built,
28
+ href: './storybook/',
29
+ note: built
30
+ ? null
31
+ : 'Static Storybook not built next to the dashboard. Run `npm run quality:dashboard` (or `npm run build-storybook`).',
32
+ };
33
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared paths for the quality-dashboard modules.
3
+ *
4
+ * These were previously a global closure inside quality-dashboard.mjs. Exporting
5
+ * them from one place keeps the collectors/utils free of `import.meta.url` math
6
+ * and gives every layer a single source of truth for where the project lives.
7
+ */
8
+ import path from 'node:path';
9
+
10
+ /** Repository root — the consuming repo's cwd (every QMetriX verb runs from the app root). */
11
+ export const ROOT = process.cwd();
12
+ /** Application source root. */
13
+ export const SRC = path.join(ROOT, 'src');
14
+ /** Optional curated dependency-notes.json location (app-side; absent → notes default to {}). */
15
+ export const SCRIPTS_DIR = path.join(ROOT, 'dev', 'scripts');