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,158 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { mkdir } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ToolError } from '../scope/types.js';
|
|
7
|
+
function run(spawn, args, onChild) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const child = spawn('docker', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
10
|
+
onChild?.();
|
|
11
|
+
let stderr = '';
|
|
12
|
+
child.stderr?.on('data', (chunk) => {
|
|
13
|
+
stderr += chunk.toString('utf8');
|
|
14
|
+
});
|
|
15
|
+
child.on('error', (err) => resolve({ exitCode: null, stderr, spawnError: err.message }));
|
|
16
|
+
child.on('close', (code) => resolve({ exitCode: code, stderr }));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export const hostUser = () => process.getuid && process.getgid ? { uid: process.getuid(), gid: process.getgid() } : null;
|
|
20
|
+
/**
|
|
21
|
+
* Download caches of every package manager the network-on steps use, under the /cache mount. Yarn (Berry) gets its
|
|
22
|
+
* global folder only, so a repository's own cache folder (zero-installs) stays in use. pnpm keeps its store under PNPM_HOME.
|
|
23
|
+
*/
|
|
24
|
+
const CACHE_ENV = {
|
|
25
|
+
npm_config_cache: '/cache/npm',
|
|
26
|
+
PNPM_HOME: '/cache/pnpm',
|
|
27
|
+
YARN_GLOBAL_FOLDER: '/cache/yarn',
|
|
28
|
+
BUN_INSTALL_CACHE_DIR: '/cache/bun',
|
|
29
|
+
PIP_CACHE_DIR: '/cache/pip',
|
|
30
|
+
UV_CACHE_DIR: '/cache/uv',
|
|
31
|
+
POETRY_CACHE_DIR: '/cache/poetry',
|
|
32
|
+
PDM_CACHE_DIR: '/cache/pdm',
|
|
33
|
+
PIPENV_CACHE_DIR: '/cache/pipenv',
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Host directory for the package caches: `HEALTH_PACKAGE_CACHE`, or `off` for none, else `$XDG_CACHE_HOME/health/packages`,
|
|
37
|
+
* else `~/.cache/health/packages`. The cache only saves downloads: lockfiles and integrity checks decide what is installed.
|
|
38
|
+
*/
|
|
39
|
+
export function packageCacheDir(env = process.env, home = homedir()) {
|
|
40
|
+
const setting = env.HEALTH_PACKAGE_CACHE?.trim();
|
|
41
|
+
if (setting === 'off')
|
|
42
|
+
return null;
|
|
43
|
+
if (setting)
|
|
44
|
+
return path.resolve(setting);
|
|
45
|
+
return path.join(env.XDG_CACHE_HOME || path.join(home, '.cache'), 'health', 'packages');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `docker run` argv for one step. Network is off unless the step needs it. The container runs as the host user, so
|
|
49
|
+
* what a step writes into /work and /out stays removable on the host; HOME points at a directory any uid can write.
|
|
50
|
+
* Network-on steps also mount the host package cache at /cache, so repeated scans do not download the same packages again.
|
|
51
|
+
*/
|
|
52
|
+
export function dockerArgs(spec, name, runner, user = hostUser(), cacheDir = null) {
|
|
53
|
+
const cache = spec.network && cacheDir !== null;
|
|
54
|
+
return [
|
|
55
|
+
'run',
|
|
56
|
+
'--rm',
|
|
57
|
+
'--pull',
|
|
58
|
+
'never',
|
|
59
|
+
'--name',
|
|
60
|
+
name,
|
|
61
|
+
'--cpus',
|
|
62
|
+
String(runner.cpus),
|
|
63
|
+
'--memory',
|
|
64
|
+
`${runner.memory_gb}g`,
|
|
65
|
+
...(user ? ['--user', `${user.uid}:${user.gid}`, '-e', 'HOME=/tmp'] : []),
|
|
66
|
+
...(spec.network ? [] : ['--network', 'none']),
|
|
67
|
+
...(cache ? Object.entries(CACHE_ENV).flatMap(([key, value]) => ['-e', `${key}=${value}`]) : []),
|
|
68
|
+
...Object.entries(spec.env).flatMap(([key, value]) => ['-e', `${key}=${value}`]),
|
|
69
|
+
'-v',
|
|
70
|
+
`${spec.workDir}:/work`,
|
|
71
|
+
'-v',
|
|
72
|
+
`${spec.outDir}:/out`,
|
|
73
|
+
...(cache ? ['-v', `${cacheDir}:/cache`] : []),
|
|
74
|
+
'-w',
|
|
75
|
+
spec.cwd === '.' ? '/work' : `/work/${spec.cwd}`,
|
|
76
|
+
runner.image,
|
|
77
|
+
...spec.argv,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
function toolError(finished, spec) {
|
|
81
|
+
if (finished.spawnError !== undefined)
|
|
82
|
+
return new ToolError(`docker could not start: ${finished.spawnError}`);
|
|
83
|
+
if (finished.exitCode !== null && [125, 126, 127].includes(finished.exitCode)) {
|
|
84
|
+
const detail = finished.stderr.trim().split('\n').slice(-2).join(' | ');
|
|
85
|
+
return new ToolError(`${spec.step} could not run in the image (exit ${finished.exitCode})${detail ? `: ${detail}` : ''}`);
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The real runner: one `docker run` per step. The timeout is enforced here with `docker kill`. The cache directory is
|
|
91
|
+
* created before a network-on step, so Docker does not create it as root.
|
|
92
|
+
*/
|
|
93
|
+
export function createDockerRunner(runner, spawn = nodeSpawn, cacheDir = packageCacheDir()) {
|
|
94
|
+
return {
|
|
95
|
+
async runStep(spec) {
|
|
96
|
+
if (spec.network && cacheDir !== null)
|
|
97
|
+
await mkdir(cacheDir, { recursive: true });
|
|
98
|
+
const name = `health-${randomBytes(4).toString('hex')}-${spec.label.replace(/[^A-Za-z0-9_.-]/g, '-')}`;
|
|
99
|
+
const started = Date.now();
|
|
100
|
+
let timedOut = false;
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
timedOut = true;
|
|
103
|
+
void run(spawn, ['kill', name]);
|
|
104
|
+
}, spec.timeoutSeconds * 1000);
|
|
105
|
+
const finished = await run(spawn, dockerArgs(spec, name, runner, hostUser(), cacheDir));
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
const durationMs = Date.now() - started;
|
|
108
|
+
if (timedOut)
|
|
109
|
+
return { status: 'timeout', exitCode: null, durationMs, outputDir: spec.outDir };
|
|
110
|
+
const failure = toolError(finished, spec);
|
|
111
|
+
if (failure)
|
|
112
|
+
throw failure;
|
|
113
|
+
return {
|
|
114
|
+
status: finished.exitCode === 0 ? 'ok' : 'failed',
|
|
115
|
+
exitCode: finished.exitCode,
|
|
116
|
+
durationMs,
|
|
117
|
+
outputDir: spec.outDir,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The image label a full scan checks. Bump it with `LABEL dev.vibecodedshit.health.revision` in docker/Dockerfile
|
|
124
|
+
* whenever the tools a full run needs from the image change, so an image built before the change is rebuilt.
|
|
125
|
+
*/
|
|
126
|
+
export const IMAGE_REVISION_LABEL = 'dev.vibecodedshit.health.revision';
|
|
127
|
+
export const IMAGE_REVISION = '2';
|
|
128
|
+
/**
|
|
129
|
+
* `docker image inspect <tag>`: the image id (a locally built image has no RepoDigest). An image whose revision label
|
|
130
|
+
* is not IMAGE_REVISION lacks tools this version runs in it, so it is a ToolError like a missing image.
|
|
131
|
+
*/
|
|
132
|
+
export function imageDigest(tag, spawn = nodeSpawn) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
const format = `{{.Id}} {{index .Config.Labels "${IMAGE_REVISION_LABEL}"}}`;
|
|
135
|
+
const child = spawn('docker', ['image', 'inspect', tag, '--format', format], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
136
|
+
let stdout = '';
|
|
137
|
+
let stderr = '';
|
|
138
|
+
child.stdout?.on('data', (chunk) => {
|
|
139
|
+
stdout += chunk.toString('utf8');
|
|
140
|
+
});
|
|
141
|
+
child.stderr?.on('data', (chunk) => {
|
|
142
|
+
stderr += chunk.toString('utf8');
|
|
143
|
+
});
|
|
144
|
+
child.on('error', (err) => reject(new ToolError(`docker could not start: ${err.message}`)));
|
|
145
|
+
child.on('close', (code) => {
|
|
146
|
+
const [digest = '', revision = ''] = stdout.trim().split(/\s+/);
|
|
147
|
+
if (code !== 0 || digest === '') {
|
|
148
|
+
const detail = stderr.trim().split('\n').slice(-1)[0];
|
|
149
|
+
reject(new ToolError(`image ${tag} is not available${detail ? `: ${detail}` : ''}. Build it with npm run image:build`));
|
|
150
|
+
}
|
|
151
|
+
else if (revision !== IMAGE_REVISION) {
|
|
152
|
+
reject(new ToolError(`image ${tag} is out of date (revision ${revision || 'none'}, this version needs ${IMAGE_REVISION}). Rebuild it with npm run image:build`));
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
resolve(digest);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { emptyTallies, unitId } from '../measure/units.js';
|
|
2
|
+
import { NORMAL_EXIT, parseEslint, parseMypy, parsePyright, parseRuff, parseTsc } from './reports.js';
|
|
3
|
+
import { withFailedUnits } from './roots.js';
|
|
4
|
+
function parse(result) {
|
|
5
|
+
switch (result.tool) {
|
|
6
|
+
case 'tsc':
|
|
7
|
+
return parseTsc(result.report ?? '', result.dir);
|
|
8
|
+
case 'mypy':
|
|
9
|
+
return parseMypy(result.report ?? '', result.dir);
|
|
10
|
+
case 'pyright':
|
|
11
|
+
return parsePyright(result.report ?? '', result.dir);
|
|
12
|
+
case 'eslint':
|
|
13
|
+
return parseEslint(result.report ?? '', result.dir);
|
|
14
|
+
case 'ruff':
|
|
15
|
+
return parseRuff(result.report ?? '', result.dir);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Why a check counts as a repository failure, or null when it ran and its report can be read. */
|
|
19
|
+
export function failure(result) {
|
|
20
|
+
if (result.outcome.status === 'timeout')
|
|
21
|
+
return `${result.tool} timed out in ${result.dir}`;
|
|
22
|
+
const code = result.outcome.exitCode;
|
|
23
|
+
if (code === null || !NORMAL_EXIT[result.tool].includes(code))
|
|
24
|
+
return `${result.tool} exited with ${String(code)} in ${result.dir}`;
|
|
25
|
+
if (result.report === null || parse(result) === null)
|
|
26
|
+
return `${result.tool} wrote no usable report in ${result.dir}`;
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Findings per 1,000 source lines for 2.1 and 2.5. Numerator: findings located in in-scope logic and
|
|
31
|
+
* test files (anything elsewhere is dropped). Denominator: source lines of those files, per
|
|
32
|
+
* (package, group) unit. A timeout or a checker with no usable report is a repository failure: the
|
|
33
|
+
* parameter scores 0. So does an install that failed in every root; when only some roots failed, the units
|
|
34
|
+
* beneath those roots are failed units and the rest are measured.
|
|
35
|
+
*/
|
|
36
|
+
export function measureFindings(ctx, results) {
|
|
37
|
+
const exec = ctx.exec;
|
|
38
|
+
if (!exec)
|
|
39
|
+
return { status: 'not_measured', reason: 'execution layer did not run' };
|
|
40
|
+
if (!exec.checksRan)
|
|
41
|
+
return { status: 'failed', reason: 'install failed, so the step did not run' };
|
|
42
|
+
const checks = results(exec);
|
|
43
|
+
for (const check of checks) {
|
|
44
|
+
const reason = failure(check);
|
|
45
|
+
if (reason !== null)
|
|
46
|
+
return { status: 'failed', reason };
|
|
47
|
+
}
|
|
48
|
+
const inScope = ctx.scope.files.filter((f) => f.role === 'logic' || f.role === 'test');
|
|
49
|
+
const tallies = emptyTallies(inScope);
|
|
50
|
+
const byPath = new Map(inScope.map((f) => [f.path, f]));
|
|
51
|
+
for (const file of inScope) {
|
|
52
|
+
const tally = tallies.get(unitId(file));
|
|
53
|
+
if (tally)
|
|
54
|
+
tally.denominator += file.sourceLines;
|
|
55
|
+
}
|
|
56
|
+
for (const check of checks) {
|
|
57
|
+
for (const reported of parse(check)?.files ?? []) {
|
|
58
|
+
const file = byPath.get(reported);
|
|
59
|
+
const tally = file && tallies.get(unitId(file));
|
|
60
|
+
if (tally)
|
|
61
|
+
tally.numerator += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const units = [...tallies.values()]
|
|
65
|
+
.sort((a, b) => a.package.localeCompare(b.package) || a.group.localeCompare(b.group))
|
|
66
|
+
.map((t) => ({
|
|
67
|
+
package: t.package,
|
|
68
|
+
group: t.group,
|
|
69
|
+
weight: t.weight,
|
|
70
|
+
raw: { kind: 'ratio', numerator: t.numerator, denominator: t.denominator, scale: 1000 },
|
|
71
|
+
}));
|
|
72
|
+
return { status: 'measured', units: withFailedUnits(exec, ctx.scope, units) };
|
|
73
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const MANIFEST = {
|
|
3
|
+
typescript_javascript: (base) => base === 'package.json',
|
|
4
|
+
python: (base) => base === 'pyproject.toml' || base === 'Pipfile' || base === 'setup.py' || /^requirements.*\.txt$/.test(base),
|
|
5
|
+
};
|
|
6
|
+
export function isUnder(dir, parent) {
|
|
7
|
+
return parent === '.' || dir === parent || dir.startsWith(`${parent}/`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Install roots per language group: the manifest directories that have no manifest directory of the
|
|
11
|
+
* same group above them, so a workspace installs once at its root and a nested example does not
|
|
12
|
+
* need an install of its own. Logic files outside every root go to a root at "." with nothing to install.
|
|
13
|
+
* A root with no logic file of its group beneath it is dropped.
|
|
14
|
+
*/
|
|
15
|
+
export function planExecution(scope, tracked) {
|
|
16
|
+
const roots = [];
|
|
17
|
+
const typecheck = [];
|
|
18
|
+
for (const group of ['typescript_javascript', 'python']) {
|
|
19
|
+
const files = scope.files.filter((f) => f.group === group && (f.role === 'logic' || f.role === 'test'));
|
|
20
|
+
if (!files.some((f) => f.role === 'logic'))
|
|
21
|
+
continue;
|
|
22
|
+
const manifestDirs = [...new Set(tracked.filter((file) => MANIFEST[group](path.posix.basename(file))).map((file) => path.posix.dirname(file)))];
|
|
23
|
+
const candidates = manifestDirs.filter((dir) => !manifestDirs.some((other) => other !== dir && isUnder(dir, other))).sort();
|
|
24
|
+
const rootOf = (file) => candidates.find((dir) => isUnder(path.posix.dirname(file.path), dir)) ?? '.';
|
|
25
|
+
const byRoot = new Map();
|
|
26
|
+
for (const file of files)
|
|
27
|
+
byRoot.set(rootOf(file), [...(byRoot.get(rootOf(file)) ?? []), file]);
|
|
28
|
+
for (const [dir, groupFiles] of [...byRoot].sort(([a], [b]) => a.localeCompare(b))) {
|
|
29
|
+
const logic = groupFiles.filter((f) => f.role === 'logic');
|
|
30
|
+
if (logic.length === 0)
|
|
31
|
+
continue;
|
|
32
|
+
roots.push({
|
|
33
|
+
dir,
|
|
34
|
+
group,
|
|
35
|
+
weight: logic.reduce((sum, f) => sum + f.logicLines, 0),
|
|
36
|
+
logicFiles: logic.map((f) => f.path),
|
|
37
|
+
sourceFiles: groupFiles.map((f) => f.path),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
const byPackage = new Map();
|
|
41
|
+
for (const file of files)
|
|
42
|
+
byPackage.set(file.package, [...(byPackage.get(file.package) ?? []), file]);
|
|
43
|
+
for (const [dir, unitFiles] of [...byPackage].sort(([a], [b]) => a.localeCompare(b))) {
|
|
44
|
+
if (!unitFiles.some((f) => f.role === 'logic'))
|
|
45
|
+
continue;
|
|
46
|
+
typecheck.push({ dir, group, root: rootOf(unitFiles[0]), sourceFiles: unitFiles.map((f) => f.path) });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { roots, typecheck };
|
|
50
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
/** Tool output paths are `/work/...` or relative to the step's directory; map both to repo-relative. */
|
|
3
|
+
export function toRepoPath(reported, stepDir) {
|
|
4
|
+
const posix = reported.split(path.sep).join('/').replace(/^\.\//, '');
|
|
5
|
+
const rel = posix.startsWith('/work/') ? posix.slice('/work/'.length) : posix.startsWith('/') ? null : path.posix.join(stepDir, posix);
|
|
6
|
+
return rel === null ? posix : path.posix.normalize(rel);
|
|
7
|
+
}
|
|
8
|
+
/** tsc `--pretty false`: `path(line,col): error TSnnnn: ...`. An error with no location is a config error, not a finding. */
|
|
9
|
+
export function parseTsc(text, stepDir) {
|
|
10
|
+
const files = [];
|
|
11
|
+
for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
12
|
+
const located = /^(.+?)\(\d+,\d+\): error TS\d+:/.exec(line);
|
|
13
|
+
if (located?.[1])
|
|
14
|
+
files.push(toRepoPath(located[1], stepDir));
|
|
15
|
+
else if (/^error TS\d+:/.test(line))
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return { files };
|
|
19
|
+
}
|
|
20
|
+
/** mypy text output: `path:line: error: ...` or `path:line:col: error: ...`. */
|
|
21
|
+
export function parseMypy(text, stepDir) {
|
|
22
|
+
const files = [];
|
|
23
|
+
for (const line of text.replace(/\r\n?/g, '\n').split('\n')) {
|
|
24
|
+
const match = /^(.+?):\d+:(?:\d+:)? error:/.exec(line);
|
|
25
|
+
if (match?.[1])
|
|
26
|
+
files.push(toRepoPath(match[1], stepDir));
|
|
27
|
+
}
|
|
28
|
+
return { files };
|
|
29
|
+
}
|
|
30
|
+
function json(text) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** `pyright --outputjson`: `generalDiagnostics` entries of severity error. Duplicates (same place and rule) count once. */
|
|
39
|
+
export function parsePyright(text, stepDir) {
|
|
40
|
+
const doc = json(text);
|
|
41
|
+
if (!doc || !Array.isArray(doc.generalDiagnostics))
|
|
42
|
+
return null;
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const files = [];
|
|
45
|
+
for (const entry of doc.generalDiagnostics) {
|
|
46
|
+
if (entry.severity !== 'error' || typeof entry.file !== 'string')
|
|
47
|
+
continue;
|
|
48
|
+
const key = `${entry.file}:${entry.range?.start?.line ?? 0}:${entry.range?.start?.character ?? 0}:${entry.rule ?? entry.message ?? ''}`;
|
|
49
|
+
if (seen.has(key))
|
|
50
|
+
continue;
|
|
51
|
+
seen.add(key);
|
|
52
|
+
files.push(toRepoPath(entry.file, stepDir));
|
|
53
|
+
}
|
|
54
|
+
return { files };
|
|
55
|
+
}
|
|
56
|
+
/** `eslint -f json`: every message is a finding, including a parse error (no rule id), which counts as one. */
|
|
57
|
+
export function parseEslint(text, stepDir) {
|
|
58
|
+
const doc = json(text);
|
|
59
|
+
if (!Array.isArray(doc))
|
|
60
|
+
return null;
|
|
61
|
+
const files = [];
|
|
62
|
+
for (const entry of doc) {
|
|
63
|
+
if (typeof entry.filePath !== 'string')
|
|
64
|
+
return null;
|
|
65
|
+
for (let i = 0; i < (entry.messages?.length ?? 0); i += 1)
|
|
66
|
+
files.push(toRepoPath(entry.filePath, stepDir));
|
|
67
|
+
}
|
|
68
|
+
return { files };
|
|
69
|
+
}
|
|
70
|
+
/** `ruff check --output-format json`: one entry per finding, a syntax error included. */
|
|
71
|
+
export function parseRuff(text, stepDir) {
|
|
72
|
+
const doc = json(text);
|
|
73
|
+
if (!Array.isArray(doc))
|
|
74
|
+
return null;
|
|
75
|
+
const files = [];
|
|
76
|
+
for (const entry of doc) {
|
|
77
|
+
if (typeof entry.filename !== 'string')
|
|
78
|
+
return null;
|
|
79
|
+
files.push(toRepoPath(entry.filename, stepDir));
|
|
80
|
+
}
|
|
81
|
+
return { files };
|
|
82
|
+
}
|
|
83
|
+
/** Exit codes that mean "ran and reported findings", per tool. Anything else is a config or tool failure of the repository. */
|
|
84
|
+
export const NORMAL_EXIT = {
|
|
85
|
+
tsc: [0, 1, 2],
|
|
86
|
+
pyright: [0, 1],
|
|
87
|
+
mypy: [0, 1],
|
|
88
|
+
eslint: [0, 1],
|
|
89
|
+
ruff: [0, 1],
|
|
90
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { logicUnits } from '../measure/units.js';
|
|
2
|
+
import { isUnder } from './plan.js';
|
|
3
|
+
export function installFailed(root) {
|
|
4
|
+
return root.install !== null && root.install.status !== 'ok';
|
|
5
|
+
}
|
|
6
|
+
/** False only when every install root failed to install. With no root at all the checks ran. */
|
|
7
|
+
export function anyRootHealthy(roots) {
|
|
8
|
+
return roots.length === 0 || roots.some((root) => !installFailed(root));
|
|
9
|
+
}
|
|
10
|
+
/** The install root of `group` whose install failed and that holds `dir` (a directory or a file path), or undefined. Install roots of one group never nest, so it is unique. */
|
|
11
|
+
export function failedRootOf(exec, dir, group) {
|
|
12
|
+
return exec.roots.find((root) => installFailed(root) && root.group === group && isUnder(dir, root.dir));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The failed install roots a unit depends on: those holding its package directory or any of its logic files.
|
|
16
|
+
* A package is a workspace member or a directory with its own pyproject.toml, so without workspaces one TS/JS unit
|
|
17
|
+
* can span several install roots; it then counts as failed as soon as one of them failed.
|
|
18
|
+
*/
|
|
19
|
+
function failedRootsOf(exec, unit) {
|
|
20
|
+
return exec.roots.filter((root) => installFailed(root) && root.group === unit.group && (isUnder(unit.dir, root.dir) || unit.files.some((f) => isUnder(f.path, root.dir))));
|
|
21
|
+
}
|
|
22
|
+
/** One failed unit per (package, group) that depends on a failed install root. */
|
|
23
|
+
export function failedUnits(exec, scope) {
|
|
24
|
+
return logicUnits(scope)
|
|
25
|
+
.filter((unit) => failedRootsOf(exec, unit).length > 0)
|
|
26
|
+
.map((unit) => ({ package: unit.package, group: unit.group, weight: unit.weight, raw: { kind: 'failed' } }));
|
|
27
|
+
}
|
|
28
|
+
const unitKey = (unit) => `${unit.package}\0${unit.group}`;
|
|
29
|
+
/** `units` with the units beneath a failed install root replaced by failed ones. Order is kept when no root failed. */
|
|
30
|
+
export function withFailedUnits(exec, scope, units) {
|
|
31
|
+
const failed = failedUnits(exec, scope);
|
|
32
|
+
if (failed.length === 0)
|
|
33
|
+
return units;
|
|
34
|
+
const keys = new Set(failed.map(unitKey));
|
|
35
|
+
return [...units.filter((unit) => !keys.has(unitKey(unit))), ...failed].sort((a, b) => a.package.localeCompare(b.package) || a.group.localeCompare(b.group));
|
|
36
|
+
}
|
|
37
|
+
/** The failed install roots that the failed units of a measurement lie under, for the report. */
|
|
38
|
+
export function failedRootDirs(exec, scope, units) {
|
|
39
|
+
const logic = new Map(logicUnits(scope).map((unit) => [unitKey(unit), unit]));
|
|
40
|
+
const dirs = units.flatMap((unit) => {
|
|
41
|
+
const found = logic.get(unitKey(unit));
|
|
42
|
+
return unit.raw.kind === 'failed' && found ? failedRootsOf(exec, found).map((root) => root.dir) : [];
|
|
43
|
+
});
|
|
44
|
+
return [...new Set(dirs)].sort();
|
|
45
|
+
}
|
|
46
|
+
/** The install outcome of every install root, as run.json records it. */
|
|
47
|
+
export function executionRoots(exec) {
|
|
48
|
+
return exec.roots.map((root) => ({ dir: root.dir, group: root.group, install: root.install?.status ?? null, exitCode: root.install?.exitCode ?? null }));
|
|
49
|
+
}
|
package/dist/exec/run.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseEnvExample } from '../measure/ops/env-example.js';
|
|
5
|
+
import { dirsUpward } from '../measure/ops/entry-points.js';
|
|
6
|
+
import { readTracked, trackedPaths } from '../scope/tracked.js';
|
|
7
|
+
import { knipRoots } from '../tools/knip.js';
|
|
8
|
+
import { createWorkCopy } from '../tools/workcopy.js';
|
|
9
|
+
import { ToolError } from '../scope/types.js';
|
|
10
|
+
import { isUnder, planExecution } from './plan.js';
|
|
11
|
+
import { anyRootHealthy, installFailed } from './roots.js';
|
|
12
|
+
import { build } from './steps/build.js';
|
|
13
|
+
import { install } from './steps/install.js';
|
|
14
|
+
import { knip } from './steps/knip.js';
|
|
15
|
+
import { lint } from './steps/lint.js';
|
|
16
|
+
import { coverageInstall } from './steps/coverage-install.js';
|
|
17
|
+
import { test } from './steps/test.js';
|
|
18
|
+
import { typecheck } from './steps/typecheck.js';
|
|
19
|
+
import { detectInvocations } from './test-runners/detect.js';
|
|
20
|
+
import { RUNNERS } from './test-runners/index.js';
|
|
21
|
+
/**
|
|
22
|
+
* `timeouts_seconds` names the test step `tests` and has no entry for the coverage provider install,
|
|
23
|
+
* which is bounded like install, or for knip, which is bounded like lint.
|
|
24
|
+
*/
|
|
25
|
+
const TIMEOUT_KEY = {
|
|
26
|
+
install: 'install',
|
|
27
|
+
'coverage-install': 'install',
|
|
28
|
+
build: 'build',
|
|
29
|
+
typecheck: 'typecheck',
|
|
30
|
+
lint: 'lint',
|
|
31
|
+
knip: 'lint',
|
|
32
|
+
test: 'tests',
|
|
33
|
+
};
|
|
34
|
+
function envFor(repoPath, dir, methodology) {
|
|
35
|
+
for (const up of dirsUpward(dir)) {
|
|
36
|
+
for (const name of methodology.d6.envExampleFiles) {
|
|
37
|
+
const text = readTracked(repoPath, path.posix.join(up, name));
|
|
38
|
+
if (text !== null)
|
|
39
|
+
return parseEnvExample(text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reads the reports of one test run. A timeout or a missing or unreadable results report leaves `results`
|
|
46
|
+
* unset (a repository failure). A results report with at least one case but no usable coverage report is
|
|
47
|
+
* a ToolError: coverage is the tool's job, so the run is invalid.
|
|
48
|
+
*/
|
|
49
|
+
async function readTest(invocation, outcome) {
|
|
50
|
+
const spec = RUNNERS[invocation.runner];
|
|
51
|
+
const base = { dir: invocation.dir, group: invocation.group, runner: invocation.runner, args: invocation.args, outcome };
|
|
52
|
+
if (outcome.status === 'timeout')
|
|
53
|
+
return base;
|
|
54
|
+
const read = (name) => readFile(path.join(outcome.outputDir, name), 'utf8').catch(() => null);
|
|
55
|
+
const text = await read(spec.results);
|
|
56
|
+
const results = text === null ? null : spec.parseResults(text);
|
|
57
|
+
if (results === null)
|
|
58
|
+
return base;
|
|
59
|
+
if (results.passed + results.failed + results.errored + results.skipped === 0)
|
|
60
|
+
return { ...base, results };
|
|
61
|
+
const coverageText = await read(spec.coverage);
|
|
62
|
+
const coverage = coverageText === null ? null : spec.parseCoverage(coverageText, invocation.dir);
|
|
63
|
+
if (coverage === null)
|
|
64
|
+
throw new ToolError(`${invocation.runner} produced results but no usable coverage data in ${invocation.dir}`);
|
|
65
|
+
if (spec.ranges === undefined || spec.parseRanges === undefined)
|
|
66
|
+
return { ...base, results, coverage };
|
|
67
|
+
const rangesText = await read(spec.ranges);
|
|
68
|
+
const ranges = rangesText === null ? null : spec.parseRanges(rangesText, invocation.dir);
|
|
69
|
+
if (ranges === null)
|
|
70
|
+
throw new ToolError(`${invocation.runner} produced no usable coverage-final.json in ${invocation.dir}`);
|
|
71
|
+
return { ...base, results, coverage, ranges };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Install, then build per install root, then typecheck per workspace package, lint per install root and knip
|
|
75
|
+
* per knip root, for the roots whose install worked (a failed root is skipped, its units score 0). One `docker run` each, run one after another on a temp copy
|
|
76
|
+
* of the tracked files; reports go to a separate temp directory, deleted afterwards. The work copy
|
|
77
|
+
* outlives the call so static tools can read the installed dependencies (`results.workDir`); the
|
|
78
|
+
* caller removes it with `cleanup`. On a throw both are deleted here.
|
|
79
|
+
* Throws `ToolError` from the runner; a repository failure is only recorded in the results.
|
|
80
|
+
*/
|
|
81
|
+
export async function runExecution(opts) {
|
|
82
|
+
const { repoPath, methodology, scope, runner, image } = opts;
|
|
83
|
+
const timeouts = methodology.execution.timeouts_seconds;
|
|
84
|
+
const tracked = trackedPaths(repoPath);
|
|
85
|
+
const trackedSet = new Set(tracked);
|
|
86
|
+
const plan = planExecution(scope, tracked);
|
|
87
|
+
const work = await createWorkCopy(repoPath, tracked);
|
|
88
|
+
const out = await mkdtemp(path.join(tmpdir(), 'health-out-'));
|
|
89
|
+
let counter = 0;
|
|
90
|
+
try {
|
|
91
|
+
const run = async (step, ctx, dirForEnv) => {
|
|
92
|
+
const command = step.command(ctx);
|
|
93
|
+
if (command === null)
|
|
94
|
+
return null;
|
|
95
|
+
counter += 1;
|
|
96
|
+
const label = `${step.name}-${counter}`;
|
|
97
|
+
const outDir = path.join(out, label);
|
|
98
|
+
await mkdir(outDir, { recursive: true });
|
|
99
|
+
const outcome = await runner.runStep({
|
|
100
|
+
label,
|
|
101
|
+
step: step.name,
|
|
102
|
+
network: step.network,
|
|
103
|
+
argv: command.argv,
|
|
104
|
+
cwd: ctx.dir,
|
|
105
|
+
timeoutSeconds: timeouts[TIMEOUT_KEY[step.name]],
|
|
106
|
+
env: envFor(repoPath, dirForEnv, methodology),
|
|
107
|
+
workDir: work.dir,
|
|
108
|
+
outDir,
|
|
109
|
+
});
|
|
110
|
+
return { outcome, command };
|
|
111
|
+
};
|
|
112
|
+
const context = (dir, group, files) => ({
|
|
113
|
+
dir,
|
|
114
|
+
group,
|
|
115
|
+
tracked: trackedSet,
|
|
116
|
+
read: (rel) => readTracked(repoPath, rel),
|
|
117
|
+
files,
|
|
118
|
+
});
|
|
119
|
+
const roots = [];
|
|
120
|
+
for (const root of plan.roots) {
|
|
121
|
+
const ctx = context(root.dir, root.group, root.logicFiles);
|
|
122
|
+
const installed = await run(install, ctx, root.dir);
|
|
123
|
+
const installOk = installed === null || installed.outcome.status === 'ok';
|
|
124
|
+
const built = installOk ? await run(build, ctx, root.dir) : null;
|
|
125
|
+
roots.push({ dir: root.dir, group: root.group, weight: root.weight, install: installed?.outcome ?? null, build: built?.outcome ?? null });
|
|
126
|
+
}
|
|
127
|
+
const checksRan = anyRootHealthy(roots);
|
|
128
|
+
const healthy = (dir, group) => !roots.some((r) => installFailed(r) && r.group === group && isUnder(dir, r.dir));
|
|
129
|
+
const healthyRoots = plan.roots.filter((root) => healthy(root.dir, root.group));
|
|
130
|
+
const typecheckResults = [];
|
|
131
|
+
const lintResults = [];
|
|
132
|
+
const knipResults = [];
|
|
133
|
+
const testResults = [];
|
|
134
|
+
if (checksRan) {
|
|
135
|
+
const collect = async (step, ctx, into, envDir) => {
|
|
136
|
+
const result = await run(step, ctx, envDir);
|
|
137
|
+
if (result === null || !result.command.tool || !result.command.report)
|
|
138
|
+
return;
|
|
139
|
+
const report = await readFile(path.join(result.outcome.outputDir, result.command.report), 'utf8').catch(() => null);
|
|
140
|
+
into.push({ tool: result.command.tool, dir: ctx.dir, group: ctx.group, outcome: result.outcome, report });
|
|
141
|
+
};
|
|
142
|
+
for (const pkg of plan.typecheck.filter((p) => healthy(p.root, p.group)))
|
|
143
|
+
await collect(typecheck, context(pkg.dir, pkg.group, pkg.sourceFiles), typecheckResults, pkg.root);
|
|
144
|
+
for (const root of healthyRoots)
|
|
145
|
+
await collect(lint, context(root.dir, root.group, root.sourceFiles), lintResults, root.dir);
|
|
146
|
+
for (const dir of knipRoots(repoPath).filter((dir) => healthy(dir, 'typescript_javascript'))) {
|
|
147
|
+
const result = await run(knip, context(dir, 'typescript_javascript', []), dir);
|
|
148
|
+
if (result === null || !result.command.report)
|
|
149
|
+
continue;
|
|
150
|
+
const report = await readFile(path.join(result.outcome.outputDir, result.command.report), 'utf8').catch(() => null);
|
|
151
|
+
knipResults.push({ dir, outcome: result.outcome, report });
|
|
152
|
+
}
|
|
153
|
+
const invocations = detectInvocations({ roots: healthyRoots, scope, tracked: trackedSet, read: (rel) => readTracked(repoPath, rel) });
|
|
154
|
+
for (const invocation of invocations) {
|
|
155
|
+
const files = healthyRoots
|
|
156
|
+
.filter((r) => r.group === invocation.group)
|
|
157
|
+
.flatMap((r) => r.logicFiles)
|
|
158
|
+
.filter((file) => invocation.dir === '.' || file.startsWith(`${invocation.dir}/`));
|
|
159
|
+
const ctx = { ...context(invocation.dir, invocation.group, files), invocation };
|
|
160
|
+
const provider = await run(coverageInstall, ctx, invocation.dir);
|
|
161
|
+
if (provider && provider.outcome.status !== 'ok') {
|
|
162
|
+
throw new ToolError(`the coverage provider could not be installed for ${invocation.runner} in ${invocation.dir} (${provider.outcome.status})`);
|
|
163
|
+
}
|
|
164
|
+
const ran = await run(test, ctx, invocation.dir);
|
|
165
|
+
if (ran === null)
|
|
166
|
+
continue;
|
|
167
|
+
testResults.push(await readTest(invocation, ran.outcome));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
results: { image, roots, checksRan, typecheck: typecheckResults, lint: lintResults, knip: knipResults, test: { invocations: testResults }, workDir: work.dir },
|
|
172
|
+
cleanup: () => work.cleanup(),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
await work.cleanup();
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await rm(out, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { inDir, jsManager } from './lockfiles.js';
|
|
3
|
+
function hasBuildScript(text) {
|
|
4
|
+
if (text === null)
|
|
5
|
+
return false;
|
|
6
|
+
try {
|
|
7
|
+
const scripts = JSON.parse(text).scripts;
|
|
8
|
+
return typeof scripts?.build === 'string';
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** First-party top-level packages: directories with an `__init__.py` that hold a logic file. */
|
|
15
|
+
function topLevelPackages(ctx) {
|
|
16
|
+
const names = new Set();
|
|
17
|
+
for (const file of ctx.files) {
|
|
18
|
+
const rel = path.posix.relative(ctx.dir, file);
|
|
19
|
+
const top = rel.split('/')[0];
|
|
20
|
+
if (!top || top === rel || !/^[A-Za-z_]\w*$/.test(top))
|
|
21
|
+
continue;
|
|
22
|
+
if (ctx.tracked.has(path.posix.join(ctx.dir, top, '__init__.py')))
|
|
23
|
+
names.add(top);
|
|
24
|
+
}
|
|
25
|
+
return [...names].sort();
|
|
26
|
+
}
|
|
27
|
+
/** The build script, or for Python `manage.py check` (Django) or importing each top-level package. Network off. */
|
|
28
|
+
export const build = {
|
|
29
|
+
name: 'build',
|
|
30
|
+
network: false,
|
|
31
|
+
command(ctx) {
|
|
32
|
+
if (ctx.group === 'typescript_javascript') {
|
|
33
|
+
if (!hasBuildScript(ctx.read(inDir(ctx, 'package.json'))))
|
|
34
|
+
return null;
|
|
35
|
+
return { argv: [jsManager(ctx).manager, 'run', 'build'] };
|
|
36
|
+
}
|
|
37
|
+
if (/django/i.test(ctx.read(inDir(ctx, 'manage.py')) ?? '')) {
|
|
38
|
+
return { argv: ['health-venv', 'python', 'manage.py', 'check'] };
|
|
39
|
+
}
|
|
40
|
+
const packages = topLevelPackages(ctx);
|
|
41
|
+
if (packages.length === 0)
|
|
42
|
+
return null;
|
|
43
|
+
return { argv: ['health-venv', 'python', '-c', `import ${packages.join(', ')}`] };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@vitest/coverage-v8` has to match the installed vitest, so it cannot be baked into the image. This
|
|
3
|
+
* installs it into the temporary copy only (never into package.json or a lockfile). The only network-on
|
|
4
|
+
* step after install. A failure is a tool error: the provider is the tool's dependency, not the repository's.
|
|
5
|
+
*/
|
|
6
|
+
export const coverageInstall = {
|
|
7
|
+
name: 'coverage-install',
|
|
8
|
+
network: true,
|
|
9
|
+
command(ctx) {
|
|
10
|
+
return ctx.invocation?.runner === 'vitest' ? { argv: ['health-coverage-install'] } : null;
|
|
11
|
+
},
|
|
12
|
+
};
|