@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,626 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Bundle the codebase into a single, self-contained HTML file.
|
|
4
|
+
*
|
|
5
|
+
* Walks the project tree, collects source code, configs and generated reports,
|
|
6
|
+
* groups them exactly as a file explorer would (nested folders, then files) and
|
|
7
|
+
* renders each one inside a collapsible block prefixed with a one-sentence
|
|
8
|
+
* description. The output is dependency-free HTML you can open in any browser.
|
|
9
|
+
*
|
|
10
|
+
* Usage: node dev/scripts/bundle-codebase.mjs [outFile]
|
|
11
|
+
* Default output: dist/reports/codebase-bundle.html
|
|
12
|
+
*
|
|
13
|
+
* Also importable: quality-dashboard.mjs calls bundleCodebase() to generate the
|
|
14
|
+
* bundle next to the dashboard and link to it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// Anchored on the consuming repo's cwd — every verb runs from the app root.
|
|
22
|
+
// (fileURLToPath stays imported: the isMain guard below still needs import.meta.url.)
|
|
23
|
+
const ROOT = process.cwd();
|
|
24
|
+
let OUT; // resolved per run inside bundleCodebase()
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ config */
|
|
27
|
+
|
|
28
|
+
// Directory names skipped anywhere in the tree (deps, build output, caches…).
|
|
29
|
+
const IGNORE_DIRS = new Set([
|
|
30
|
+
'node_modules',
|
|
31
|
+
'.git',
|
|
32
|
+
'.next',
|
|
33
|
+
'.swc',
|
|
34
|
+
'.turbo',
|
|
35
|
+
'.idea',
|
|
36
|
+
'.vscode',
|
|
37
|
+
'.codeql-bundle',
|
|
38
|
+
'.codeql-db',
|
|
39
|
+
'.vercel',
|
|
40
|
+
'.husky/_',
|
|
41
|
+
'_',
|
|
42
|
+
'coverage',
|
|
43
|
+
'dist',
|
|
44
|
+
'build',
|
|
45
|
+
'out',
|
|
46
|
+
'test-results',
|
|
47
|
+
'playwright-report',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Text extensions worth embedding.
|
|
51
|
+
const INCLUDE_EXT = new Set([
|
|
52
|
+
'ts',
|
|
53
|
+
'tsx',
|
|
54
|
+
'js',
|
|
55
|
+
'jsx',
|
|
56
|
+
'mjs',
|
|
57
|
+
'cjs',
|
|
58
|
+
'json',
|
|
59
|
+
'css',
|
|
60
|
+
'scss',
|
|
61
|
+
'sass',
|
|
62
|
+
'md',
|
|
63
|
+
'mdx',
|
|
64
|
+
'yml',
|
|
65
|
+
'yaml',
|
|
66
|
+
'sql',
|
|
67
|
+
'html',
|
|
68
|
+
'txt',
|
|
69
|
+
'sarif',
|
|
70
|
+
'sh',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// Extension-less / dotfiles that should still be embedded.
|
|
74
|
+
const INCLUDE_NAMES = new Set([
|
|
75
|
+
'.gitignore',
|
|
76
|
+
'.prettierrc',
|
|
77
|
+
'.prettierignore',
|
|
78
|
+
'.eslintrc.json',
|
|
79
|
+
'.env.example',
|
|
80
|
+
'.dependency-cruiser.js',
|
|
81
|
+
'LICENSE',
|
|
82
|
+
'pre-commit',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// Never embed (secrets, binaries, generated noise) regardless of extension.
|
|
86
|
+
const BINARY_EXT = new Set([
|
|
87
|
+
'png',
|
|
88
|
+
'jpg',
|
|
89
|
+
'jpeg',
|
|
90
|
+
'gif',
|
|
91
|
+
'webp',
|
|
92
|
+
'avif',
|
|
93
|
+
'ico',
|
|
94
|
+
'svg',
|
|
95
|
+
'woff',
|
|
96
|
+
'woff2',
|
|
97
|
+
'ttf',
|
|
98
|
+
'otf',
|
|
99
|
+
'eot',
|
|
100
|
+
'pdf',
|
|
101
|
+
'zip',
|
|
102
|
+
'gz',
|
|
103
|
+
'mp4',
|
|
104
|
+
'webm',
|
|
105
|
+
]);
|
|
106
|
+
const SECRET_RE = /^\.env(\.|$)/; // matches .env, .env.bak.x, .env.local … (NOT .env.example, handled below)
|
|
107
|
+
|
|
108
|
+
const MAX_BYTES = 2_000_000; // truncate file bodies larger than this
|
|
109
|
+
|
|
110
|
+
/* --------------------------------------------------------------- walk tree */
|
|
111
|
+
|
|
112
|
+
/** @returns {Promise<string[]>} project-relative POSIX paths, sorted. */
|
|
113
|
+
async function collect(dir = ROOT, rel = '') {
|
|
114
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name;
|
|
118
|
+
if (e.isDirectory()) {
|
|
119
|
+
if (IGNORE_DIRS.has(e.name) || IGNORE_DIRS.has(relPath)) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
out.push(...(await collect(path.join(dir, e.name), relPath)));
|
|
123
|
+
} else if (e.isFile() && shouldInclude(e.name, relPath, path.join(dir, e.name))) {
|
|
124
|
+
out.push(relPath);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out.sort();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function shouldInclude(name, relPath, abs) {
|
|
131
|
+
if (abs === OUT) {
|
|
132
|
+
return false;
|
|
133
|
+
} // never embed our own output
|
|
134
|
+
if (name === '.env.example') {
|
|
135
|
+
return true;
|
|
136
|
+
} // explicitly safe
|
|
137
|
+
if (SECRET_RE.test(name)) {
|
|
138
|
+
return false;
|
|
139
|
+
} // guard real secrets
|
|
140
|
+
if (name.endsWith('.tsbuildinfo') || name.endsWith('.map') || name.endsWith('.log')) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
if (INCLUDE_NAMES.has(name)) {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
const ext = ext_of(name);
|
|
147
|
+
if (BINARY_EXT.has(ext)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return INCLUDE_EXT.has(ext);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ext_of = (n) => (n.includes('.') ? n.split('.').pop().toLowerCase() : '');
|
|
154
|
+
|
|
155
|
+
/* ----------------------------------------------------------- descriptions */
|
|
156
|
+
|
|
157
|
+
// Curated fallbacks for well-known files that carry no useful inline prose.
|
|
158
|
+
const CURATED = {
|
|
159
|
+
'package.json': 'npm manifest: scripts, dependencies and project metadata.',
|
|
160
|
+
'package-lock.json': 'npm lockfile pinning the exact resolved version of every dependency.',
|
|
161
|
+
'tsconfig.json': 'TypeScript compiler configuration.',
|
|
162
|
+
'next.config.mjs': 'Next.js build and runtime configuration (wrapped with Sentry).',
|
|
163
|
+
'next-env.d.ts': 'Next.js auto-generated ambient TypeScript types (do not edit).',
|
|
164
|
+
'jest.config.mjs': 'Jest unit-test runner configuration.',
|
|
165
|
+
'jest.setup.ts': 'Global setup executed before every Jest test file.',
|
|
166
|
+
'playwright.config.ts': 'Playwright end-to-end test configuration.',
|
|
167
|
+
'tailwind.config.ts': 'Tailwind CSS theme, plugins and content configuration.',
|
|
168
|
+
'postcss.config.js': 'PostCSS plugin pipeline (Tailwind + autoprefixer).',
|
|
169
|
+
'.eslintrc.json': 'ESLint linting rules.',
|
|
170
|
+
'.prettierrc': 'Prettier code-formatting options.',
|
|
171
|
+
'.prettierignore': 'Paths excluded from Prettier formatting.',
|
|
172
|
+
'.gitignore': 'Files and directories excluded from Git.',
|
|
173
|
+
'.dependency-cruiser.js': 'dependency-cruiser rules enforcing module dependency boundaries.',
|
|
174
|
+
'.env.example': 'Template of the environment variables the app expects (no secrets).',
|
|
175
|
+
'instrumentation-client.ts': 'Client-side Sentry instrumentation bootstrap.',
|
|
176
|
+
'sentry.server.config.ts': 'Sentry initialisation for the Node.js server runtime.',
|
|
177
|
+
'sentry.edge.config.ts': 'Sentry initialisation for the Edge runtime.',
|
|
178
|
+
'pre-commit': 'Husky Git pre-commit hook.',
|
|
179
|
+
LICENSE: 'Project software license.',
|
|
180
|
+
'quality-dashboard.html':
|
|
181
|
+
'Generated HTML quality dashboard (security, coverage and lint metrics).',
|
|
182
|
+
'quality-dashboard.json': 'Generated quality-dashboard data consumed to build the HTML report.',
|
|
183
|
+
'README.md': 'Project overview and getting-started documentation.',
|
|
184
|
+
'SETUP.md': 'Environment and tooling setup instructions.',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const TYPE_TAG_RE = /^@\w+/; // JSDoc tag-only lines like "@type {...}" carry no prose
|
|
188
|
+
|
|
189
|
+
/** Produce a single-sentence description for a file. */
|
|
190
|
+
function describe(relPath, name, raw) {
|
|
191
|
+
const ext = ext_of(name);
|
|
192
|
+
|
|
193
|
+
if (ext === 'md' || ext === 'mdx') {
|
|
194
|
+
const d = fromMarkdown(raw);
|
|
195
|
+
if (d) {
|
|
196
|
+
return d;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (name === 'package.json') {
|
|
200
|
+
const d = jsonField(raw, 'description');
|
|
201
|
+
if (d) {
|
|
202
|
+
return d;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (ext === 'sarif') {
|
|
206
|
+
const d = fromSarif(raw);
|
|
207
|
+
if (d) {
|
|
208
|
+
return d;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const prose = leadingComment(raw, ext);
|
|
213
|
+
if (prose) {
|
|
214
|
+
return prose;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (CURATED[name]) {
|
|
218
|
+
return CURATED[name];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const code = fromCode(relPath, name, ext, raw);
|
|
222
|
+
if (code) {
|
|
223
|
+
return code;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return humanize(relPath, ext);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function firstSentence(text) {
|
|
230
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
231
|
+
if (!clean) {
|
|
232
|
+
return '';
|
|
233
|
+
}
|
|
234
|
+
const m = clean.match(/^(.+?[.!?])(\s|$)/);
|
|
235
|
+
let s = (m ? m[1] : clean).trim();
|
|
236
|
+
if (s.length > 200) {
|
|
237
|
+
s = s.slice(0, 197).trimEnd() + '…';
|
|
238
|
+
}
|
|
239
|
+
return s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function fromMarkdown(raw) {
|
|
243
|
+
// YAML frontmatter: prefer a human tagline / description / title.
|
|
244
|
+
const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
245
|
+
if (fm) {
|
|
246
|
+
for (const key of ['tagline', 'description', 'summary', 'name', 'title']) {
|
|
247
|
+
const m = fm[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
|
|
248
|
+
if (m) {
|
|
249
|
+
const v = m[1]
|
|
250
|
+
.trim()
|
|
251
|
+
.replace(/^['"]|['"]$/g, '')
|
|
252
|
+
.trim();
|
|
253
|
+
if (v) {
|
|
254
|
+
return firstSentence(v);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const body = fm ? raw.slice(fm[0].length) : raw;
|
|
260
|
+
const h1 = body.match(/^#\s+(.+)$/m);
|
|
261
|
+
const para = body
|
|
262
|
+
.replace(/^#{1,6}\s+.*$/gm, '')
|
|
263
|
+
.split(/\r?\n\s*\r?\n/)
|
|
264
|
+
.map((p) => p.trim())
|
|
265
|
+
.find((p) => p && !p.startsWith('<') && !p.startsWith('|') && !p.startsWith('```'));
|
|
266
|
+
if (h1 && para) {
|
|
267
|
+
return firstSentence(`${h1[1]} — ${para}`);
|
|
268
|
+
}
|
|
269
|
+
if (para) {
|
|
270
|
+
return firstSentence(para);
|
|
271
|
+
}
|
|
272
|
+
if (h1) {
|
|
273
|
+
return firstSentence(h1[1]);
|
|
274
|
+
}
|
|
275
|
+
return '';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function leadingComment(raw, ext) {
|
|
279
|
+
let s = raw.replace(/^/, '');
|
|
280
|
+
if (s.startsWith('#!')) {
|
|
281
|
+
s = s.slice(s.indexOf('\n') + 1);
|
|
282
|
+
} // drop shebang
|
|
283
|
+
s = s.replace(/^\s+/, '');
|
|
284
|
+
|
|
285
|
+
let text = '';
|
|
286
|
+
if (s.startsWith('/*')) {
|
|
287
|
+
const end = s.indexOf('*/');
|
|
288
|
+
if (end === -1) {
|
|
289
|
+
return '';
|
|
290
|
+
}
|
|
291
|
+
text = s
|
|
292
|
+
.slice(2, end)
|
|
293
|
+
.replace(/^\*+/, '')
|
|
294
|
+
.replace(/^\s*\*[ \t]?/gm, '');
|
|
295
|
+
} else if (s.startsWith('//')) {
|
|
296
|
+
const lines = [];
|
|
297
|
+
for (const line of s.split(/\r?\n/)) {
|
|
298
|
+
const t = line.trim();
|
|
299
|
+
if (t.startsWith('//')) {
|
|
300
|
+
lines.push(t.replace(/^\/\/+\s?/, ''));
|
|
301
|
+
} else {
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
text = lines.join(' ');
|
|
306
|
+
} else if ((ext === 'html' || ext === 'md') && s.startsWith('<!--')) {
|
|
307
|
+
const end = s.indexOf('-->');
|
|
308
|
+
if (end !== -1) {
|
|
309
|
+
text = s.slice(4, end);
|
|
310
|
+
}
|
|
311
|
+
} else if (['yml', 'yaml', 'sh'].includes(ext) && s.startsWith('#')) {
|
|
312
|
+
const lines = [];
|
|
313
|
+
for (const line of s.split(/\r?\n/)) {
|
|
314
|
+
const t = line.trim();
|
|
315
|
+
if (t.startsWith('#')) {
|
|
316
|
+
lines.push(t.replace(/^#+\s?/, ''));
|
|
317
|
+
} else {
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
text = lines.join(' ');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Drop JSDoc tag-only lines (@type, @param …) — they aren't a description.
|
|
325
|
+
const prose = text
|
|
326
|
+
.split(/\r?\n/)
|
|
327
|
+
.map((l) => l.trim())
|
|
328
|
+
.filter((l) => l && !TYPE_TAG_RE.test(l))
|
|
329
|
+
.join(' ');
|
|
330
|
+
return prose ? firstSentence(prose) : '';
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function fromSarif(raw) {
|
|
334
|
+
try {
|
|
335
|
+
const j = JSON.parse(raw);
|
|
336
|
+
const run = j.runs?.[0];
|
|
337
|
+
const tool = run?.tool?.driver?.name ?? 'unknown tool';
|
|
338
|
+
const n = run?.results?.length ?? 0;
|
|
339
|
+
return `SARIF static-analysis report from ${tool} — ${n} result${n === 1 ? '' : 's'}.`;
|
|
340
|
+
} catch {
|
|
341
|
+
return 'SARIF static-analysis report.';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function jsonField(raw, field) {
|
|
346
|
+
try {
|
|
347
|
+
const v = JSON.parse(raw)[field];
|
|
348
|
+
return typeof v === 'string' ? firstSentence(v) : '';
|
|
349
|
+
} catch {
|
|
350
|
+
return '';
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function fromCode(relPath, name, ext, raw) {
|
|
355
|
+
if (!['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
|
|
356
|
+
return '';
|
|
357
|
+
}
|
|
358
|
+
const base = name.replace(/\.\w+$/, '');
|
|
359
|
+
|
|
360
|
+
if (/\/app\/api\/.*\/route\.tsx?$/.test(relPath) || name === 'route.ts') {
|
|
361
|
+
const verbs = [
|
|
362
|
+
...raw.matchAll(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/g),
|
|
363
|
+
].map((m) => m[1]);
|
|
364
|
+
return `API route handler${verbs.length ? ` (${[...new Set(verbs)].join(', ')})` : ''}.`;
|
|
365
|
+
}
|
|
366
|
+
if (name === 'page.tsx') {
|
|
367
|
+
return `Next.js page for the ${path.dirname(relPath).split('/').pop()} route.`;
|
|
368
|
+
}
|
|
369
|
+
if (name === 'layout.tsx') {
|
|
370
|
+
return 'Next.js layout wrapper for its route segment.';
|
|
371
|
+
}
|
|
372
|
+
if (ext === 'test.ts' || /\.(test|spec)\.[jt]sx?$/.test(name)) {
|
|
373
|
+
return `Test suite for ${base.replace(/\.(test|spec)$/, '')}.`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const exp =
|
|
377
|
+
raw.match(/export\s+default\s+function\s+(\w+)/)?.[1] ||
|
|
378
|
+
raw.match(/export\s+(?:async\s+)?function\s+(\w+)/)?.[1] ||
|
|
379
|
+
raw.match(/export\s+const\s+(\w+)\s*[:=]/)?.[1] ||
|
|
380
|
+
raw.match(/export\s+(?:abstract\s+)?class\s+(\w+)/)?.[1];
|
|
381
|
+
|
|
382
|
+
if (ext === 'tsx' || ext === 'jsx') {
|
|
383
|
+
const dir = path.dirname(relPath).split('/').pop();
|
|
384
|
+
return `React ${dir && dir !== 'components' ? `${dir} ` : ''}component${exp ? ` \`${exp}\`` : ` \`${base}\``}.`;
|
|
385
|
+
}
|
|
386
|
+
if (exp) {
|
|
387
|
+
return `Defines \`${exp}\`.`;
|
|
388
|
+
}
|
|
389
|
+
return '';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function humanize(relPath, ext) {
|
|
393
|
+
const parent = path
|
|
394
|
+
.dirname(relPath)
|
|
395
|
+
.split('/')
|
|
396
|
+
.filter((p) => p && p !== '.')
|
|
397
|
+
.pop();
|
|
398
|
+
const kind = ext === 'json' ? 'data/config' : ext === 'css' ? 'stylesheet' : ext || 'file';
|
|
399
|
+
return parent ? `${kind} in ${parent}/.` : `${kind} file.`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ------------------------------------------------------------------ render */
|
|
403
|
+
|
|
404
|
+
const LANG = {
|
|
405
|
+
ts: 'TypeScript',
|
|
406
|
+
tsx: 'TSX',
|
|
407
|
+
js: 'JavaScript',
|
|
408
|
+
jsx: 'JSX',
|
|
409
|
+
mjs: 'ESM',
|
|
410
|
+
cjs: 'CommonJS',
|
|
411
|
+
json: 'JSON',
|
|
412
|
+
css: 'CSS',
|
|
413
|
+
scss: 'SCSS',
|
|
414
|
+
md: 'Markdown',
|
|
415
|
+
mdx: 'MDX',
|
|
416
|
+
yml: 'YAML',
|
|
417
|
+
yaml: 'YAML',
|
|
418
|
+
sql: 'SQL',
|
|
419
|
+
html: 'HTML',
|
|
420
|
+
txt: 'Text',
|
|
421
|
+
sarif: 'SARIF',
|
|
422
|
+
sh: 'Shell',
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c]);
|
|
426
|
+
|
|
427
|
+
function fmtBytes(n) {
|
|
428
|
+
if (n < 1024) {
|
|
429
|
+
return `${n} B`;
|
|
430
|
+
}
|
|
431
|
+
if (n < 1024 * 1024) {
|
|
432
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
433
|
+
}
|
|
434
|
+
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const slug = (s) => s.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
438
|
+
|
|
439
|
+
/** Build a nested {dirs, files} tree from flat relative paths. */
|
|
440
|
+
function buildTree(files) {
|
|
441
|
+
const root = { dirs: new Map(), files: [] };
|
|
442
|
+
for (const rel of files) {
|
|
443
|
+
const parts = rel.split('/');
|
|
444
|
+
let node = root;
|
|
445
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
446
|
+
if (!node.dirs.has(parts[i])) {
|
|
447
|
+
node.dirs.set(parts[i], { dirs: new Map(), files: [] });
|
|
448
|
+
}
|
|
449
|
+
node = node.dirs.get(parts[i]);
|
|
450
|
+
}
|
|
451
|
+
node.files.push({ name: parts[parts.length - 1], rel });
|
|
452
|
+
}
|
|
453
|
+
return root;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function countFiles(node) {
|
|
457
|
+
let n = node.files.length;
|
|
458
|
+
for (const child of node.dirs.values()) {
|
|
459
|
+
n += countFiles(child);
|
|
460
|
+
}
|
|
461
|
+
return n;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function renderNode(node, meta, depth = 0) {
|
|
465
|
+
let html = '';
|
|
466
|
+
const dirNames = [...node.dirs.keys()].sort();
|
|
467
|
+
for (const dn of dirNames) {
|
|
468
|
+
const child = node.dirs.get(dn);
|
|
469
|
+
html +=
|
|
470
|
+
`<details class="dir" ${depth < 1 ? 'open' : ''}>` +
|
|
471
|
+
`<summary><span class="folder">📁 ${esc(dn)}</span>` +
|
|
472
|
+
`<span class="count">${countFiles(child)}</span></summary>` +
|
|
473
|
+
`<div class="dir-body">${renderNode(child, meta, depth + 1)}</div></details>`;
|
|
474
|
+
}
|
|
475
|
+
for (const f of node.files.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
476
|
+
const m = meta.get(f.rel);
|
|
477
|
+
html +=
|
|
478
|
+
`<details class="file" id="${slug(f.rel)}" data-path="${esc(f.rel.toLowerCase())}" data-desc="${esc(m.desc.toLowerCase())}">` +
|
|
479
|
+
`<summary><span class="fname">${esc(f.name)}</span>` +
|
|
480
|
+
`<span class="fdesc">${esc(m.desc)}</span>` +
|
|
481
|
+
`<span class="badge">${LANG[ext_of(f.name)] ?? ext_of(f.name) ?? 'file'}</span>` +
|
|
482
|
+
`<span class="size">${fmtBytes(m.bytes)}</span></summary>` +
|
|
483
|
+
`<pre><code>${m.body}</code></pre></details>`;
|
|
484
|
+
}
|
|
485
|
+
return html;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* -------------------------------------------------------------------- main */
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Generate the bundle and return its stats.
|
|
492
|
+
* @param {string} [outFile] output path, relative to the repo root or absolute.
|
|
493
|
+
* @returns {Promise<{outFile: string, fileCount: number, totalBytes: number, htmlBytes: number}>}
|
|
494
|
+
*/
|
|
495
|
+
export async function bundleCodebase(outFile = 'dist/reports/codebase-bundle.html') {
|
|
496
|
+
OUT = path.resolve(ROOT, outFile);
|
|
497
|
+
const files = await collect();
|
|
498
|
+
const meta = new Map();
|
|
499
|
+
let totalBytes = 0;
|
|
500
|
+
|
|
501
|
+
for (const rel of files) {
|
|
502
|
+
const abs = path.join(ROOT, rel);
|
|
503
|
+
let raw = await fs.readFile(abs, 'utf8');
|
|
504
|
+
const bytes = Buffer.byteLength(raw, 'utf8');
|
|
505
|
+
totalBytes += bytes;
|
|
506
|
+
const desc = describe(rel, path.basename(rel), raw);
|
|
507
|
+
let truncated = false;
|
|
508
|
+
if (raw.length > MAX_BYTES) {
|
|
509
|
+
raw = raw.slice(0, MAX_BYTES);
|
|
510
|
+
truncated = true;
|
|
511
|
+
}
|
|
512
|
+
const body = esc(raw) + (truncated ? '\n\n… [truncated — file exceeds display limit] …' : '');
|
|
513
|
+
meta.set(rel, { desc, bytes, body });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const tree = buildTree(files);
|
|
517
|
+
const generated = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
|
518
|
+
const projectName =
|
|
519
|
+
JSON.parse(await fs.readFile(path.join(ROOT, 'package.json'), 'utf8')).name ?? 'project';
|
|
520
|
+
|
|
521
|
+
const html = `<!doctype html>
|
|
522
|
+
<html lang="en">
|
|
523
|
+
<head>
|
|
524
|
+
<meta charset="utf-8">
|
|
525
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
526
|
+
<title>${esc(projectName)} — codebase bundle</title>
|
|
527
|
+
<style>
|
|
528
|
+
:root { color-scheme: light dark; --bg:#fff; --fg:#1f2328; --muted:#656d76; --line:#d0d7de;
|
|
529
|
+
--code-bg:#f6f8fa; --accent:#0969da; --badge:#ddf4ff; --badge-fg:#0969da; }
|
|
530
|
+
@media (prefers-color-scheme: dark) {
|
|
531
|
+
:root { --bg:#0d1117; --fg:#e6edf3; --muted:#8b949e; --line:#30363d;
|
|
532
|
+
--code-bg:#161b22; --accent:#58a6ff; --badge:#10243e; --badge-fg:#58a6ff; } }
|
|
533
|
+
* { box-sizing: border-box; }
|
|
534
|
+
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;
|
|
535
|
+
color:var(--fg); background:var(--bg); }
|
|
536
|
+
header { position:sticky; top:0; z-index:5; background:var(--bg); border-bottom:1px solid var(--line);
|
|
537
|
+
padding:14px 20px; display:flex; flex-wrap:wrap; gap:12px; align-items:center; }
|
|
538
|
+
header h1 { font-size:16px; margin:0; }
|
|
539
|
+
header .stats { color:var(--muted); font-size:12px; }
|
|
540
|
+
header .spacer { flex:1; }
|
|
541
|
+
#q { padding:6px 10px; border:1px solid var(--line); border-radius:6px; background:var(--code-bg);
|
|
542
|
+
color:var(--fg); width:240px; }
|
|
543
|
+
button { padding:6px 10px; border:1px solid var(--line); border-radius:6px; background:var(--code-bg);
|
|
544
|
+
color:var(--fg); cursor:pointer; }
|
|
545
|
+
button:hover { border-color:var(--accent); }
|
|
546
|
+
main { padding:16px 20px 80px; }
|
|
547
|
+
details.dir { margin:2px 0; }
|
|
548
|
+
details.dir > summary { font-weight:600; cursor:pointer; padding:3px 4px; border-radius:6px; }
|
|
549
|
+
details.dir > summary:hover { background:var(--code-bg); }
|
|
550
|
+
.dir-body { margin-left:18px; border-left:1px solid var(--line); padding-left:10px; }
|
|
551
|
+
.count { color:var(--muted); font-weight:400; font-size:11px; margin-left:8px;
|
|
552
|
+
background:var(--code-bg); padding:0 6px; border-radius:10px; }
|
|
553
|
+
details.file { margin:1px 0; }
|
|
554
|
+
details.file > summary { cursor:pointer; padding:4px 6px; border-radius:6px; display:flex;
|
|
555
|
+
gap:10px; align-items:baseline; list-style:none; }
|
|
556
|
+
details.file > summary::-webkit-details-marker { display:none; }
|
|
557
|
+
details.file > summary:hover { background:var(--code-bg); }
|
|
558
|
+
.fname { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--accent); white-space:nowrap; }
|
|
559
|
+
.fdesc { color:var(--muted); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
560
|
+
.badge { font-size:11px; background:var(--badge); color:var(--badge-fg); padding:0 6px;
|
|
561
|
+
border-radius:10px; white-space:nowrap; }
|
|
562
|
+
.size { font-size:11px; color:var(--muted); white-space:nowrap; }
|
|
563
|
+
pre { background:var(--code-bg); border:1px solid var(--line); border-radius:8px; padding:12px;
|
|
564
|
+
overflow:auto; margin:4px 0 10px; }
|
|
565
|
+
code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12.5px; white-space:pre; }
|
|
566
|
+
.hidden { display:none !important; }
|
|
567
|
+
</style>
|
|
568
|
+
</head>
|
|
569
|
+
<body>
|
|
570
|
+
<header>
|
|
571
|
+
<h1>📦 ${esc(projectName)}</h1>
|
|
572
|
+
<span class="stats">${files.length} files · ${fmtBytes(totalBytes)} · ${generated}</span>
|
|
573
|
+
<span class="spacer"></span>
|
|
574
|
+
<input id="q" type="search" placeholder="filter by path or description…" autocomplete="off">
|
|
575
|
+
<button id="expand">Expand all</button>
|
|
576
|
+
<button id="collapse">Collapse all</button>
|
|
577
|
+
</header>
|
|
578
|
+
<main id="tree">
|
|
579
|
+
${renderNode(tree, meta)}
|
|
580
|
+
</main>
|
|
581
|
+
<script>
|
|
582
|
+
const q = document.getElementById('q');
|
|
583
|
+
const files = [...document.querySelectorAll('details.file')];
|
|
584
|
+
const dirs = [...document.querySelectorAll('details.dir')];
|
|
585
|
+
q.addEventListener('input', () => {
|
|
586
|
+
const term = q.value.trim().toLowerCase();
|
|
587
|
+
for (const f of files) {
|
|
588
|
+
const hit = !term || f.dataset.path.includes(term) || f.dataset.desc.includes(term);
|
|
589
|
+
f.classList.toggle('hidden', !hit);
|
|
590
|
+
}
|
|
591
|
+
for (const d of dirs) {
|
|
592
|
+
const anyVisible = [...d.querySelectorAll('details.file')].some((f) => !f.classList.contains('hidden'));
|
|
593
|
+
d.classList.toggle('hidden', term && !anyVisible);
|
|
594
|
+
if (term && anyVisible) d.open = true;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
document.getElementById('expand').onclick = () => document.querySelectorAll('details').forEach((d) => (d.open = true));
|
|
598
|
+
document.getElementById('collapse').onclick = () =>
|
|
599
|
+
document.querySelectorAll('details.file, details.dir').forEach((d) => (d.open = false));
|
|
600
|
+
</script>
|
|
601
|
+
</body>
|
|
602
|
+
</html>`;
|
|
603
|
+
|
|
604
|
+
await fs.mkdir(path.dirname(OUT), { recursive: true });
|
|
605
|
+
await fs.writeFile(OUT, html, 'utf8');
|
|
606
|
+
return {
|
|
607
|
+
outFile: OUT,
|
|
608
|
+
fileCount: files.length,
|
|
609
|
+
totalBytes,
|
|
610
|
+
htmlBytes: Buffer.byteLength(html),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
615
|
+
if (isMain) {
|
|
616
|
+
bundleCodebase(process.argv[2])
|
|
617
|
+
.then((r) => {
|
|
618
|
+
console.log(
|
|
619
|
+
`✓ Wrote ${path.relative(ROOT, r.outFile)} — ${r.fileCount} files, ${fmtBytes(r.htmlBytes)}`,
|
|
620
|
+
);
|
|
621
|
+
})
|
|
622
|
+
.catch((err) => {
|
|
623
|
+
console.error(err);
|
|
624
|
+
process.exit(1);
|
|
625
|
+
});
|
|
626
|
+
}
|