vibecodedshit 0.0.0-stage → 0.3.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 +95 -0
- package/README.md +73 -2
- package/assets/fonts/NotoEmoji-OFL.txt +93 -0
- package/assets/fonts/NotoEmoji-Poop.ttf +0 -0
- package/assets/fonts/SpaceGrotesk-Bold.ttf +0 -0
- package/assets/fonts/SpaceGrotesk-OFL.txt +93 -0
- package/dist/bin.js +11 -0
- package/dist/card/previous.js +38 -0
- package/dist/card/render.js +71 -0
- package/dist/cli.js +138 -0
- package/dist/commands/card.js +69 -0
- package/dist/commands/common.js +1 -0
- package/dist/commands/diff.js +54 -0
- package/dist/commands/footer.js +28 -0
- package/dist/commands/report.js +49 -0
- package/dist/commands/route.js +62 -0
- package/dist/commands/submit.js +125 -0
- package/dist/commands/summary.js +77 -0
- package/dist/diff/compare.js +73 -0
- package/dist/diff/conditions.js +87 -0
- package/dist/diff/format.js +23 -0
- package/dist/exec/docker.js +158 -0
- package/dist/exec/findings.js +73 -0
- package/dist/exec/plan.js +50 -0
- package/dist/exec/reports.js +90 -0
- package/dist/exec/roots.js +49 -0
- package/dist/exec/run.js +182 -0
- package/dist/exec/steps/build.js +45 -0
- package/dist/exec/steps/coverage-install.js +12 -0
- package/dist/exec/steps/install.js +41 -0
- package/dist/exec/steps/knip.js +28 -0
- package/dist/exec/steps/lint.js +25 -0
- package/dist/exec/steps/lockfiles.js +25 -0
- package/dist/exec/steps/test.js +17 -0
- package/dist/exec/steps/typecheck.js +40 -0
- package/dist/exec/test-reports/coverage-py.js +23 -0
- package/dist/exec/test-reports/coverage-ranges.js +65 -0
- package/dist/exec/test-reports/coverage-summary.js +23 -0
- package/dist/exec/test-reports/jest-json.js +31 -0
- package/dist/exec/test-reports/junit.js +24 -0
- package/dist/exec/test-reports/mocha-json.js +18 -0
- package/dist/exec/test-reports/vitest-json.js +5 -0
- package/dist/exec/test-runners/detect.js +99 -0
- package/dist/exec/test-runners/index.js +6 -0
- package/dist/exec/test-runners/jest.js +27 -0
- package/dist/exec/test-runners/mocha.js +28 -0
- package/dist/exec/test-runners/node-test.js +24 -0
- package/dist/exec/test-runners/pytest.js +18 -0
- package/dist/exec/test-runners/script.js +48 -0
- package/dist/exec/test-runners/spec.js +1 -0
- package/dist/exec/test-runners/vitest.js +28 -0
- package/dist/exec/tests.js +120 -0
- package/dist/exec/types.js +1 -0
- package/dist/interactive/after-scan.js +121 -0
- package/dist/interactive/docker-setup.js +97 -0
- package/dist/interactive/prompt.js +17 -0
- package/dist/interactive/system.js +10 -0
- package/dist/manifest/package-json.js +36 -0
- package/dist/manifest/python.js +106 -0
- package/dist/measure/d3/index.js +45 -0
- package/dist/measure/d3/presets.js +100 -0
- package/dist/measure/d3/py/adapter.js +67 -0
- package/dist/measure/d3/py/config.js +80 -0
- package/dist/measure/d3/py/contracts.js +57 -0
- package/dist/measure/d3/py/domain.js +57 -0
- package/dist/measure/d3/py/graph.js +214 -0
- package/dist/measure/d3/py/interpreter.js +31 -0
- package/dist/measure/d3/py/layers.js +120 -0
- package/dist/measure/d3/py/roots.js +58 -0
- package/dist/measure/d3/py/types.js +1 -0
- package/dist/measure/d3/scc.js +64 -0
- package/dist/measure/d3/ts/adapter.js +371 -0
- package/dist/measure/d3/ts/classify.js +107 -0
- package/dist/measure/d3/ts/config.js +318 -0
- package/dist/measure/d3/ts/domain.js +23 -0
- package/dist/measure/d3/ts/rules.js +65 -0
- package/dist/measure/d3/types.js +5 -0
- package/dist/measure/deps/cvss.js +66 -0
- package/dist/measure/deps/declared.js +195 -0
- package/dist/measure/deps/files.js +40 -0
- package/dist/measure/deps/lockfiles.js +308 -0
- package/dist/measure/deps/osv.js +122 -0
- package/dist/measure/deps/registry.js +142 -0
- package/dist/measure/deps/resolved.js +78 -0
- package/dist/measure/deps/units.js +5 -0
- package/dist/measure/eslint/health-plugin.js +19 -0
- package/dist/measure/ops/docs-commands.js +73 -0
- package/dist/measure/ops/docs-resolve.js +217 -0
- package/dist/measure/ops/entry-points.js +49 -0
- package/dist/measure/ops/env-example.js +20 -0
- package/dist/measure/ops/env-reads.js +46 -0
- package/dist/measure/ops/runtime.js +73 -0
- package/dist/measure/p1-1.js +8 -0
- package/dist/measure/p1-2.js +8 -0
- package/dist/measure/p1-3.js +8 -0
- package/dist/measure/p1-4.js +41 -0
- package/dist/measure/p1-5.js +59 -0
- package/dist/measure/p2-1.js +8 -0
- package/dist/measure/p2-2.js +44 -0
- package/dist/measure/p2-3.js +81 -0
- package/dist/measure/p2-4.js +288 -0
- package/dist/measure/p2-5.js +8 -0
- package/dist/measure/p3-1.js +16 -0
- package/dist/measure/p3-2.js +26 -0
- package/dist/measure/p3-3.js +37 -0
- package/dist/measure/p3-4.js +26 -0
- package/dist/measure/p3-5.js +26 -0
- package/dist/measure/p4-1.js +184 -0
- package/dist/measure/p4-2.js +27 -0
- package/dist/measure/p4-3.js +106 -0
- package/dist/measure/p4-4.js +309 -0
- package/dist/measure/p5-1.js +117 -0
- package/dist/measure/p5-2.js +80 -0
- package/dist/measure/p5-3.js +21 -0
- package/dist/measure/p5-4.js +161 -0
- package/dist/measure/p5-5.js +158 -0
- package/dist/measure/p6-1.js +20 -0
- package/dist/measure/p6-2.js +20 -0
- package/dist/measure/p6-3.js +40 -0
- package/dist/measure/p6-4.js +31 -0
- package/dist/measure/p6-5.js +60 -0
- package/dist/measure/p6-6.js +54 -0
- package/dist/measure/py/typecheck-config.js +95 -0
- package/dist/measure/registry.js +31 -0
- package/dist/measure/safety/py-tests.js +124 -0
- package/dist/measure/safety/ts-barrels.js +68 -0
- package/dist/measure/safety/ts-cases.js +111 -0
- package/dist/measure/safety/ts-credits.js +101 -0
- package/dist/measure/safety/ts-imports.js +63 -0
- package/dist/measure/safety/ts-resolve.js +43 -0
- package/dist/measure/ts/directives.js +37 -0
- package/dist/measure/ts/exports.js +60 -0
- package/dist/measure/ts/tsconfig.js +179 -0
- package/dist/measure/types.js +1 -0
- package/dist/measure/units.js +41 -0
- package/dist/methodology/load.js +159 -0
- package/dist/methodology/schema.js +105 -0
- package/dist/python/deadcode.js +27 -0
- package/dist/python/env.js +13 -0
- package/dist/python/helper.js +24 -0
- package/dist/python/imports.js +40 -0
- package/dist/python/nox.js +16 -0
- package/dist/python/scope.js +35 -0
- package/dist/python/tests.js +19 -0
- package/dist/python/typing.js +27 -0
- package/dist/report/escape.js +5 -0
- package/dist/report/format.js +75 -0
- package/dist/report/render.js +213 -0
- package/dist/report/roadmap.js +58 -0
- package/dist/report/scares.js +83 -0
- package/dist/report/style.js +27 -0
- package/dist/report-only/hotspots.js +48 -0
- package/dist/route/check.js +37 -0
- package/dist/route/files.js +74 -0
- package/dist/route/order.js +22 -0
- package/dist/route/select.js +114 -0
- package/dist/route/write.js +13 -0
- package/dist/run/read.js +89 -0
- package/dist/run/recompute.js +19 -0
- package/dist/run/scan.js +196 -0
- package/dist/run/schema.js +1 -0
- package/dist/run/score-run.js +42 -0
- package/dist/run/write.js +4 -0
- package/dist/scope/build.js +173 -0
- package/dist/scope/packages.js +87 -0
- package/dist/scope/rules.js +21 -0
- package/dist/scope/tracked.js +34 -0
- package/dist/scope/ts-lines.js +63 -0
- package/dist/scope/types.js +7 -0
- package/dist/scoring/aggregate.js +38 -0
- package/dist/scoring/pool.js +65 -0
- package/dist/scoring/score.js +81 -0
- package/dist/scoring/what-if.js +38 -0
- package/dist/scripts/checks.js +53 -0
- package/dist/scripts/ci.js +139 -0
- package/dist/scripts/entry-points/find.js +31 -0
- package/dist/scripts/entry-points/index.js +18 -0
- package/dist/scripts/entry-points/justfile.js +62 -0
- package/dist/scripts/entry-points/makefile.js +66 -0
- package/dist/scripts/entry-points/nox.js +29 -0
- package/dist/scripts/entry-points/package-json.js +21 -0
- package/dist/scripts/entry-points/pyproject.js +73 -0
- package/dist/scripts/entry-points/tox.js +130 -0
- package/dist/scripts/expand.js +300 -0
- package/dist/scripts/triggers.js +151 -0
- package/dist/scripts/types.js +1 -0
- package/dist/submit/constants.js +10 -0
- package/dist/submit/payload.js +42 -0
- package/dist/submit/repo-identity.js +46 -0
- package/dist/submit/send.js +29 -0
- package/dist/tools/catalog.js +46 -0
- package/dist/tools/image.js +180 -0
- package/dist/tools/kit.js +71 -0
- package/dist/tools/knip.js +169 -0
- package/dist/tools/outcome.js +21 -0
- package/dist/tools/resolve.js +66 -0
- package/dist/tools/workcopy.js +27 -0
- package/dist/util/ini.js +37 -0
- package/dist/util/pep440.js +163 -0
- package/dist/util/toml.js +27 -0
- package/docker/Dockerfile +142 -0
- package/docker/bin/health-capture +5 -0
- package/docker/bin/health-coverage-install +5 -0
- package/docker/bin/health-eslint +66 -0
- package/docker/bin/health-pytest +10 -0
- package/docker/bin/health-tsc +10 -0
- package/docker/bin/health-venv +11 -0
- package/docker/config/eslint.config.mjs +4 -0
- package/docker/config/eslint.rules.mjs +65 -0
- package/docker/config/eslint.untyped.config.mjs +4 -0
- package/docker/config/knip.json +1 -0
- package/docker/config/ruff.toml +7 -0
- package/methodology/LICENSE +396 -0
- package/methodology/v1.0/METHODOLOGY.md +1176 -0
- package/methodology/v1.0/dimensions/1-safety-net.yaml +240 -0
- package/methodology/v1.0/dimensions/2-type-static-rigor.yaml +289 -0
- package/methodology/v1.0/dimensions/3-architecture-boundaries.yaml +266 -0
- package/methodology/v1.0/dimensions/4-complexity-maintainability.yaml +192 -0
- package/methodology/v1.0/dimensions/5-dependency-security-hygiene.yaml +223 -0
- package/methodology/v1.0/dimensions/6-operability-feedback-loop.yaml +279 -0
- package/methodology/v1.0/methodology.yaml +242 -0
- package/methodology/v1.0/presets/django.yaml +43 -0
- package/methodology/v1.0/presets/express.yaml +47 -0
- package/methodology/v1.0/presets/fastapi.yaml +52 -0
- package/methodology/v1.0/presets/generic.yaml +41 -0
- package/methodology/v1.0/presets/nextjs.yaml +50 -0
- package/methodology/v1.0/scope.yaml +115 -0
- package/package.json +61 -4
- package/python/dead_code_helper.py +265 -0
- package/python/env_helper.py +71 -0
- package/python/graph_helper.py +277 -0
- package/python/imports_helper.py +132 -0
- package/python/nox_helper.py +79 -0
- package/python/scope_helper.py +96 -0
- package/python/tests_helper.py +171 -0
- package/python/typing_helper.py +235 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { ToolError } from '../scope/types.js';
|
|
5
|
+
const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'python', 'typing_helper.py');
|
|
6
|
+
/** Typing facts of every file, with one helper process. */
|
|
7
|
+
export function analyzePythonTyping(repoPath, files) {
|
|
8
|
+
if (files.length === 0)
|
|
9
|
+
return {};
|
|
10
|
+
const result = spawnSync('python3', [helperPath], {
|
|
11
|
+
cwd: repoPath,
|
|
12
|
+
input: JSON.stringify({ files }),
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
15
|
+
});
|
|
16
|
+
if (result.error)
|
|
17
|
+
throw new ToolError(`Could not run python3: ${result.error.message}`);
|
|
18
|
+
if (result.status !== 0) {
|
|
19
|
+
throw new ToolError(`typing_helper.py failed: ${result.stderr.trim() || `exit ${String(result.status)}`}`);
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(result.stdout);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new ToolError('typing_helper.py printed invalid JSON');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
2
|
+
/** Every dynamic string in the report goes through this: package names, paths and reasons are untrusted. */
|
|
3
|
+
export function escapeHtml(value) {
|
|
4
|
+
return String(value).replace(/[&<>"']/g, (c) => ENTITIES[c] ?? c);
|
|
5
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { roundHalfUp } from '../scoring/aggregate.js';
|
|
2
|
+
function round1(value) {
|
|
3
|
+
return roundHalfUp(value, 1).toFixed(1);
|
|
4
|
+
}
|
|
5
|
+
function scaleLabel(scale) {
|
|
6
|
+
if (scale === 100)
|
|
7
|
+
return '%';
|
|
8
|
+
if (scale === 1000)
|
|
9
|
+
return ' per 1,000';
|
|
10
|
+
return ` per ${scale}`;
|
|
11
|
+
}
|
|
12
|
+
function formatRaw(raw, unit) {
|
|
13
|
+
switch (raw.kind) {
|
|
14
|
+
case 'ratio':
|
|
15
|
+
return raw.denominator === 0
|
|
16
|
+
? `${raw.numerator} / 0`
|
|
17
|
+
: `${raw.numerator} / ${raw.denominator} (${round1((raw.numerator / raw.denominator) * raw.scale)}${scaleLabel(raw.scale)})`;
|
|
18
|
+
case 'level':
|
|
19
|
+
return raw.level;
|
|
20
|
+
case 'number':
|
|
21
|
+
return `${raw.value} ${unit}`;
|
|
22
|
+
case 'failed':
|
|
23
|
+
return 'install failed';
|
|
24
|
+
case 'components':
|
|
25
|
+
return Object.entries(raw.components)
|
|
26
|
+
.map(([key, part]) => `${key}: ${formatRaw(part, unit)}`)
|
|
27
|
+
.join('; ');
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The raw value as the report prints it. Level parameters have no pooled raw, so their units' levels are listed. */
|
|
31
|
+
export function rawText(param, def) {
|
|
32
|
+
if (param.raw)
|
|
33
|
+
return formatRaw(param.raw, def.raw.unit);
|
|
34
|
+
const levels = [...new Set((param.units ?? []).flatMap((u) => (u.raw.kind === 'level' ? [u.raw.level] : [])))];
|
|
35
|
+
if (levels.length > 0)
|
|
36
|
+
return levels.join(', ');
|
|
37
|
+
const components = (param.units ?? []).flatMap((u) => (u.raw.kind === 'components' ? [u.raw] : []));
|
|
38
|
+
if (components.length > 0)
|
|
39
|
+
return components.map((c) => formatRaw(c, def.raw.unit)).join(' | ');
|
|
40
|
+
return 'no data';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The descriptor of the anchor or level whose score is closest to the parameter's score, ties to the lower score.
|
|
44
|
+
* Null for parameters without descriptors (components).
|
|
45
|
+
*/
|
|
46
|
+
export function nearestDescriptor(def, score) {
|
|
47
|
+
const scoring = def.scoring;
|
|
48
|
+
let candidates;
|
|
49
|
+
if (scoring.method === 'piecewise_linear')
|
|
50
|
+
candidates = scoring.anchors;
|
|
51
|
+
else if (scoring.method === 'levels')
|
|
52
|
+
candidates = Array.isArray(scoring.levels) ? scoring.levels : Object.values(scoring.levels).flat();
|
|
53
|
+
else
|
|
54
|
+
return null;
|
|
55
|
+
let best;
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
if (!candidate.descriptor)
|
|
58
|
+
continue;
|
|
59
|
+
if (!best) {
|
|
60
|
+
best = candidate;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const gap = Math.abs(candidate.score - score);
|
|
64
|
+
const bestGap = Math.abs(best.score - score);
|
|
65
|
+
if (gap < bestGap - 1e-9 || (Math.abs(gap - bestGap) <= 1e-9 && candidate.score < best.score))
|
|
66
|
+
best = candidate;
|
|
67
|
+
}
|
|
68
|
+
return best?.descriptor?.replace(/\s+/g, ' ').trim() ?? null;
|
|
69
|
+
}
|
|
70
|
+
export function statusText(status) {
|
|
71
|
+
return status.replace('_', ' ');
|
|
72
|
+
}
|
|
73
|
+
export function scoreText(score) {
|
|
74
|
+
return score === null || score === undefined ? '–' : String(roundHalfUp(score));
|
|
75
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { SHALLOW_WARNING } from '../report-only/hotspots.js';
|
|
2
|
+
import { escapeHtml as e } from './escape.js';
|
|
3
|
+
import { nearestDescriptor, rawText, scoreText, statusText } from './format.js';
|
|
4
|
+
import { buildRoute, routeLines } from '../route/select.js';
|
|
5
|
+
import { buildRoadmap, gainText, ROADMAP_LIMIT } from './roadmap.js';
|
|
6
|
+
import { NO_SCARES, buildScares, unchecked } from './scares.js';
|
|
7
|
+
import { STYLE } from './style.js';
|
|
8
|
+
const HELP_URL = 'https://vibecodedshit.com';
|
|
9
|
+
const NOT_COLLECTED = '<p class="muted">not collected</p>';
|
|
10
|
+
function table(head, rows) {
|
|
11
|
+
const th = head.map((h) => `<th${h.num ? ' class="num"' : ''}>${e(h.text)}</th>`).join('');
|
|
12
|
+
const body = rows
|
|
13
|
+
.map((row) => `<tr>${row.map((cell, i) => `<td${head[i]?.num ? ' class="num"' : ''}>${cell}</td>`).join('')}</tr>`)
|
|
14
|
+
.join('\n');
|
|
15
|
+
return `<table><thead><tr>${th}</tr></thead><tbody>\n${body}\n</tbody></table>`;
|
|
16
|
+
}
|
|
17
|
+
function bar(percent, marks = []) {
|
|
18
|
+
const width = Math.max(0, Math.min(100, percent));
|
|
19
|
+
const ticks = marks.map((m) => `<i style="left:${m}%"></i>`).join('');
|
|
20
|
+
return `<div class="bar"><span style="width:${width}%"></span>${ticks}</div>`;
|
|
21
|
+
}
|
|
22
|
+
function asRecords(value) {
|
|
23
|
+
if (!Array.isArray(value))
|
|
24
|
+
return null;
|
|
25
|
+
return value.filter((v) => typeof v === 'object' && v !== null);
|
|
26
|
+
}
|
|
27
|
+
const str = (v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : '');
|
|
28
|
+
function header(run, methodology) {
|
|
29
|
+
const parts = ['<h1>Codebase health report</h1>'];
|
|
30
|
+
parts.push(`<p class="muted">${e(run.repo.path)} · ${run.repo.headCommit ? `commit <code>${e(run.repo.headCommit.slice(0, 12))}</code> · ` : ''}as of ${e(run.asOf)} · methodology ${e(run.methodology.version)} · ${e(run.mode)} run</p>`);
|
|
31
|
+
if (run.mode === 'preview' || !run.comparable) {
|
|
32
|
+
parts.push(`<p><span class="label">${e(run.comparableReason ?? 'preview, not comparable')}</span></p>`);
|
|
33
|
+
}
|
|
34
|
+
if (run.health) {
|
|
35
|
+
const band = methodology.bands.find((b) => b.key === run.health?.band);
|
|
36
|
+
parts.push(`<div class="health"><span class="score">${e(run.health.displayed)}</span><span class="band">${e(band?.name ?? run.health.band)}</span></div>`, bar(run.health.displayed, methodology.bands.slice(1).map((b) => b.min)), `<p>${e(band?.meaning?.replace(/\s+/g, ' ').trim() ?? '')}</p>`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
parts.push('<p class="muted">no health score</p>');
|
|
40
|
+
}
|
|
41
|
+
return parts.join('\n');
|
|
42
|
+
}
|
|
43
|
+
function scaresSection(run) {
|
|
44
|
+
const scares = buildScares(run);
|
|
45
|
+
const parts = ['<h2>Top scares</h2>'];
|
|
46
|
+
if (scares.length > 0)
|
|
47
|
+
parts.push(`<ol>${scares.map((s) => `<li>${e(s)}</li>`).join('')}</ol>`);
|
|
48
|
+
else
|
|
49
|
+
parts.push(`<p>${e(NO_SCARES)}</p>`);
|
|
50
|
+
const missing = unchecked(run);
|
|
51
|
+
if (missing.length > 0)
|
|
52
|
+
parts.push(`<p class="muted">Not checked in this run: ${e(missing.join(', '))}.</p>`);
|
|
53
|
+
return parts.join('\n');
|
|
54
|
+
}
|
|
55
|
+
function dimensionsSection(run, methodology) {
|
|
56
|
+
const rows = methodology.dimensions.map((def) => {
|
|
57
|
+
const score = run.dimensions.find((d) => d.id === def.id)?.score ?? null;
|
|
58
|
+
return [
|
|
59
|
+
e(`${def.id}`),
|
|
60
|
+
e(def.name),
|
|
61
|
+
score === null ? '<span class="muted">not scored</span>' : e(scoreText(score)),
|
|
62
|
+
score === null ? '' : bar(score),
|
|
63
|
+
];
|
|
64
|
+
});
|
|
65
|
+
return `<h2>Dimensions</h2>\n${table([{ text: '#' }, { text: 'Dimension' }, { text: 'Score', num: true }, { text: '' }], rows)}`;
|
|
66
|
+
}
|
|
67
|
+
function roadmapSection(run, methodology) {
|
|
68
|
+
const parts = ['<h2>Roadmap</h2>'];
|
|
69
|
+
const items = buildRoadmap(run, methodology);
|
|
70
|
+
if (!run.health) {
|
|
71
|
+
parts.push('<p class="muted">no roadmap</p>');
|
|
72
|
+
return parts.join('\n');
|
|
73
|
+
}
|
|
74
|
+
parts.push(`<p class="muted">The parameters ranked by Gain: the whole health points the health would rise by if that one parameter alone reached 100. Gains are independent and do not add up, because the dimension means and the cap interact. Top ${ROADMAP_LIMIT}.</p>`);
|
|
75
|
+
if (run.mode === 'preview')
|
|
76
|
+
parts.push('<p class="muted">Execution-layer parameters were not measured, so they are not ranked.</p>');
|
|
77
|
+
if (items.length === 0)
|
|
78
|
+
parts.push('<p class="muted">no parameter is below 100</p>');
|
|
79
|
+
else {
|
|
80
|
+
const top = Math.max(1, ...items.map((i) => i.gain));
|
|
81
|
+
parts.push(table([{ text: '#', num: true }, { text: 'Parameter' }, { text: 'Dimension' }, { text: 'Raw value' }, { text: 'Score', num: true }, { text: 'Gain', num: true }, { text: '' }, { text: 'Next anchor' }], items.map((item, i) => [
|
|
82
|
+
String(i + 1),
|
|
83
|
+
`${e(item.id)} ${e(item.name)}`,
|
|
84
|
+
e(item.dimensionName),
|
|
85
|
+
item.raw === null ? '–' : e(item.raw),
|
|
86
|
+
e(scoreText(item.score)),
|
|
87
|
+
e(gainText(item.gain)),
|
|
88
|
+
bar((item.gain / top) * 100),
|
|
89
|
+
item.target ? e(item.target) : '–',
|
|
90
|
+
])));
|
|
91
|
+
}
|
|
92
|
+
if (run.health.band === 'vibe_coded' || run.health.band === 'fragile') {
|
|
93
|
+
parts.push(`<p>If you want help working through this roadmap to Stable, Peter Kisel takes on this work: <a href="${HELP_URL}">${HELP_URL}</a></p>`);
|
|
94
|
+
}
|
|
95
|
+
return parts.join('\n');
|
|
96
|
+
}
|
|
97
|
+
function routeSection(run, methodology) {
|
|
98
|
+
const plan = buildRoute(run, methodology);
|
|
99
|
+
if (plan.kind === 'none')
|
|
100
|
+
return '';
|
|
101
|
+
const lines = routeLines(plan);
|
|
102
|
+
if (plan.kind === 'preview')
|
|
103
|
+
return `<h2>Route</h2>\n<p class="muted">${e(lines.join(' '))}</p>`;
|
|
104
|
+
const parts = [`<h2>Route to ${e(plan.target.name)}</h2>`, ...lines.map((line) => `<p>${e(line)}</p>`)];
|
|
105
|
+
if (plan.unmeasured.length > 0)
|
|
106
|
+
parts.push(`<p class="muted">Not measured in this run, so the Route leaves them out: ${e(plan.unmeasured.join(', '))}.</p>`);
|
|
107
|
+
if (plan.slices.length > 0) {
|
|
108
|
+
parts.push(table([{ text: '#', num: true }, { text: 'Step' }, { text: 'Score', num: true }, { text: 'Health after', num: true }, { text: 'Target' }], plan.slices.map((s) => [String(s.position), `${e(s.id)} ${e(s.name)}`, e(`${scoreText(s.fromScore)} → ${scoreText(s.toScore)}`), e(s.healthAfter), e(s.descriptor)])));
|
|
109
|
+
}
|
|
110
|
+
return parts.join('\n');
|
|
111
|
+
}
|
|
112
|
+
function parametersSection(run, methodology) {
|
|
113
|
+
const defs = new Map(methodology.dimensions.flatMap((d) => d.parameters).map((p) => [p.id, p]));
|
|
114
|
+
const rows = run.parameters.map((param) => {
|
|
115
|
+
const def = defs.get(param.id);
|
|
116
|
+
const status = `<span class="status-${e(param.status)}">${e(statusText(param.status))}</span>${param.reason ? ` <span class="muted">(${e(param.reason)})</span>` : ''}`;
|
|
117
|
+
const scored = param.status === 'measured' || param.status === 'failed';
|
|
118
|
+
const raw = def && param.status === 'measured' ? e(rawText(param, def)) : '–';
|
|
119
|
+
const descriptor = def && param.status === 'measured' && param.score !== undefined ? nearestDescriptor(def, param.score) : null;
|
|
120
|
+
return [e(param.id), e(def?.name ?? param.id), raw, scored ? e(scoreText(param.score)) : '–', status, descriptor ? e(descriptor) : '–'];
|
|
121
|
+
});
|
|
122
|
+
return `<h2>Parameters</h2>\n${table([{ text: 'ID' }, { text: 'Parameter' }, { text: 'Raw value' }, { text: 'Score', num: true }, { text: 'Status' }, { text: 'Nearest anchor' }], rows)}`;
|
|
123
|
+
}
|
|
124
|
+
function breakdownTable(title, byKey, methodology) {
|
|
125
|
+
const keys = Object.keys(byKey).sort();
|
|
126
|
+
if (keys.length === 0)
|
|
127
|
+
return `<h3>${e(title)}</h3>\n<p class="muted">no units</p>`;
|
|
128
|
+
const head = [{ text: title.replace(/^By /, '') }, ...methodology.dimensions.map((d) => ({ text: `${d.id} ${d.name}`, num: true }))];
|
|
129
|
+
const rows = keys.map((key) => [
|
|
130
|
+
`<code>${e(key)}</code>`,
|
|
131
|
+
...methodology.dimensions.map((d) => e(scoreText(byKey[key]?.[String(d.id)]))),
|
|
132
|
+
]);
|
|
133
|
+
return `<h3>${e(title)}</h3>\n${table(head, rows)}`;
|
|
134
|
+
}
|
|
135
|
+
function breakdownSection(run, methodology) {
|
|
136
|
+
return `<h2>Breakdown</h2>\n${breakdownTable('By package', run.breakdown.byPackage, methodology)}\n${breakdownTable('By language group', run.breakdown.byGroup, methodology)}`;
|
|
137
|
+
}
|
|
138
|
+
function excludedSection(run) {
|
|
139
|
+
return `<h2>Excluded lines</h2>\n<p>${e(run.ignoreSet.excludedLinesPercent)}% of lines excluded by .healthignore</p>`;
|
|
140
|
+
}
|
|
141
|
+
function secretsHtml(value) {
|
|
142
|
+
const record = typeof value === 'object' && value !== null ? value : null;
|
|
143
|
+
const findings = asRecords(record?.findings);
|
|
144
|
+
if (!findings)
|
|
145
|
+
return NOT_COLLECTED;
|
|
146
|
+
if (findings.length === 0)
|
|
147
|
+
return '<p class="muted">none found</p>';
|
|
148
|
+
return table([{ text: 'Rule' }, { text: 'File' }, { text: 'Commit' }], findings.map((f) => [e(str(f.ruleId)), `<code>${e(str(f.file))}</code>`, `<code>${e(str(f.commit).slice(0, 8))}</code>`]));
|
|
149
|
+
}
|
|
150
|
+
function vulnerabilitiesHtml(value) {
|
|
151
|
+
const record = typeof value === 'object' && value !== null ? value : null;
|
|
152
|
+
const advisories = asRecords(record?.advisories);
|
|
153
|
+
if (!advisories)
|
|
154
|
+
return NOT_COLLECTED;
|
|
155
|
+
if (advisories.length === 0)
|
|
156
|
+
return '<p class="muted">none found</p>';
|
|
157
|
+
return table([{ text: 'Package' }, { text: 'Advisory' }, { text: 'Severity' }], advisories.map((a) => [`<code>${e(str(a.name))}</code>`, e(str(a.id)), e(str(a.severity))]));
|
|
158
|
+
}
|
|
159
|
+
function reportOnlySection(run) {
|
|
160
|
+
const only = run.reportOnly ?? {};
|
|
161
|
+
return [
|
|
162
|
+
'<h2>Report-only items</h2>',
|
|
163
|
+
'<p class="muted">These never change a score.</p>',
|
|
164
|
+
'<h3>Secrets in git history</h3>',
|
|
165
|
+
secretsHtml(only.secretsInHistory),
|
|
166
|
+
'<h3>Development-dependency vulnerabilities</h3>',
|
|
167
|
+
vulnerabilitiesHtml(only.devDependencyVulnerabilities),
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
function hotspotsSection(run) {
|
|
171
|
+
const hotspots = asRecords(run.reportOnly?.hotspots);
|
|
172
|
+
const parts = ['<h2>Fix these first</h2>', '<p class="muted">Files ranked by commits in the last 12 months times cognitive complexity.</p>'];
|
|
173
|
+
if (run.warnings.includes(SHALLOW_WARNING))
|
|
174
|
+
parts.push(`<p class="note">${e(SHALLOW_WARNING)}</p>`);
|
|
175
|
+
if (!hotspots)
|
|
176
|
+
parts.push(NOT_COLLECTED);
|
|
177
|
+
else if (hotspots.length === 0)
|
|
178
|
+
parts.push('<p class="muted">no file has both churn and complexity</p>');
|
|
179
|
+
else {
|
|
180
|
+
parts.push(table([{ text: 'File' }, { text: 'Commits', num: true }, { text: 'Complexity', num: true }, { text: 'Score', num: true }], hotspots.map((h) => [`<code>${e(str(h.path))}</code>`, e(str(h.churn)), e(str(h.complexity)), e(str(h.score))])));
|
|
181
|
+
}
|
|
182
|
+
return parts.join('\n');
|
|
183
|
+
}
|
|
184
|
+
/** One self-contained HTML document. Inline CSS, no script, no external reference, no clock value. */
|
|
185
|
+
export function renderReport(run, methodology) {
|
|
186
|
+
const sections = [
|
|
187
|
+
header(run, methodology),
|
|
188
|
+
scaresSection(run),
|
|
189
|
+
dimensionsSection(run, methodology),
|
|
190
|
+
roadmapSection(run, methodology),
|
|
191
|
+
routeSection(run, methodology),
|
|
192
|
+
parametersSection(run, methodology),
|
|
193
|
+
breakdownSection(run, methodology),
|
|
194
|
+
excludedSection(run),
|
|
195
|
+
reportOnlySection(run),
|
|
196
|
+
hotspotsSection(run),
|
|
197
|
+
];
|
|
198
|
+
return `<!doctype html>
|
|
199
|
+
<html lang="en">
|
|
200
|
+
<head>
|
|
201
|
+
<meta charset="utf-8">
|
|
202
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
203
|
+
<title>Codebase health report</title>
|
|
204
|
+
<style>${STYLE}</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<main>
|
|
208
|
+
${sections.filter((section) => section !== '').join('\n')}
|
|
209
|
+
</main>
|
|
210
|
+
</body>
|
|
211
|
+
</html>
|
|
212
|
+
`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { roundHalfUp } from '../scoring/aggregate.js';
|
|
2
|
+
import { healthWith, nextCandidate } from '../scoring/what-if.js';
|
|
3
|
+
import { rawText } from './format.js';
|
|
4
|
+
export const ROADMAP_LIMIT = 10;
|
|
5
|
+
const STDOUT_LIMIT = 3;
|
|
6
|
+
const NO_OVERRIDES = new Map();
|
|
7
|
+
/** The descriptor of the lowest-scoring anchor or level above `score`. Null when there is none or the parameter has no descriptors. */
|
|
8
|
+
export function targetDescriptor(def, score) {
|
|
9
|
+
return nextCandidate(def, score)?.descriptor ?? null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The parameters ranked by Gain: the whole health points the displayed health would rise by if that one parameter
|
|
13
|
+
* alone reached 100. Parameters at 100 and the not applicable or not measured ones are not listed. Empty when the
|
|
14
|
+
* run has no health score. Items with no Gain are listed only to fill the list up to the limit.
|
|
15
|
+
*/
|
|
16
|
+
export function buildRoadmap(run, methodology) {
|
|
17
|
+
if (!run.health)
|
|
18
|
+
return [];
|
|
19
|
+
const base = healthWith(run, methodology, NO_OVERRIDES);
|
|
20
|
+
if (base.health === null)
|
|
21
|
+
return [];
|
|
22
|
+
const items = [];
|
|
23
|
+
const dimensionOf = new Map();
|
|
24
|
+
for (const dim of methodology.dimensions) {
|
|
25
|
+
for (const def of dim.parameters) {
|
|
26
|
+
const param = run.parameters.find((p) => p.id === def.id);
|
|
27
|
+
if (!param || (param.status !== 'measured' && param.status !== 'failed'))
|
|
28
|
+
continue;
|
|
29
|
+
const score = param.status === 'failed' ? 0 : (param.score ?? 0);
|
|
30
|
+
if (score >= 100)
|
|
31
|
+
continue;
|
|
32
|
+
const after = healthWith(run, methodology, new Map([[def.id, 100]])).health ?? base.health;
|
|
33
|
+
items.push({
|
|
34
|
+
id: def.id,
|
|
35
|
+
name: def.name,
|
|
36
|
+
dimensionId: dim.id,
|
|
37
|
+
dimensionName: dim.name,
|
|
38
|
+
raw: param.status === 'measured' ? rawText(param, def) : null,
|
|
39
|
+
score,
|
|
40
|
+
gain: roundHalfUp(after) - run.health.displayed,
|
|
41
|
+
gainPrecise: after - base.health,
|
|
42
|
+
target: targetDescriptor(def, score),
|
|
43
|
+
});
|
|
44
|
+
dimensionOf.set(def.id, base.dimension.get(dim.id) ?? 0);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
items.sort((a, b) => b.gain - a.gain || b.gainPrecise - a.gainPrecise || (dimensionOf.get(a.id) ?? 0) - (dimensionOf.get(b.id) ?? 0) || a.id.localeCompare(b.id, 'en', { numeric: true }));
|
|
48
|
+
return items.slice(0, ROADMAP_LIMIT);
|
|
49
|
+
}
|
|
50
|
+
export function gainText(gain) {
|
|
51
|
+
return gain > 0 ? `+${gain}` : '+0 · no gain';
|
|
52
|
+
}
|
|
53
|
+
/** The top lines `health report` prints after the report path: `<rank>. <id> <name> (<dimension>) +<gain> — <target>`. */
|
|
54
|
+
export function roadmapLines(run, methodology) {
|
|
55
|
+
return buildRoadmap(run, methodology)
|
|
56
|
+
.slice(0, STDOUT_LIMIT)
|
|
57
|
+
.map((item, i) => `${i + 1}. ${item.id} ${item.name} (${item.dimensionName}) ${gainText(item.gain)}${item.target ? ` — ${item.target}` : ''}`);
|
|
58
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const SCARE_LIMIT = 3;
|
|
2
|
+
export const NO_SCARES = 'Nothing in the checks below is a scare. The scan looks at committed secrets and known advisories, not access rules, so this is not a clean bill of health.';
|
|
3
|
+
const records = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'object' && v !== null) : null;
|
|
4
|
+
const text = (value) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '');
|
|
5
|
+
const SEVERITY_RANK = { critical: 4, high: 3, moderate: 2, low: 1 };
|
|
6
|
+
const measured = (param) => param?.status === 'measured';
|
|
7
|
+
function levels(param) {
|
|
8
|
+
if (!measured(param))
|
|
9
|
+
return [];
|
|
10
|
+
const fromRaw = param.raw?.kind === 'level' ? [param.raw.level] : [];
|
|
11
|
+
return [...fromRaw, ...(param.units ?? []).flatMap((u) => (u.raw.kind === 'level' ? [u.raw.level] : []))];
|
|
12
|
+
}
|
|
13
|
+
function secretScares(run, param) {
|
|
14
|
+
if (!measured(param))
|
|
15
|
+
return [];
|
|
16
|
+
const only = run.reportOnly ?? {};
|
|
17
|
+
const tree = records(only.secretsInTree) ?? [];
|
|
18
|
+
const scares = [];
|
|
19
|
+
if (tree.some((f) => f.supabaseRole === 'service_role')) {
|
|
20
|
+
scares.push('Anyone with access to your git repo can read and change all the data in your Supabase.');
|
|
21
|
+
}
|
|
22
|
+
// A Supabase anon key is public by design, so it is not a leaked secret.
|
|
23
|
+
const others = tree.filter((f) => f.supabaseRole !== 'service_role' && f.supabaseRole !== 'anon');
|
|
24
|
+
const first = others[0];
|
|
25
|
+
if (first) {
|
|
26
|
+
const more = others.length > 1 ? ` (${others.length} secrets in all)` : '';
|
|
27
|
+
scares.push(`Anyone with access to your git repo can use a secret committed in ${text(first.file)} (${text(first.ruleId)})${more}.`);
|
|
28
|
+
}
|
|
29
|
+
return scares;
|
|
30
|
+
}
|
|
31
|
+
function dependencyScare(run, param) {
|
|
32
|
+
if (!measured(param) || (param.score ?? 100) > 50)
|
|
33
|
+
return [];
|
|
34
|
+
const advisories = records(run.reportOnly?.productionDependencyVulnerabilities?.advisories) ?? [];
|
|
35
|
+
const worst = [...advisories].sort((a, b) => (SEVERITY_RANK[text(b.severity)] ?? 0) - (SEVERITY_RANK[text(a.severity)] ?? 0))[0];
|
|
36
|
+
const detail = worst
|
|
37
|
+
? ` (${advisories.length} known ${advisories.length === 1 ? 'advisory' : 'advisories'}, the worst in ${text(worst.name)} ${text(worst.version)})`
|
|
38
|
+
: '';
|
|
39
|
+
return [`Your live app runs on dependencies with publicly known security holes${detail}.`];
|
|
40
|
+
}
|
|
41
|
+
function historyScare(run, param) {
|
|
42
|
+
if (!measured(param))
|
|
43
|
+
return [];
|
|
44
|
+
const history = records(run.reportOnly?.secretsInHistory?.findings) ?? [];
|
|
45
|
+
const inTree = new Set((records(run.reportOnly?.secretsInTree) ?? []).map((f) => `${text(f.file)}\0${text(f.ruleId)}`));
|
|
46
|
+
const gone = history.filter((f) => !inTree.has(`${text(f.file)}\0${text(f.ruleId)}`));
|
|
47
|
+
if (gone.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
return [`Deleting a key from the code did not kill it: ${gone.length === 1 ? 'a secret is' : `${gone.length} secrets are`} still readable in your git history.`];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The scares in severity order: plain-English consequences, not metrics. Only measured or failed parameters trigger
|
|
53
|
+
* one, so a preview simply has fewer. Never padded. Report only, never scored.
|
|
54
|
+
*/
|
|
55
|
+
export function buildScares(run) {
|
|
56
|
+
if (!run.valid)
|
|
57
|
+
return [];
|
|
58
|
+
const param = (id) => run.parameters.find((p) => p.id === id);
|
|
59
|
+
const scoreOf = (id) => {
|
|
60
|
+
const p = param(id);
|
|
61
|
+
return p && (p.status === 'measured' || p.status === 'failed') ? (p.status === 'failed' ? 0 : (p.score ?? 0)) : null;
|
|
62
|
+
};
|
|
63
|
+
const scares = [...secretScares(run, param('5.4')), ...dependencyScare(run, param('5.1')), ...historyScare(run, param('5.4'))];
|
|
64
|
+
if (levels(param('6.1')).some((l) => l === 'install_fails' || l === 'build_fails')) {
|
|
65
|
+
scares.push("A clean copy of this code doesn't install or build.");
|
|
66
|
+
}
|
|
67
|
+
if (scoreOf('1.1') === 0 || scoreOf('1.2') === 0)
|
|
68
|
+
scares.push('Nothing checks your changes, so your users find the breakage first.');
|
|
69
|
+
if (levels(param('5.3')).includes('none'))
|
|
70
|
+
scares.push('Two installs of the same commit can ship different code.');
|
|
71
|
+
if (scoreOf('6.4') === 0)
|
|
72
|
+
scares.push('Nothing stops broken code from reaching main.');
|
|
73
|
+
return scares.slice(0, SCARE_LIMIT);
|
|
74
|
+
}
|
|
75
|
+
/** What the scan could not check, named so an empty list is not read as clean. */
|
|
76
|
+
export function unchecked(run) {
|
|
77
|
+
const names = [['5.4', 'secrets'], ['5.1', 'dependency advisories']];
|
|
78
|
+
return names.filter(([id]) => !run.parameters.some((p) => p.id === id && (p.status === 'measured' || p.status === 'failed'))).map(([, name]) => name);
|
|
79
|
+
}
|
|
80
|
+
/** The lines `health report` prints after the Roadmap lines: `Scare: <text>`. */
|
|
81
|
+
export function scareLines(run) {
|
|
82
|
+
return buildScares(run).map((s) => `Scare: ${s}`);
|
|
83
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Inline CSS. No external font, image or request. */
|
|
2
|
+
export const STYLE = `
|
|
3
|
+
:root { color-scheme: light dark; --bg: #fafaf8; --fg: #1c1c1a; --muted: #6b6b66; --line: #dcdcd4; --accent: #2b5fd9; --warn: #a15c00; --card: #ffffff; }
|
|
4
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #151514; --fg: #ecece6; --muted: #9a9a92; --line: #33332f; --accent: #7aa2ff; --warn: #e0a040; --card: #1e1e1c; } }
|
|
5
|
+
* { box-sizing: border-box; }
|
|
6
|
+
body { margin: 0; padding: 32px 16px 64px; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
|
|
7
|
+
main { max-width: 960px; margin: 0 auto; }
|
|
8
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
9
|
+
h2 { font-size: 17px; margin: 40px 0 8px; padding-top: 16px; border-top: 1px solid var(--line); }
|
|
10
|
+
.muted { color: var(--muted); }
|
|
11
|
+
.health { display: flex; gap: 24px; align-items: baseline; margin: 16px 0 8px; }
|
|
12
|
+
.health .score { font-size: 56px; font-weight: 700; line-height: 1; }
|
|
13
|
+
.health .band { font-size: 22px; font-weight: 600; }
|
|
14
|
+
.label { display: inline-block; padding: 2px 10px; border: 1px solid var(--warn); color: var(--warn); border-radius: 4px; font-weight: 600; }
|
|
15
|
+
table { width: 100%; border-collapse: collapse; background: var(--card); font-size: 14px; }
|
|
16
|
+
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
17
|
+
th { font-weight: 600; color: var(--muted); }
|
|
18
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
19
|
+
.bar { position: relative; min-width: 80px; height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; margin: 6px 0; }
|
|
20
|
+
.bar span { display: block; height: 100%; background: var(--accent); }
|
|
21
|
+
.bar i { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--bg); }
|
|
22
|
+
a { color: var(--accent); }
|
|
23
|
+
code { font: 13px ui-monospace, monospace; word-break: break-all; }
|
|
24
|
+
.status-not_measured, .status-not_applicable { color: var(--muted); }
|
|
25
|
+
.status-failed { color: var(--warn); font-weight: 600; }
|
|
26
|
+
.note { color: var(--warn); margin: 4px 0 8px; }
|
|
27
|
+
`;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
const HOTSPOT_LIMIT = 20;
|
|
3
|
+
export const SHALLOW_WARNING = 'history is shallow; churn is a lower bound';
|
|
4
|
+
/** `[asOf minus 12 months, end of asOf day]`, UTC, as ISO strings git accepts. */
|
|
5
|
+
export function churnWindow(asOf) {
|
|
6
|
+
const start = new Date(`${asOf}T00:00:00Z`);
|
|
7
|
+
start.setUTCFullYear(start.getUTCFullYear() - 1);
|
|
8
|
+
return { since: `${start.toISOString().slice(0, 10)}T00:00:00+00:00`, until: `${asOf}T23:59:59+00:00` };
|
|
9
|
+
}
|
|
10
|
+
function git(repoPath, args) {
|
|
11
|
+
const result = spawnSync('git', ['-C', repoPath, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
12
|
+
return { status: result.error ? null : result.status, stdout: result.stdout };
|
|
13
|
+
}
|
|
14
|
+
/** Commits per path in the window, merges and renames ignored. */
|
|
15
|
+
function churnByPath(repoPath, asOf) {
|
|
16
|
+
const { since, until } = churnWindow(asOf);
|
|
17
|
+
const log = git(repoPath, ['log', `--since=${since}`, `--until=${until}`, '--no-merges', '--no-renames', '--name-only', '-z', '--format=']);
|
|
18
|
+
if (log.status !== 0)
|
|
19
|
+
return null;
|
|
20
|
+
const churn = new Map();
|
|
21
|
+
for (const name of log.stdout.split('\0')) {
|
|
22
|
+
if (name === '')
|
|
23
|
+
continue;
|
|
24
|
+
churn.set(name, (churn.get(name) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
return churn;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Files ranked by churn times complexity. Files with no churn or no complexity are left out.
|
|
30
|
+
* Only the files in `complexity` (logic files in scope that exist at HEAD) can appear.
|
|
31
|
+
*/
|
|
32
|
+
export function computeHotspots(repoPath, asOf, complexity) {
|
|
33
|
+
const warnings = [];
|
|
34
|
+
const churn = churnByPath(repoPath, asOf);
|
|
35
|
+
if (churn === null)
|
|
36
|
+
return { hotspots: [], warnings: ['hotspots: git history could not be read'] };
|
|
37
|
+
if (git(repoPath, ['rev-parse', '--is-shallow-repository']).stdout.trim() === 'true')
|
|
38
|
+
warnings.push(SHALLOW_WARNING);
|
|
39
|
+
const hotspots = [];
|
|
40
|
+
for (const entry of complexity) {
|
|
41
|
+
const commits = churn.get(entry.file) ?? 0;
|
|
42
|
+
if (commits === 0 || entry.complexitySum === 0)
|
|
43
|
+
continue;
|
|
44
|
+
hotspots.push({ path: entry.file, churn: commits, complexity: entry.complexitySum, score: commits * entry.complexitySum });
|
|
45
|
+
}
|
|
46
|
+
hotspots.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
47
|
+
return { hotspots: hotspots.slice(0, HOTSPOT_LIMIT), warnings };
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ROUTE_FILE, SLICE_FILE, TASK_PLACEHOLDER } from './files.js';
|
|
4
|
+
/** The problems with `dir` compared to the files regenerated from run.json. Empty when the facts match and every Task is written. */
|
|
5
|
+
export function checkRouteFiles(dir, expected) {
|
|
6
|
+
if (!existsSync(dir))
|
|
7
|
+
return [`${dir} does not exist`];
|
|
8
|
+
const problems = [];
|
|
9
|
+
const known = new Set(expected.map((f) => f.name));
|
|
10
|
+
for (const name of readdirSync(dir)) {
|
|
11
|
+
if ((name === ROUTE_FILE || SLICE_FILE.test(name)) && !known.has(name))
|
|
12
|
+
problems.push(`${name}: not part of this Route`);
|
|
13
|
+
}
|
|
14
|
+
for (const file of expected) {
|
|
15
|
+
const full = path.join(dir, file.name);
|
|
16
|
+
if (!existsSync(full)) {
|
|
17
|
+
problems.push(`${file.name}: missing`);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const actual = readFileSync(full, 'utf8');
|
|
21
|
+
if (!actual.startsWith(file.fixed)) {
|
|
22
|
+
problems.push(`${file.name}: facts differ from run.json`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (file.name === ROUTE_FILE) {
|
|
26
|
+
if (actual !== file.content)
|
|
27
|
+
problems.push(`${file.name}: facts differ from run.json`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const task = actual.slice(file.fixed.length);
|
|
31
|
+
if (task.includes(TASK_PLACEHOLDER))
|
|
32
|
+
problems.push(`${file.name}: Task placeholder not replaced`);
|
|
33
|
+
else if (task.trim() === '')
|
|
34
|
+
problems.push(`${file.name}: Task is empty`);
|
|
35
|
+
}
|
|
36
|
+
return problems;
|
|
37
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { nearestDescriptor, rawText } from '../report/format.js';
|
|
2
|
+
import { outOfReachLine } from './select.js';
|
|
3
|
+
export const FACTS_START = '<!-- health:facts:start -->';
|
|
4
|
+
export const FACTS_END = '<!-- health:facts:end -->';
|
|
5
|
+
export const TASK_PLACEHOLDER = '<!-- health:task -->';
|
|
6
|
+
export const ROUTE_FILE = 'ROUTE.md';
|
|
7
|
+
export const SLICE_FILE = /^\d{2}-.+\.md$/;
|
|
8
|
+
const WHERE_LIMIT = 10;
|
|
9
|
+
const kebab = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
10
|
+
export function sliceFileName(slice) {
|
|
11
|
+
return `${String(slice.position).padStart(2, '0')}-${slice.id.replace('.', '-')}-${kebab(slice.name)}.md`;
|
|
12
|
+
}
|
|
13
|
+
function whereLines(run, slice, param) {
|
|
14
|
+
const units = [...(param?.units ?? [])]
|
|
15
|
+
.sort((a, b) => a.score - b.score || a.package.localeCompare(b.package) || a.group.localeCompare(b.group))
|
|
16
|
+
.slice(0, WHERE_LIMIT)
|
|
17
|
+
.map((u) => `- package \`${u.package}\` (${u.group}), score ${Math.round(u.score)}`);
|
|
18
|
+
const hotspots = slice.id === '4.1' && Array.isArray(run.reportOnly?.hotspots)
|
|
19
|
+
? run.reportOnly.hotspots
|
|
20
|
+
.flatMap((h) => (typeof h === 'object' && h !== null && typeof h.path === 'string' ? [h.path] : []))
|
|
21
|
+
.slice(0, WHERE_LIMIT)
|
|
22
|
+
.map((file) => `- file \`${file}\` (hotspot)`)
|
|
23
|
+
: [];
|
|
24
|
+
const lines = [...units, ...hotspots];
|
|
25
|
+
return lines.length > 0 ? lines : ['- the whole repository'];
|
|
26
|
+
}
|
|
27
|
+
function sliceFacts(run, def, slice) {
|
|
28
|
+
const param = run.parameters.find((p) => p.id === slice.id);
|
|
29
|
+
const current = param?.status === 'measured' ? rawText(param, def) : 'no usable result';
|
|
30
|
+
const nearest = nearestDescriptor(def, slice.fromScore);
|
|
31
|
+
return [
|
|
32
|
+
FACTS_START,
|
|
33
|
+
`**Goal:** Raise ${slice.id} ${slice.name} from ${Math.round(slice.fromScore)} to ${Math.round(slice.toScore)}: "${slice.descriptor}"`,
|
|
34
|
+
'',
|
|
35
|
+
`**Why:** Today it is ${current} (score ${Math.round(slice.fromScore)})${nearest ? `, which the methodology describes as "${nearest}"` : ''}.`,
|
|
36
|
+
'',
|
|
37
|
+
'**Where:**',
|
|
38
|
+
...whereLines(run, slice, param),
|
|
39
|
+
'',
|
|
40
|
+
`**Health effect:** health ${slice.healthBefore} -> ${slice.healthAfter} once this Slice and the ones before it are done.`,
|
|
41
|
+
'',
|
|
42
|
+
`**Done when:** re-running /codebase-health shows ${slice.id} ${slice.name} at ${Math.round(slice.toScore)} or higher ("${slice.descriptor}").`,
|
|
43
|
+
'',
|
|
44
|
+
'**Rules:** Raise the score by changing the code, not the measurement. Do not edit .healthignore, .gitleaksignore, lint, type or test configuration to hide findings, and do not delete, skip or weaken tests.',
|
|
45
|
+
FACTS_END,
|
|
46
|
+
].join('\n');
|
|
47
|
+
}
|
|
48
|
+
function sliceFile(run, methodology, slice) {
|
|
49
|
+
const def = methodology.dimensions.flatMap((d) => d.parameters).find((p) => p.id === slice.id);
|
|
50
|
+
if (!def)
|
|
51
|
+
throw new Error(`No parameter ${slice.id} in the methodology`);
|
|
52
|
+
const fixed = `# ${String(slice.position).padStart(2, '0')}. ${slice.id} ${slice.name}\n\n${sliceFacts(run, def, slice)}\n\n## Task\n`;
|
|
53
|
+
return { name: sliceFileName(slice), fixed, content: `${fixed}\n${TASK_PLACEHOLDER}\n` };
|
|
54
|
+
}
|
|
55
|
+
function routeIndex(plan) {
|
|
56
|
+
const lines = [`# Route to ${plan.target.name}`, '', `${plan.slices.length} Slices take health from ${plan.from} to ${plan.to}. Hand them to your agent one at a time, in this order, as a goal each.`];
|
|
57
|
+
if (!plan.reached)
|
|
58
|
+
lines.push('', outOfReachLine(plan));
|
|
59
|
+
if (plan.unmeasured.length > 0)
|
|
60
|
+
lines.push('', `Not measured in this run, so this Route leaves them out: ${plan.unmeasured.join(', ')}.`);
|
|
61
|
+
if (plan.slices.length > 0) {
|
|
62
|
+
lines.push('', '| # | Slice | Health after |', '| --- | --- | --- |');
|
|
63
|
+
for (const slice of plan.slices)
|
|
64
|
+
lines.push(`| ${slice.position} | [${slice.id} ${slice.name}](${sliceFileName(slice)}) | ${slice.healthAfter} |`);
|
|
65
|
+
}
|
|
66
|
+
const text = `${lines.join('\n')}\n`;
|
|
67
|
+
return { name: ROUTE_FILE, fixed: text, content: text };
|
|
68
|
+
}
|
|
69
|
+
/** ROUTE.md and one file per Slice, the same on every call for the same run.json. Empty unless the plan is a Route. */
|
|
70
|
+
export function buildRouteFiles(run, methodology, plan) {
|
|
71
|
+
if (plan.kind !== 'route')
|
|
72
|
+
return [];
|
|
73
|
+
return [routeIndex(plan), ...plan.slices.map((slice) => sliceFile(run, methodology, slice))];
|
|
74
|
+
}
|