@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,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Code-quality dashboard generator (orchestrator) — COLLECT-ONLY.
|
|
4
|
+
*
|
|
5
|
+
* Walks the known artifact locations and renders a single, self-contained (offline,
|
|
6
|
+
* no CDN) HTML dashboard. It does NOT run linters, tests or audits; whatever a section
|
|
7
|
+
* needs must already exist on disk, and anything missing is clearly flagged with the
|
|
8
|
+
* command that generates it.
|
|
9
|
+
*
|
|
10
|
+
* 1. Tests, lint & types — coverage-summary.json + dist/reports/eslint.json + tsc.log
|
|
11
|
+
* 2. Security scanners — Snyk / CodeQL / Checkmarx (local SARIF, else CI link)
|
|
12
|
+
* 3. Dependencies — filterable table: prod/dev, what it is, why/when added,
|
|
13
|
+
* imported?, outdated?, vulnerable? (npm-audit.json / npm-outdated.json)
|
|
14
|
+
* 4. File graph — internal import relationships (expandable folder tree + force graph)
|
|
15
|
+
* 5. Routing — Next.js App Router tree (pages / API routes / layouts)
|
|
16
|
+
* 6. Component composition — app-rooted nested rectangles of feature widgets (props/state/events/API)
|
|
17
|
+
* 7. Data model — ER-style entity boxes parsed from supabase/migrations/*.sql
|
|
18
|
+
* 8. Storybook — link to the static Storybook built next to the dashboard
|
|
19
|
+
* 9. Codebase bundle — the whole codebase as one browsable HTML file
|
|
20
|
+
*
|
|
21
|
+
* Read-only artifact inputs (generate them separately; the dashboard only reads them):
|
|
22
|
+
* - dist/reports/coverage/<suite>/coverage-summary.json (npm run coverage:unit / :all)
|
|
23
|
+
* - dist/reports/eslint.json (npm run lint -- -f json -o dist/reports/eslint.json)
|
|
24
|
+
* - dist/reports/tsc.log (npm run typecheck > dist/reports/tsc.log 2>&1)
|
|
25
|
+
* - dist/reports/npm-audit.json (npm audit --json > dist/reports/npm-audit.json)
|
|
26
|
+
* - dist/reports/npm-outdated.json (npm outdated --json > dist/reports/npm-outdated.json)
|
|
27
|
+
* - dist/reports/*.sarif (npm run security:scan)
|
|
28
|
+
* - <outDir>/storybook/index.html (built by `npm run quality:dashboard`, or `npm run build-storybook`)
|
|
29
|
+
*
|
|
30
|
+
* The collection logic lives in dev/scripts/dashboard/collectors/*, shared helpers in
|
|
31
|
+
* dev/scripts/dashboard/utils/*, and the HTML rendering in dev/scripts/dashboard/render/*.
|
|
32
|
+
*
|
|
33
|
+
* The dependency "what it is / why / when" columns are populated automatically from
|
|
34
|
+
* each package's node_modules description and the git commit that introduced it in
|
|
35
|
+
* package.json. To curate them, create dev/scripts/dependency-notes.json:
|
|
36
|
+
* { "<package>": { "description": "...", "reason": "...", "task": "PROJ-123", "added": "2026-06-05" } }
|
|
37
|
+
* Any field present there overrides the auto-detected value.
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
* npm run quality:dashboard # full deployable site: build Storybook + dashboard → dist/site/
|
|
41
|
+
* node dev/scripts/quality-dashboard.mjs [options] # fast report-only regen (no Storybook build)
|
|
42
|
+
*
|
|
43
|
+
* Options:
|
|
44
|
+
* --out <file> Output path (default: dist/site/index.html)
|
|
45
|
+
* --no-bundle Skip generating the codebase bundle (bundle-codebase.mjs)
|
|
46
|
+
* --open Open the dashboard when done
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { spawnSync } from 'node:child_process';
|
|
50
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
|
|
53
|
+
import { bundleCodebase } from './bundle-codebase.mjs';
|
|
54
|
+
import { ROOT } from './dashboard/config.mjs';
|
|
55
|
+
import { collectCode } from './dashboard/collectors/code.mjs';
|
|
56
|
+
import { collectComposition } from './dashboard/collectors/composition.mjs';
|
|
57
|
+
import { collectCoverage } from './dashboard/collectors/coverage.mjs';
|
|
58
|
+
import { collectDeps } from './dashboard/collectors/deps.mjs';
|
|
59
|
+
import { collectEntities } from './dashboard/collectors/entities.mjs';
|
|
60
|
+
import { collectGraph } from './dashboard/collectors/graph.mjs';
|
|
61
|
+
import { collectLint } from './dashboard/collectors/lint.mjs';
|
|
62
|
+
import { collectRouting } from './dashboard/collectors/routing.mjs';
|
|
63
|
+
import { collectSecurity } from './dashboard/collectors/security.mjs';
|
|
64
|
+
import { collectStorybook } from './dashboard/collectors/storybook.mjs';
|
|
65
|
+
import { render } from './dashboard/render/template.mjs';
|
|
66
|
+
import { exec, log } from './dashboard/utils/exec.mjs';
|
|
67
|
+
import { rel } from './dashboard/utils/fs.mjs';
|
|
68
|
+
|
|
69
|
+
const argv = process.argv.slice(2);
|
|
70
|
+
const flag = (name) => argv.includes(name);
|
|
71
|
+
const opt = (name, fallback) => {
|
|
72
|
+
const i = argv.indexOf(name);
|
|
73
|
+
return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const OUT = path.resolve(ROOT, opt('--out', 'dist/site/index.html'));
|
|
77
|
+
const RUN_BUNDLE = !flag('--no-bundle');
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Print a found/missing inventory of every collect-only artifact the dashboard reads,
|
|
81
|
+
* so the build log shows at a glance which reports were picked up and which are absent
|
|
82
|
+
* (with the command that generates each missing one). Source-derived sections (code,
|
|
83
|
+
* graph, routing, composition, entities) are always computed and aren't listed here.
|
|
84
|
+
*/
|
|
85
|
+
function printReportInventory({ coverage, lint, deps, security, storybook }) {
|
|
86
|
+
const rows = [];
|
|
87
|
+
const add = (section, name, found, detail) => rows.push({ section, name, found, detail });
|
|
88
|
+
|
|
89
|
+
// 1. Coverage — one row per suite (unit / e2e / storybook / global).
|
|
90
|
+
for (const s of coverage.suites) {
|
|
91
|
+
const hint = s.key === 'unit' ? 'npm run coverage:unit' : 'npm run coverage:all';
|
|
92
|
+
add(
|
|
93
|
+
'Coverage',
|
|
94
|
+
s.label,
|
|
95
|
+
s.available,
|
|
96
|
+
s.available ? `dist/reports/coverage/${s.key}/coverage-summary.json` : hint,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 2. Lint & types.
|
|
101
|
+
add(
|
|
102
|
+
'Lint & types',
|
|
103
|
+
'ESLint',
|
|
104
|
+
!!lint.eslint?.available,
|
|
105
|
+
lint.eslint?.available
|
|
106
|
+
? 'dist/reports/eslint.json'
|
|
107
|
+
: 'npm run lint -- -f json -o dist/reports/eslint.json',
|
|
108
|
+
);
|
|
109
|
+
add(
|
|
110
|
+
'Lint & types',
|
|
111
|
+
'TypeScript',
|
|
112
|
+
!!lint.tsc?.available,
|
|
113
|
+
lint.tsc?.available ? 'dist/reports/tsc.log' : 'npm run typecheck > dist/reports/tsc.log 2>&1',
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// 3. Dependencies (audit + outdated).
|
|
117
|
+
add(
|
|
118
|
+
'Dependencies',
|
|
119
|
+
'npm audit',
|
|
120
|
+
!!deps.audit?.available,
|
|
121
|
+
deps.audit?.available
|
|
122
|
+
? 'dist/reports/npm-audit.json'
|
|
123
|
+
: 'npm audit --json > dist/reports/npm-audit.json',
|
|
124
|
+
);
|
|
125
|
+
add(
|
|
126
|
+
'Dependencies',
|
|
127
|
+
'npm outdated',
|
|
128
|
+
!!deps.outdatedAvailable,
|
|
129
|
+
deps.outdatedAvailable
|
|
130
|
+
? 'dist/reports/npm-outdated.json'
|
|
131
|
+
: 'npm outdated --json > dist/reports/npm-outdated.json',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// 4. Security scanners — local SARIF/JSON if present, otherwise a CI link only.
|
|
135
|
+
for (const tool of [security.snyk, security.codeql, security.checkmarx]) {
|
|
136
|
+
const n = tool.reports?.length || 0;
|
|
137
|
+
add(
|
|
138
|
+
'Security',
|
|
139
|
+
tool.name,
|
|
140
|
+
n > 0,
|
|
141
|
+
n > 0
|
|
142
|
+
? `${n} local report${n > 1 ? 's' : ''}`
|
|
143
|
+
: 'no local SARIF (CI link only; Snyk: npm run security:scan)',
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 5. Storybook static build (built next to the dashboard).
|
|
148
|
+
add(
|
|
149
|
+
'Storybook',
|
|
150
|
+
'Static build',
|
|
151
|
+
!!storybook.built,
|
|
152
|
+
storybook.built
|
|
153
|
+
? rel(path.join(path.dirname(OUT), 'storybook', 'index.html'))
|
|
154
|
+
: 'npm run quality:dashboard (or npm run build-storybook)',
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const found = rows.filter((r) => r.found).length;
|
|
158
|
+
console.log(`\n📋 Report inventory — ${found}/${rows.length} artifacts found (collect-only):\n`);
|
|
159
|
+
const pad = Math.max(...rows.map((r) => r.name.length));
|
|
160
|
+
let section = '';
|
|
161
|
+
for (const r of rows) {
|
|
162
|
+
if (r.section !== section) {
|
|
163
|
+
section = r.section;
|
|
164
|
+
console.log(` ${section}`);
|
|
165
|
+
}
|
|
166
|
+
const tail = r.found ? r.detail : `missing — ${r.detail}`;
|
|
167
|
+
console.log(` ${r.found ? '✓' : '✗'} ${r.name.padEnd(pad)} ${tail}`);
|
|
168
|
+
}
|
|
169
|
+
console.log('');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function gitInfo() {
|
|
173
|
+
const remote = exec('git config --get remote.origin.url').stdout.trim();
|
|
174
|
+
const branch = exec('git rev-parse --abbrev-ref HEAD').stdout.trim() || 'unknown';
|
|
175
|
+
let repo = null;
|
|
176
|
+
const m = remote.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/);
|
|
177
|
+
if (m) {
|
|
178
|
+
repo = { owner: m[1], name: m[2] };
|
|
179
|
+
}
|
|
180
|
+
return { repo, branch };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function main() {
|
|
184
|
+
console.log('\n📊 Building quality dashboard…\n');
|
|
185
|
+
let pkg = {};
|
|
186
|
+
try {
|
|
187
|
+
pkg = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
188
|
+
} catch {}
|
|
189
|
+
const { repo, branch } = gitInfo();
|
|
190
|
+
|
|
191
|
+
log('Scanning source tree…');
|
|
192
|
+
const code = collectCode();
|
|
193
|
+
const graph = collectGraph();
|
|
194
|
+
const coverage = collectCoverage(path.dirname(OUT));
|
|
195
|
+
const lint = collectLint();
|
|
196
|
+
const deps = collectDeps(graph.importedExternals);
|
|
197
|
+
const security = collectSecurity(repo);
|
|
198
|
+
const routing = collectRouting();
|
|
199
|
+
const composition = collectComposition();
|
|
200
|
+
const entities = collectEntities();
|
|
201
|
+
const storybook = collectStorybook(path.dirname(OUT));
|
|
202
|
+
|
|
203
|
+
printReportInventory({ coverage, lint, deps, security, storybook });
|
|
204
|
+
|
|
205
|
+
// Codebase bundle — generated next to the dashboard so the relative link works.
|
|
206
|
+
let bundle = { available: false, note: 'Skipped (--no-bundle).' };
|
|
207
|
+
if (RUN_BUNDLE) {
|
|
208
|
+
log('Bundling codebase…');
|
|
209
|
+
try {
|
|
210
|
+
const b = await bundleCodebase(path.join(path.dirname(OUT), 'codebase-bundle.html'));
|
|
211
|
+
bundle = {
|
|
212
|
+
available: true,
|
|
213
|
+
file: path.basename(b.outFile),
|
|
214
|
+
fileCount: b.fileCount,
|
|
215
|
+
totalBytes: b.totalBytes,
|
|
216
|
+
htmlBytes: b.htmlBytes,
|
|
217
|
+
};
|
|
218
|
+
} catch (err) {
|
|
219
|
+
bundle = { available: false, note: `Bundle failed: ${err.message}` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const meta = {
|
|
224
|
+
name: pkg.name || 'project',
|
|
225
|
+
version: pkg.version || '0.0.0',
|
|
226
|
+
generated: new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC',
|
|
227
|
+
repo,
|
|
228
|
+
branch,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const html = render({
|
|
232
|
+
meta,
|
|
233
|
+
code,
|
|
234
|
+
coverage,
|
|
235
|
+
lint,
|
|
236
|
+
deps,
|
|
237
|
+
graph,
|
|
238
|
+
security,
|
|
239
|
+
routing,
|
|
240
|
+
composition,
|
|
241
|
+
entities,
|
|
242
|
+
storybook,
|
|
243
|
+
bundle,
|
|
244
|
+
});
|
|
245
|
+
mkdirSync(path.dirname(OUT), { recursive: true });
|
|
246
|
+
writeFileSync(OUT, html, 'utf8');
|
|
247
|
+
|
|
248
|
+
// Also dump the raw collected data for programmatic use / debugging.
|
|
249
|
+
const jsonOut = OUT.replace(/\.html?$/i, '') + '.json';
|
|
250
|
+
writeFileSync(
|
|
251
|
+
jsonOut,
|
|
252
|
+
JSON.stringify(
|
|
253
|
+
{
|
|
254
|
+
meta,
|
|
255
|
+
code,
|
|
256
|
+
coverage,
|
|
257
|
+
lint,
|
|
258
|
+
deps,
|
|
259
|
+
graph: { ...graph, graph: undefined, importedExternals: [...graph.importedExternals] },
|
|
260
|
+
security,
|
|
261
|
+
routing,
|
|
262
|
+
composition,
|
|
263
|
+
entities,
|
|
264
|
+
storybook,
|
|
265
|
+
bundle,
|
|
266
|
+
},
|
|
267
|
+
null,
|
|
268
|
+
2,
|
|
269
|
+
),
|
|
270
|
+
'utf8',
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
console.log(`\n✅ Dashboard: ${rel(OUT)}`);
|
|
274
|
+
console.log(` Raw data: ${rel(jsonOut)}\n`);
|
|
275
|
+
|
|
276
|
+
if (flag('--open')) {
|
|
277
|
+
// Arg-array spawn (no shell string) so quotes/&/spaces in the path survive.
|
|
278
|
+
const [cmd, args] =
|
|
279
|
+
process.platform === 'win32'
|
|
280
|
+
? ['cmd', ['/c', 'start', '', OUT]]
|
|
281
|
+
: process.platform === 'darwin'
|
|
282
|
+
? ['open', [OUT]]
|
|
283
|
+
: ['xdg-open', [OUT]];
|
|
284
|
+
spawnSync(cmd, args, { stdio: 'ignore', windowsHide: true });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
main().catch((err) => {
|
|
289
|
+
console.error(err);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
});
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Local security scanners → SARIF, ready for the Quality Dashboard.
|
|
4
|
+
*
|
|
5
|
+
* • Snyk — Open Source (SCA) + Snyk Code (SAST). Requires a free Snyk account
|
|
6
|
+
* (run `snyk auth`, or set SNYK_TOKEN). Skipped with guidance if absent.
|
|
7
|
+
* • CodeQL — GitHub's semantic SAST. Free, no account. Auto-downloads the official
|
|
8
|
+
* CodeQL CLI bundle into ./.codeql-bundle/ on first run, then builds a DB
|
|
9
|
+
* from ./src and analyses it with the security-and-quality suite.
|
|
10
|
+
*
|
|
11
|
+
* Outputs (under dist/reports/, git-ignored, all parsed by dev/scripts/quality-dashboard.mjs):
|
|
12
|
+
* dist/reports/snyk-deps.sarif · dist/reports/snyk-code.sarif · dist/reports/codeql.sarif
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node dev/scripts/security-scan.mjs [--snyk-only|--codeql-only] [--no-download]
|
|
16
|
+
* npm run security:scan
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
|
|
24
|
+
// Anchored on the consuming repo's cwd — every verb runs from the app root.
|
|
25
|
+
const ROOT = process.cwd();
|
|
26
|
+
|
|
27
|
+
// All SARIF reports are collected here (alongside jest/playwright under dist/reports).
|
|
28
|
+
const REPORTS_DIR = path.join(ROOT, 'dist', 'reports');
|
|
29
|
+
mkdirSync(REPORTS_DIR, { recursive: true });
|
|
30
|
+
const SARIF = {
|
|
31
|
+
snykDeps: path.join(REPORTS_DIR, 'snyk-deps.sarif'),
|
|
32
|
+
snykCode: path.join(REPORTS_DIR, 'snyk-code.sarif'),
|
|
33
|
+
codeql: path.join(REPORTS_DIR, 'codeql.sarif'),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const argv = process.argv.slice(2);
|
|
37
|
+
const has = (f) => argv.includes(f);
|
|
38
|
+
const SNYK_ONLY = has('--snyk-only');
|
|
39
|
+
const CODEQL_ONLY = has('--codeql-only');
|
|
40
|
+
const ANY_ONLY = SNYK_ONLY || CODEQL_ONLY;
|
|
41
|
+
const NO_DOWNLOAD = has('--no-download');
|
|
42
|
+
|
|
43
|
+
const results = [];
|
|
44
|
+
|
|
45
|
+
/* ── helpers ── */
|
|
46
|
+
function exec(cmd, { timeout = 1_800_000, stdio = 'pipe', cwd = ROOT } = {}) {
|
|
47
|
+
const r = spawnSync(cmd, {
|
|
48
|
+
cwd,
|
|
49
|
+
shell: true,
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
timeout,
|
|
52
|
+
maxBuffer: 128 * 1024 * 1024,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
stdio: stdio === 'inherit' ? 'inherit' : 'pipe',
|
|
55
|
+
});
|
|
56
|
+
return {
|
|
57
|
+
code: r.status ?? (r.error ? 1 : 0),
|
|
58
|
+
stdout: r.stdout || '',
|
|
59
|
+
stderr: r.stderr || '',
|
|
60
|
+
error: r.error,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function which(cmd) {
|
|
64
|
+
const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
|
|
65
|
+
return exec(probe).code === 0;
|
|
66
|
+
}
|
|
67
|
+
const hr = (t) => console.log(`\n${'─'.repeat(58)}\n ${t}\n${'─'.repeat(58)}`);
|
|
68
|
+
const sarifCount = (file) => {
|
|
69
|
+
try {
|
|
70
|
+
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
|
71
|
+
return (doc.runs || []).reduce((n, run) => n + (run.results?.length || 0), 0);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/* ─────────────────────────── Snyk ─────────────────────────── */
|
|
78
|
+
function snykAuthed() {
|
|
79
|
+
if (process.env.SNYK_TOKEN) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
const cfgs = [
|
|
83
|
+
path.join(process.env.APPDATA || '', 'configstore', 'snyk.json'),
|
|
84
|
+
path.join(os.homedir(), '.config', 'configstore', 'snyk.json'),
|
|
85
|
+
];
|
|
86
|
+
return cfgs.some((p) => p && existsSync(p));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runSnyk() {
|
|
90
|
+
hr('Snyk — Open Source (SCA) + Snyk Code (SAST)');
|
|
91
|
+
if (!snykAuthed()) {
|
|
92
|
+
console.log('⚠ No Snyk credentials found (no SNYK_TOKEN, no stored auth).');
|
|
93
|
+
console.log(' Authenticate once, then re-run:');
|
|
94
|
+
console.log(' npm i -g snyk && snyk auth (free account)');
|
|
95
|
+
console.log(' — or set $env:SNYK_TOKEN=<token>');
|
|
96
|
+
console.log(' Skipping Snyk.');
|
|
97
|
+
results.push({ tool: 'Snyk', status: 'skipped (not authenticated)' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const snyk = which('snyk') ? 'snyk' : 'npx --yes snyk';
|
|
101
|
+
console.log(`Using: ${snyk}`);
|
|
102
|
+
|
|
103
|
+
console.log('→ snyk test (dependencies)…');
|
|
104
|
+
const dep = exec(
|
|
105
|
+
`${snyk} test --all-projects --severity-threshold=low --sarif-file-output="${SARIF.snykDeps}"`,
|
|
106
|
+
);
|
|
107
|
+
// Snyk exits non-zero when issues are found — that's success for us, the SARIF is what matters.
|
|
108
|
+
const depN = sarifCount(SARIF.snykDeps);
|
|
109
|
+
console.log(
|
|
110
|
+
depN == null
|
|
111
|
+
? ` (no SARIF — ${(dep.stderr || dep.stdout).trim().split('\n')[0]})`
|
|
112
|
+
: ` ${depN} dependency findings`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
console.log('→ snyk code test (SAST)…');
|
|
116
|
+
const code = exec(
|
|
117
|
+
`${snyk} code test --severity-threshold=low --sarif-file-output="${SARIF.snykCode}"`,
|
|
118
|
+
);
|
|
119
|
+
const codeN = sarifCount(SARIF.snykCode);
|
|
120
|
+
console.log(
|
|
121
|
+
codeN == null
|
|
122
|
+
? ` (no SARIF — ${(code.stderr || code.stdout).trim().split('\n')[0]})`
|
|
123
|
+
: ` ${codeN} code findings`,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
results.push({
|
|
127
|
+
tool: 'Snyk',
|
|
128
|
+
status:
|
|
129
|
+
depN == null && codeN == null
|
|
130
|
+
? 'no SARIF produced'
|
|
131
|
+
: `deps=${depN ?? '—'}, code=${codeN ?? '—'}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ────────────────────────── CodeQL ────────────────────────── */
|
|
136
|
+
function codeqlPlatformAsset() {
|
|
137
|
+
if (process.platform === 'win32') {
|
|
138
|
+
return 'codeql-bundle-win64.tar.gz';
|
|
139
|
+
}
|
|
140
|
+
if (process.platform === 'darwin') {
|
|
141
|
+
return 'codeql-bundle-osx64.tar.gz';
|
|
142
|
+
}
|
|
143
|
+
return 'codeql-bundle-linux64.tar.gz';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function ensureCodeql() {
|
|
147
|
+
// 1) Explicit override, 2) on PATH, 3) local cached bundle, 4) download bundle.
|
|
148
|
+
if (process.env.CODEQL_DIST) {
|
|
149
|
+
const p = path.join(
|
|
150
|
+
process.env.CODEQL_DIST,
|
|
151
|
+
process.platform === 'win32' ? 'codeql.exe' : 'codeql',
|
|
152
|
+
);
|
|
153
|
+
if (existsSync(p)) {
|
|
154
|
+
return `"${p}"`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (which('codeql')) {
|
|
158
|
+
return 'codeql';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const cacheDir = path.join(ROOT, 'dist', '.codeql-bundle');
|
|
162
|
+
const bin = path.join(cacheDir, 'codeql', process.platform === 'win32' ? 'codeql.exe' : 'codeql');
|
|
163
|
+
if (existsSync(bin)) {
|
|
164
|
+
return `"${bin}"`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (NO_DOWNLOAD) {
|
|
168
|
+
console.log('⚠ CodeQL CLI not found and --no-download was passed. Skipping CodeQL.');
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const asset = codeqlPlatformAsset();
|
|
173
|
+
const url = `https://github.com/github/codeql-action/releases/latest/download/${asset}`;
|
|
174
|
+
const tarball = path.join(cacheDir, asset);
|
|
175
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
console.log(`→ Downloading CodeQL CLI bundle (~700 MB, one-time)…\n ${url}`);
|
|
178
|
+
const dl = exec(`curl -L --fail --retry 3 -o "${tarball}" "${url}"`, { timeout: 1_800_000 });
|
|
179
|
+
if (dl.code !== 0 || !existsSync(tarball)) {
|
|
180
|
+
console.log(`✗ Download failed: ${(dl.stderr || dl.stdout).trim().split('\n').slice(-1)[0]}`);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
console.log('→ Extracting…');
|
|
184
|
+
const ex = exec(`tar -xzf "${tarball}" -C "${cacheDir}"`, { timeout: 600_000 });
|
|
185
|
+
try {
|
|
186
|
+
rmSync(tarball);
|
|
187
|
+
} catch {}
|
|
188
|
+
if (ex.code !== 0 || !existsSync(bin)) {
|
|
189
|
+
console.log(`✗ Extraction failed: ${(ex.stderr || ex.stdout).trim()}`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
console.log('✓ CodeQL ready.');
|
|
193
|
+
return `"${bin}"`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function runCodeQL() {
|
|
197
|
+
hr('CodeQL — semantic SAST (javascript-typescript)');
|
|
198
|
+
const codeql = ensureCodeql();
|
|
199
|
+
if (!codeql) {
|
|
200
|
+
results.push({ tool: 'CodeQL', status: 'skipped (CLI unavailable)' });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const ver = exec(`${codeql} version --format=terse`).stdout.trim();
|
|
204
|
+
if (ver) {
|
|
205
|
+
console.log(`CodeQL ${ver}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const dbDir = path.join(ROOT, '.codeql-db');
|
|
209
|
+
try {
|
|
210
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
211
|
+
} catch {}
|
|
212
|
+
|
|
213
|
+
console.log('→ Building database from ./src …');
|
|
214
|
+
const create = exec(
|
|
215
|
+
`${codeql} database create "${dbDir}" --language=javascript-typescript --source-root="${path.join(ROOT, 'src')}" --overwrite`,
|
|
216
|
+
{ timeout: 1_200_000 },
|
|
217
|
+
);
|
|
218
|
+
if (create.code !== 0 || !existsSync(dbDir)) {
|
|
219
|
+
console.log(
|
|
220
|
+
`✗ database create failed:\n${(create.stderr || create.stdout).trim().split('\n').slice(-8).join('\n')}`,
|
|
221
|
+
);
|
|
222
|
+
results.push({ tool: 'CodeQL', status: 'database create failed' });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log('→ Analysing (security-and-quality)…');
|
|
227
|
+
const out = SARIF.codeql;
|
|
228
|
+
const analyzeWith = (suite) =>
|
|
229
|
+
exec(
|
|
230
|
+
`${codeql} database analyze "${dbDir}" ${suite} --format=sarif-latest --output="${out}" --sarif-category=codeql`,
|
|
231
|
+
{ timeout: 1_200_000 },
|
|
232
|
+
);
|
|
233
|
+
let an = analyzeWith('javascript-security-and-quality.qls');
|
|
234
|
+
if (an.code !== 0 || !existsSync(out)) {
|
|
235
|
+
console.log(' (retrying with default query pack…)');
|
|
236
|
+
an = analyzeWith('codeql/javascript-queries');
|
|
237
|
+
}
|
|
238
|
+
const n = sarifCount(SARIF.codeql);
|
|
239
|
+
if (n == null) {
|
|
240
|
+
console.log(
|
|
241
|
+
`✗ analyze failed:\n${(an.stderr || an.stdout).trim().split('\n').slice(-8).join('\n')}`,
|
|
242
|
+
);
|
|
243
|
+
results.push({ tool: 'CodeQL', status: 'analyze failed' });
|
|
244
|
+
} else {
|
|
245
|
+
console.log(`✓ dist/reports/codeql.sarif — ${n} findings`);
|
|
246
|
+
results.push({ tool: 'CodeQL', status: `${n} findings` });
|
|
247
|
+
}
|
|
248
|
+
// Keep the DB out of the way but cheap to rebuild; remove to save disk.
|
|
249
|
+
try {
|
|
250
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
251
|
+
} catch {}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ─────────────────────────── main ─────────────────────────── */
|
|
255
|
+
console.log('\n== Local security scan ==\n');
|
|
256
|
+
if (!ANY_ONLY || SNYK_ONLY) {
|
|
257
|
+
runSnyk();
|
|
258
|
+
}
|
|
259
|
+
if (!ANY_ONLY || CODEQL_ONLY) {
|
|
260
|
+
runCodeQL();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
hr('Summary');
|
|
264
|
+
for (const r of results) {
|
|
265
|
+
console.log(` ${r.tool.padEnd(8)} ${r.status}`);
|
|
266
|
+
}
|
|
267
|
+
console.log('\nNext: npm run quality:dashboard (re-renders the dashboard with these reports)\n');
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Print a high-level markdown outline of the Playwright suites without running them.
|
|
4
|
+
*
|
|
5
|
+
* Levels map onto the spec structure:
|
|
6
|
+
* 1. feature / widget — the spec file (or the top-level describe for storybook)
|
|
7
|
+
* 2. functionality — test.describe block
|
|
8
|
+
* 3. scenario / case — test title
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node dev/scripts/test-outline.mjs # both e2e and storybook
|
|
12
|
+
* node dev/scripts/test-outline.mjs e2e # just the site e2e suite
|
|
13
|
+
* node dev/scripts/test-outline.mjs storybook # just the storybook suite
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
// Anchored on the consuming repo's cwd — every verb runs from the app root.
|
|
18
|
+
const rootDir = process.cwd();
|
|
19
|
+
|
|
20
|
+
const SUITES = {
|
|
21
|
+
e2e: { title: 'E2E (site)', config: '.config/playwright.config.ts' },
|
|
22
|
+
storybook: { title: 'Storybook (widgets)', config: '.config/playwright.storybook.config.ts' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function listTests(config) {
|
|
26
|
+
const out = execFileSync(
|
|
27
|
+
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
28
|
+
['playwright', 'test', `--config=${config}`, '--list', '--reporter=json'],
|
|
29
|
+
{
|
|
30
|
+
cwd: rootDir,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
33
|
+
shell: process.platform === 'win32',
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
// The json reporter is the only stdout writer under --list, but guard against
|
|
37
|
+
// stray npm/node noise before the JSON payload.
|
|
38
|
+
return JSON.parse(out.slice(out.indexOf('{')));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const prettify = (s) =>
|
|
42
|
+
s
|
|
43
|
+
.replace(/\.((e2e|storybook)\.)?spec\.ts$/, '')
|
|
44
|
+
.replace(/[-_]/g, ' ')
|
|
45
|
+
.replace(/^\w/, (c) => c.toUpperCase());
|
|
46
|
+
|
|
47
|
+
/** Feature title from the co-located spec path: its basename, with an api/ prefix kept for API-route specs. */
|
|
48
|
+
function featureTitle(file) {
|
|
49
|
+
const p = file.replace(/\\/g, '/');
|
|
50
|
+
const base = p.split('/').pop();
|
|
51
|
+
return prettify(p.includes('/api/') ? `api/${base}` : base);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Collect spec titles, deduplicating parameterized tests that expand per item. */
|
|
55
|
+
function specTitles(suite) {
|
|
56
|
+
const titles = [];
|
|
57
|
+
for (const spec of suite.specs ?? []) {
|
|
58
|
+
if (!titles.includes(spec.title)) {
|
|
59
|
+
titles.push(spec.title);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return titles;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printSuite(fileSuite) {
|
|
66
|
+
// Level 1: the spec file (api specs keep their api/ prefix for context)
|
|
67
|
+
console.log(`- **${featureTitle(fileSuite.title)}**`);
|
|
68
|
+
// Tests declared at file top level (outside any describe) land directly at level 2.
|
|
69
|
+
for (const title of specTitles(fileSuite)) {
|
|
70
|
+
console.log(` - ${title}`);
|
|
71
|
+
}
|
|
72
|
+
for (const describe of fileSuite.suites ?? []) {
|
|
73
|
+
console.log(` - ${describe.title}`);
|
|
74
|
+
for (const title of specTitles(describe)) {
|
|
75
|
+
console.log(` - ${title}`);
|
|
76
|
+
}
|
|
77
|
+
// Flatten deeper describes into level 3 so the outline never exceeds three levels.
|
|
78
|
+
for (const nested of describe.suites ?? []) {
|
|
79
|
+
for (const title of specTitles(nested)) {
|
|
80
|
+
console.log(` - ${nested.title} — ${title}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const wanted = process.argv[2] ? [process.argv[2]] : Object.keys(SUITES);
|
|
87
|
+
for (const key of wanted) {
|
|
88
|
+
const suite = SUITES[key];
|
|
89
|
+
if (!suite) {
|
|
90
|
+
console.error(`Unknown suite "${key}". Use: ${Object.keys(SUITES).join(', ')}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
const report = listTests(suite.config);
|
|
94
|
+
console.log(`\n# ${suite.title}\n`);
|
|
95
|
+
for (const fileSuite of report.suites ?? []) {
|
|
96
|
+
printSuite(fileSuite);
|
|
97
|
+
}
|
|
98
|
+
}
|