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.
Files changed (236) hide show
  1. package/LICENSE +95 -0
  2. package/README.md +73 -2
  3. package/assets/fonts/NotoEmoji-OFL.txt +93 -0
  4. package/assets/fonts/NotoEmoji-Poop.ttf +0 -0
  5. package/assets/fonts/SpaceGrotesk-Bold.ttf +0 -0
  6. package/assets/fonts/SpaceGrotesk-OFL.txt +93 -0
  7. package/dist/bin.js +11 -0
  8. package/dist/card/previous.js +38 -0
  9. package/dist/card/render.js +71 -0
  10. package/dist/cli.js +138 -0
  11. package/dist/commands/card.js +69 -0
  12. package/dist/commands/common.js +1 -0
  13. package/dist/commands/diff.js +54 -0
  14. package/dist/commands/footer.js +28 -0
  15. package/dist/commands/report.js +49 -0
  16. package/dist/commands/route.js +62 -0
  17. package/dist/commands/submit.js +125 -0
  18. package/dist/commands/summary.js +77 -0
  19. package/dist/diff/compare.js +73 -0
  20. package/dist/diff/conditions.js +87 -0
  21. package/dist/diff/format.js +23 -0
  22. package/dist/exec/docker.js +158 -0
  23. package/dist/exec/findings.js +73 -0
  24. package/dist/exec/plan.js +50 -0
  25. package/dist/exec/reports.js +90 -0
  26. package/dist/exec/roots.js +49 -0
  27. package/dist/exec/run.js +182 -0
  28. package/dist/exec/steps/build.js +45 -0
  29. package/dist/exec/steps/coverage-install.js +12 -0
  30. package/dist/exec/steps/install.js +41 -0
  31. package/dist/exec/steps/knip.js +28 -0
  32. package/dist/exec/steps/lint.js +25 -0
  33. package/dist/exec/steps/lockfiles.js +25 -0
  34. package/dist/exec/steps/test.js +17 -0
  35. package/dist/exec/steps/typecheck.js +40 -0
  36. package/dist/exec/test-reports/coverage-py.js +23 -0
  37. package/dist/exec/test-reports/coverage-ranges.js +65 -0
  38. package/dist/exec/test-reports/coverage-summary.js +23 -0
  39. package/dist/exec/test-reports/jest-json.js +31 -0
  40. package/dist/exec/test-reports/junit.js +24 -0
  41. package/dist/exec/test-reports/mocha-json.js +18 -0
  42. package/dist/exec/test-reports/vitest-json.js +5 -0
  43. package/dist/exec/test-runners/detect.js +99 -0
  44. package/dist/exec/test-runners/index.js +6 -0
  45. package/dist/exec/test-runners/jest.js +27 -0
  46. package/dist/exec/test-runners/mocha.js +28 -0
  47. package/dist/exec/test-runners/node-test.js +24 -0
  48. package/dist/exec/test-runners/pytest.js +18 -0
  49. package/dist/exec/test-runners/script.js +48 -0
  50. package/dist/exec/test-runners/spec.js +1 -0
  51. package/dist/exec/test-runners/vitest.js +28 -0
  52. package/dist/exec/tests.js +120 -0
  53. package/dist/exec/types.js +1 -0
  54. package/dist/interactive/after-scan.js +121 -0
  55. package/dist/interactive/docker-setup.js +97 -0
  56. package/dist/interactive/prompt.js +17 -0
  57. package/dist/interactive/system.js +10 -0
  58. package/dist/manifest/package-json.js +36 -0
  59. package/dist/manifest/python.js +106 -0
  60. package/dist/measure/d3/index.js +45 -0
  61. package/dist/measure/d3/presets.js +100 -0
  62. package/dist/measure/d3/py/adapter.js +67 -0
  63. package/dist/measure/d3/py/config.js +80 -0
  64. package/dist/measure/d3/py/contracts.js +57 -0
  65. package/dist/measure/d3/py/domain.js +57 -0
  66. package/dist/measure/d3/py/graph.js +214 -0
  67. package/dist/measure/d3/py/interpreter.js +31 -0
  68. package/dist/measure/d3/py/layers.js +120 -0
  69. package/dist/measure/d3/py/roots.js +58 -0
  70. package/dist/measure/d3/py/types.js +1 -0
  71. package/dist/measure/d3/scc.js +64 -0
  72. package/dist/measure/d3/ts/adapter.js +371 -0
  73. package/dist/measure/d3/ts/classify.js +107 -0
  74. package/dist/measure/d3/ts/config.js +318 -0
  75. package/dist/measure/d3/ts/domain.js +23 -0
  76. package/dist/measure/d3/ts/rules.js +65 -0
  77. package/dist/measure/d3/types.js +5 -0
  78. package/dist/measure/deps/cvss.js +66 -0
  79. package/dist/measure/deps/declared.js +195 -0
  80. package/dist/measure/deps/files.js +40 -0
  81. package/dist/measure/deps/lockfiles.js +308 -0
  82. package/dist/measure/deps/osv.js +122 -0
  83. package/dist/measure/deps/registry.js +142 -0
  84. package/dist/measure/deps/resolved.js +78 -0
  85. package/dist/measure/deps/units.js +5 -0
  86. package/dist/measure/eslint/health-plugin.js +19 -0
  87. package/dist/measure/ops/docs-commands.js +73 -0
  88. package/dist/measure/ops/docs-resolve.js +217 -0
  89. package/dist/measure/ops/entry-points.js +49 -0
  90. package/dist/measure/ops/env-example.js +20 -0
  91. package/dist/measure/ops/env-reads.js +46 -0
  92. package/dist/measure/ops/runtime.js +73 -0
  93. package/dist/measure/p1-1.js +8 -0
  94. package/dist/measure/p1-2.js +8 -0
  95. package/dist/measure/p1-3.js +8 -0
  96. package/dist/measure/p1-4.js +41 -0
  97. package/dist/measure/p1-5.js +59 -0
  98. package/dist/measure/p2-1.js +8 -0
  99. package/dist/measure/p2-2.js +44 -0
  100. package/dist/measure/p2-3.js +81 -0
  101. package/dist/measure/p2-4.js +288 -0
  102. package/dist/measure/p2-5.js +8 -0
  103. package/dist/measure/p3-1.js +16 -0
  104. package/dist/measure/p3-2.js +26 -0
  105. package/dist/measure/p3-3.js +37 -0
  106. package/dist/measure/p3-4.js +26 -0
  107. package/dist/measure/p3-5.js +26 -0
  108. package/dist/measure/p4-1.js +184 -0
  109. package/dist/measure/p4-2.js +27 -0
  110. package/dist/measure/p4-3.js +106 -0
  111. package/dist/measure/p4-4.js +309 -0
  112. package/dist/measure/p5-1.js +117 -0
  113. package/dist/measure/p5-2.js +80 -0
  114. package/dist/measure/p5-3.js +21 -0
  115. package/dist/measure/p5-4.js +161 -0
  116. package/dist/measure/p5-5.js +158 -0
  117. package/dist/measure/p6-1.js +20 -0
  118. package/dist/measure/p6-2.js +20 -0
  119. package/dist/measure/p6-3.js +40 -0
  120. package/dist/measure/p6-4.js +31 -0
  121. package/dist/measure/p6-5.js +60 -0
  122. package/dist/measure/p6-6.js +54 -0
  123. package/dist/measure/py/typecheck-config.js +95 -0
  124. package/dist/measure/registry.js +31 -0
  125. package/dist/measure/safety/py-tests.js +124 -0
  126. package/dist/measure/safety/ts-barrels.js +68 -0
  127. package/dist/measure/safety/ts-cases.js +111 -0
  128. package/dist/measure/safety/ts-credits.js +101 -0
  129. package/dist/measure/safety/ts-imports.js +63 -0
  130. package/dist/measure/safety/ts-resolve.js +43 -0
  131. package/dist/measure/ts/directives.js +37 -0
  132. package/dist/measure/ts/exports.js +60 -0
  133. package/dist/measure/ts/tsconfig.js +179 -0
  134. package/dist/measure/types.js +1 -0
  135. package/dist/measure/units.js +41 -0
  136. package/dist/methodology/load.js +159 -0
  137. package/dist/methodology/schema.js +105 -0
  138. package/dist/python/deadcode.js +27 -0
  139. package/dist/python/env.js +13 -0
  140. package/dist/python/helper.js +24 -0
  141. package/dist/python/imports.js +40 -0
  142. package/dist/python/nox.js +16 -0
  143. package/dist/python/scope.js +35 -0
  144. package/dist/python/tests.js +19 -0
  145. package/dist/python/typing.js +27 -0
  146. package/dist/report/escape.js +5 -0
  147. package/dist/report/format.js +75 -0
  148. package/dist/report/render.js +213 -0
  149. package/dist/report/roadmap.js +58 -0
  150. package/dist/report/scares.js +83 -0
  151. package/dist/report/style.js +27 -0
  152. package/dist/report-only/hotspots.js +48 -0
  153. package/dist/route/check.js +37 -0
  154. package/dist/route/files.js +74 -0
  155. package/dist/route/order.js +22 -0
  156. package/dist/route/select.js +114 -0
  157. package/dist/route/write.js +13 -0
  158. package/dist/run/read.js +89 -0
  159. package/dist/run/recompute.js +19 -0
  160. package/dist/run/scan.js +196 -0
  161. package/dist/run/schema.js +1 -0
  162. package/dist/run/score-run.js +42 -0
  163. package/dist/run/write.js +4 -0
  164. package/dist/scope/build.js +173 -0
  165. package/dist/scope/packages.js +87 -0
  166. package/dist/scope/rules.js +21 -0
  167. package/dist/scope/tracked.js +34 -0
  168. package/dist/scope/ts-lines.js +63 -0
  169. package/dist/scope/types.js +7 -0
  170. package/dist/scoring/aggregate.js +38 -0
  171. package/dist/scoring/pool.js +65 -0
  172. package/dist/scoring/score.js +81 -0
  173. package/dist/scoring/what-if.js +38 -0
  174. package/dist/scripts/checks.js +53 -0
  175. package/dist/scripts/ci.js +139 -0
  176. package/dist/scripts/entry-points/find.js +31 -0
  177. package/dist/scripts/entry-points/index.js +18 -0
  178. package/dist/scripts/entry-points/justfile.js +62 -0
  179. package/dist/scripts/entry-points/makefile.js +66 -0
  180. package/dist/scripts/entry-points/nox.js +29 -0
  181. package/dist/scripts/entry-points/package-json.js +21 -0
  182. package/dist/scripts/entry-points/pyproject.js +73 -0
  183. package/dist/scripts/entry-points/tox.js +130 -0
  184. package/dist/scripts/expand.js +300 -0
  185. package/dist/scripts/triggers.js +151 -0
  186. package/dist/scripts/types.js +1 -0
  187. package/dist/submit/constants.js +10 -0
  188. package/dist/submit/payload.js +42 -0
  189. package/dist/submit/repo-identity.js +46 -0
  190. package/dist/submit/send.js +29 -0
  191. package/dist/tools/catalog.js +46 -0
  192. package/dist/tools/image.js +180 -0
  193. package/dist/tools/kit.js +71 -0
  194. package/dist/tools/knip.js +169 -0
  195. package/dist/tools/outcome.js +21 -0
  196. package/dist/tools/resolve.js +66 -0
  197. package/dist/tools/workcopy.js +27 -0
  198. package/dist/util/ini.js +37 -0
  199. package/dist/util/pep440.js +163 -0
  200. package/dist/util/toml.js +27 -0
  201. package/docker/Dockerfile +142 -0
  202. package/docker/bin/health-capture +5 -0
  203. package/docker/bin/health-coverage-install +5 -0
  204. package/docker/bin/health-eslint +66 -0
  205. package/docker/bin/health-pytest +10 -0
  206. package/docker/bin/health-tsc +10 -0
  207. package/docker/bin/health-venv +11 -0
  208. package/docker/config/eslint.config.mjs +4 -0
  209. package/docker/config/eslint.rules.mjs +65 -0
  210. package/docker/config/eslint.untyped.config.mjs +4 -0
  211. package/docker/config/knip.json +1 -0
  212. package/docker/config/ruff.toml +7 -0
  213. package/methodology/LICENSE +396 -0
  214. package/methodology/v1.0/METHODOLOGY.md +1176 -0
  215. package/methodology/v1.0/dimensions/1-safety-net.yaml +240 -0
  216. package/methodology/v1.0/dimensions/2-type-static-rigor.yaml +289 -0
  217. package/methodology/v1.0/dimensions/3-architecture-boundaries.yaml +266 -0
  218. package/methodology/v1.0/dimensions/4-complexity-maintainability.yaml +192 -0
  219. package/methodology/v1.0/dimensions/5-dependency-security-hygiene.yaml +223 -0
  220. package/methodology/v1.0/dimensions/6-operability-feedback-loop.yaml +279 -0
  221. package/methodology/v1.0/methodology.yaml +242 -0
  222. package/methodology/v1.0/presets/django.yaml +43 -0
  223. package/methodology/v1.0/presets/express.yaml +47 -0
  224. package/methodology/v1.0/presets/fastapi.yaml +52 -0
  225. package/methodology/v1.0/presets/generic.yaml +41 -0
  226. package/methodology/v1.0/presets/nextjs.yaml +50 -0
  227. package/methodology/v1.0/scope.yaml +115 -0
  228. package/package.json +61 -4
  229. package/python/dead_code_helper.py +265 -0
  230. package/python/env_helper.py +71 -0
  231. package/python/graph_helper.py +277 -0
  232. package/python/imports_helper.py +132 -0
  233. package/python/nox_helper.py +79 -0
  234. package/python/scope_helper.py +96 -0
  235. package/python/tests_helper.py +171 -0
  236. package/python/typing_helper.py +235 -0
@@ -0,0 +1,180 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { hostUser } from '../exec/docker.js';
6
+ import { catalog } from './catalog.js';
7
+ import { DEFAULT_TIMEOUT_MS } from './kit.js';
8
+ function imageEntry(name) {
9
+ const entry = catalog[name];
10
+ const base = entry.kind === 'npm'
11
+ ? { bin: entry.pkg, versionArgs: ['--version'], versionPattern: /(\d+\.\d+\.\d+\S*)/ }
12
+ : { bin: entry.bin, versionArgs: entry.versionArgs, versionPattern: entry.versionPattern };
13
+ return {
14
+ ...base,
15
+ // osv-scanner looks advisories up on osv.dev; every other tool runs with the network off.
16
+ network: name === 'osv-scanner',
17
+ // import-linter's uv tool environment, which also holds grimp for python/graph_helper.py.
18
+ ...(name === 'import-linter' ? { interpreter: '/opt/uv-tools/import-linter/bin/python' } : {}),
19
+ };
20
+ }
21
+ function inside(child, parent) {
22
+ return child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : `${parent}${path.sep}`);
23
+ }
24
+ /**
25
+ * Host directories a tool run needs, each mounted at the same path in the container so every absolute path in the
26
+ * argv, the reports and the working directory means the same thing on both sides. Candidates are the working
27
+ * directory and every absolute argument (also the part after `kind:` in `requirements.txt:/abs/path`): an existing
28
+ * directory is mounted itself, an existing file or a file still to be written through its directory. Nested
29
+ * directories collapse into the outermost one. Directories inside the scanned repository are read-only.
30
+ */
31
+ export function mountsFor(args, cwd, repoPath) {
32
+ const candidates = [cwd];
33
+ for (const arg of args) {
34
+ if (path.isAbsolute(arg))
35
+ candidates.push(arg);
36
+ const colon = arg.indexOf(':');
37
+ if (colon > 0 && path.isAbsolute(arg.slice(colon + 1)))
38
+ candidates.push(arg.slice(colon + 1));
39
+ }
40
+ const dirs = new Set();
41
+ for (const candidate of candidates) {
42
+ const abs = path.resolve(candidate);
43
+ if (existsSync(abs))
44
+ dirs.add(statSync(abs).isDirectory() ? abs : path.dirname(abs));
45
+ else if (existsSync(path.dirname(abs)))
46
+ dirs.add(path.dirname(abs));
47
+ }
48
+ const sorted = [...dirs].sort((a, b) => a.length - b.length || a.localeCompare(b));
49
+ const kept = [];
50
+ for (const dir of sorted)
51
+ if (!kept.some((k) => inside(dir, k)))
52
+ kept.push(dir);
53
+ const repo = path.resolve(repoPath);
54
+ return kept.map((dir) => ({ dir, readOnly: inside(dir, repo) }));
55
+ }
56
+ /** git inside the image runs as the host uid on files it may not own; gitleaks reads history through it. */
57
+ const GIT_SAFE_ENV = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'safe.directory', GIT_CONFIG_VALUE_0: '*' };
58
+ /**
59
+ * The full-run tool kit: every static tool runs in the pinned image (`runner.image`), one `docker run` per call, as
60
+ * the host user with `HOME=/tmp`, bounded like an execution step, network off except for osv-scanner. Versions are
61
+ * the ones the image reports, so a run records what actually measured. The host needs Docker, nothing else.
62
+ */
63
+ export function createImageToolKit(deps) {
64
+ const spawn = deps.spawn ?? nodeSpawn;
65
+ const user = deps.user === undefined ? hostUser() : deps.user;
66
+ const { runner } = deps;
67
+ const versions = new Map();
68
+ const memos = new Map();
69
+ const resolved = new Map();
70
+ function docker(argv, name, timeoutMs) {
71
+ return new Promise((resolve) => {
72
+ const child = spawn('docker', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
73
+ let stdout = '';
74
+ let stderr = '';
75
+ let timedOut = false;
76
+ let settled = false;
77
+ const finish = (result) => {
78
+ if (settled)
79
+ return;
80
+ settled = true;
81
+ clearTimeout(timer);
82
+ resolve(result);
83
+ };
84
+ const timer = setTimeout(() => {
85
+ timedOut = true;
86
+ spawn('docker', ['kill', name], { stdio: 'ignore' }).on('error', () => undefined);
87
+ }, timeoutMs);
88
+ child.stdout?.on('data', (chunk) => {
89
+ stdout += chunk.toString('utf8');
90
+ });
91
+ child.stderr?.on('data', (chunk) => {
92
+ stderr += chunk.toString('utf8');
93
+ });
94
+ child.on('error', (err) => finish({ exitCode: null, stdout, stderr, timedOut, spawnError: `docker could not start: ${err.message}` }));
95
+ child.on('close', (code) => {
96
+ // 125: docker itself failed; 126 and 127: the command is not runnable or not in the image.
97
+ if (!timedOut && code !== null && [125, 126, 127].includes(code)) {
98
+ const detail = stderr.trim().split('\n').slice(-2).join(' | ');
99
+ finish({ exitCode: code, stdout, stderr, timedOut, spawnError: `could not run in the image ${runner.image} (exit ${code})${detail ? `: ${detail}` : ''}` });
100
+ }
101
+ else
102
+ finish({ exitCode: code, stdout, stderr, timedOut });
103
+ });
104
+ });
105
+ }
106
+ /** `docker run` options up to the image. Without `cwd` nothing is mounted. */
107
+ function containerArgs(name, network, cwd, mountArgs, env) {
108
+ const mounts = cwd === null ? [] : mountsFor(mountArgs, cwd, deps.repoPath);
109
+ return [
110
+ 'run',
111
+ '--rm',
112
+ '--pull',
113
+ 'never',
114
+ '--name',
115
+ name,
116
+ '--cpus',
117
+ String(runner.cpus),
118
+ '--memory',
119
+ `${runner.memory_gb}g`,
120
+ ...(user ? ['--user', `${user.uid}:${user.gid}`] : []),
121
+ '-e',
122
+ 'HOME=/tmp',
123
+ ...(network ? [] : ['--network', 'none']),
124
+ ...Object.entries({ ...GIT_SAFE_ENV, ...env }).flatMap(([key, value]) => ['-e', `${key}=${value}`]),
125
+ ...mounts.flatMap((m) => ['-v', `${m.dir}:${m.dir}${m.readOnly ? ':ro' : ''}`]),
126
+ ...(cwd === null ? [] : ['-w', path.resolve(cwd)]),
127
+ runner.image,
128
+ ];
129
+ }
130
+ const containerName = (tool) => `health-${randomBytes(4).toString('hex')}-${tool}`;
131
+ async function resolveInImage(name) {
132
+ const entry = imageEntry(name);
133
+ const container = containerName(name);
134
+ const result = await docker([...containerArgs(container, false, null, [], {}), entry.bin, ...entry.versionArgs], container, 60_000);
135
+ if (result.spawnError !== undefined || result.exitCode !== 0)
136
+ return undefined;
137
+ const match = entry.versionPattern.exec(`${result.stdout}\n${result.stderr}`);
138
+ if (!match?.[1])
139
+ return undefined;
140
+ return { name, command: entry.bin, prefixArgs: [], version: match[1], ...(entry.interpreter ? { interpreter: entry.interpreter } : {}) };
141
+ }
142
+ const kit = {
143
+ resolve(name) {
144
+ let found = resolved.get(name);
145
+ if (!found) {
146
+ found = resolveInImage(name).then((tool) => {
147
+ if (tool)
148
+ versions.set(name, tool.version);
149
+ return tool;
150
+ });
151
+ resolved.set(name, found);
152
+ }
153
+ return found;
154
+ },
155
+ run(tool, args, opts) {
156
+ const container = containerName(tool.name);
157
+ const argv = [...tool.prefixArgs, ...args];
158
+ return docker([...containerArgs(container, imageEntry(tool.name).network, opts.cwd, argv, opts.env ?? {}), tool.command, ...argv], container, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
159
+ },
160
+ fetch: deps.fetch ?? globalThis.fetch,
161
+ recordVersion(name, version) {
162
+ versions.set(name, version);
163
+ },
164
+ versions() {
165
+ return Object.fromEntries([...versions].sort(([a], [b]) => a.localeCompare(b)));
166
+ },
167
+ memo(key, make) {
168
+ const cached = memos.get(key);
169
+ if (cached)
170
+ return cached;
171
+ const made = make();
172
+ memos.set(key, made);
173
+ return made;
174
+ },
175
+ notFoundReason(name) {
176
+ return `${name} is not in the image ${runner.image}, rebuild it: npm run image:build`;
177
+ },
178
+ };
179
+ return kit;
180
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { catalog } from './catalog.js';
3
+ import { resolveTool } from './resolve.js';
4
+ /** Same as the typecheck and lint timeouts in methodology.yaml. */
5
+ export const DEFAULT_TIMEOUT_MS = 600_000;
6
+ export function createToolKit(deps = {}) {
7
+ const spawn = deps.spawn ?? nodeSpawn;
8
+ const env = deps.env ?? process.env;
9
+ const versions = new Map();
10
+ const memos = new Map();
11
+ function run(tool, args, opts) {
12
+ return new Promise((resolve) => {
13
+ const child = spawn(tool.command, [...tool.prefixArgs, ...args], {
14
+ cwd: opts.cwd,
15
+ env: { ...env, ...opts.env },
16
+ stdio: ['ignore', 'pipe', 'pipe'],
17
+ });
18
+ let stdout = '';
19
+ let stderr = '';
20
+ let timedOut = false;
21
+ let settled = false;
22
+ const finish = (result) => {
23
+ if (settled)
24
+ return;
25
+ settled = true;
26
+ clearTimeout(timer);
27
+ resolve(result);
28
+ };
29
+ const timer = setTimeout(() => {
30
+ timedOut = true;
31
+ child.kill('SIGKILL');
32
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
33
+ child.stdout?.on('data', (chunk) => {
34
+ stdout += chunk.toString('utf8');
35
+ });
36
+ child.stderr?.on('data', (chunk) => {
37
+ stderr += chunk.toString('utf8');
38
+ });
39
+ child.on('error', (err) => finish({ exitCode: null, stdout, stderr, timedOut, spawnError: err.message }));
40
+ child.on('close', (code) => finish({ exitCode: code, stdout, stderr, timedOut }));
41
+ });
42
+ }
43
+ const kit = {
44
+ async resolve(name) {
45
+ const found = await resolveTool(name, { spawn, env });
46
+ if (found)
47
+ versions.set(name, found.version);
48
+ return found;
49
+ },
50
+ run,
51
+ fetch: deps.fetch ?? globalThis.fetch,
52
+ recordVersion(name, version) {
53
+ versions.set(name, version);
54
+ },
55
+ versions() {
56
+ return Object.fromEntries([...versions].sort(([a], [b]) => a.localeCompare(b)));
57
+ },
58
+ memo(key, make) {
59
+ const cached = memos.get(key);
60
+ if (cached)
61
+ return cached;
62
+ const made = make();
63
+ memos.set(key, made);
64
+ return made;
65
+ },
66
+ notFoundReason(name) {
67
+ return `${name} not found, install: ${catalog[name].install}`;
68
+ },
69
+ };
70
+ return kit;
71
+ }
@@ -0,0 +1,169 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { isUnder } from '../exec/plan.js';
6
+ import { failedRootOf } from '../exec/roots.js';
7
+ import { failureReason, unavailable } from './outcome.js';
8
+ /** Whether the package at `pkg` lies under a knip root where knip failed. */
9
+ export function knipFailed(knip, pkg) {
10
+ return knip.failedRoots.some((root) => isUnder(pkg, root));
11
+ }
12
+ function entries(value) {
13
+ return Array.isArray(value) ? value : [];
14
+ }
15
+ function names(value) {
16
+ return entries(value)
17
+ .map((e) => e.name)
18
+ .filter((n) => typeof n === 'string');
19
+ }
20
+ /**
21
+ * Knip's JSON report. A config knip loads can print to stdout first (dotenv 17 prints an
22
+ * "injected env" banner), so when stdout is not JSON as a whole, the report is read from the last
23
+ * line that starts with `{` to the end.
24
+ */
25
+ function parseKnipReport(stdout) {
26
+ try {
27
+ return JSON.parse(stdout);
28
+ }
29
+ catch (err) {
30
+ const lines = stdout.split('\n');
31
+ const start = lines.findLastIndex((line) => line.startsWith('{'));
32
+ if (start <= 0)
33
+ throw err;
34
+ return JSON.parse(lines.slice(start).join('\n'));
35
+ }
36
+ }
37
+ /** Knip roots: the repository root when it has a tracked package.json, else each shallowest directory holding one. */
38
+ export function knipRoots(repoPath) {
39
+ const result = spawnSync('git', ['-C', repoPath, 'ls-files', '-z', '--', 'package.json', '**/package.json'], {
40
+ encoding: 'utf8',
41
+ maxBuffer: 256 * 1024 * 1024,
42
+ });
43
+ if (result.error || result.status !== 0)
44
+ return [];
45
+ const dirs = result.stdout
46
+ .split('\0')
47
+ .filter((p) => p !== '')
48
+ .map((p) => path.posix.dirname(p));
49
+ if (dirs.includes('.'))
50
+ return ['.'];
51
+ const sorted = [...new Set(dirs)].sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
52
+ const roots = [];
53
+ for (const dir of sorted) {
54
+ if (!roots.some((root) => dir === root || dir.startsWith(`${root}/`)))
55
+ roots.push(dir);
56
+ }
57
+ return roots;
58
+ }
59
+ /** Adds one knip root's report to `outcome`. Returns why the report cannot be used, or null. */
60
+ function addReport(outcome, root, stdout) {
61
+ let issues;
62
+ try {
63
+ const parsed = parseKnipReport(stdout);
64
+ if (!Array.isArray(parsed.issues))
65
+ throw new Error('no issues array');
66
+ issues = parsed.issues;
67
+ }
68
+ catch {
69
+ return 'knip printed no parseable JSON report';
70
+ }
71
+ for (const issue of issues) {
72
+ if (typeof issue.file !== 'string')
73
+ return 'knip report has an unexpected shape';
74
+ const rel = path.posix.normalize(path.posix.join(root === '.' ? '' : root, issue.file.split(path.sep).join('/')));
75
+ const entry = outcome.files.get(rel) ?? {
76
+ exports: 0,
77
+ unusedFile: false,
78
+ dependencies: [],
79
+ devDependencies: [],
80
+ unlisted: [],
81
+ };
82
+ entry.exports += entries(issue.exports).length + entries(issue.types).length;
83
+ entry.unusedFile = entry.unusedFile || entries(issue.files).length > 0;
84
+ entry.dependencies.push(...names(issue.dependencies));
85
+ entry.devDependencies.push(...names(issue.devDependencies));
86
+ entry.unlisted.push(...names(issue.unlisted));
87
+ outcome.files.set(rel, entry);
88
+ }
89
+ return null;
90
+ }
91
+ /** Knip's exit codes for a finished run: 0 no issues, 1 issues found. */
92
+ const KNIP_OK = [0, 1];
93
+ /** Adds the knip steps of a full run, which ran in the image on the installed work copy, to `outcome`. Returns why one cannot be used, or null. */
94
+ function fromExecution(exec, outcome) {
95
+ for (const result of exec.knip) {
96
+ if (result.outcome.status === 'timeout')
97
+ return unavailable(`knip timed out in ${result.dir}`);
98
+ const code = result.outcome.exitCode;
99
+ if (code === null || !KNIP_OK.includes(code))
100
+ return unavailable(`knip exited with ${String(code)} in ${result.dir}`);
101
+ if (result.report === null)
102
+ return unavailable(`knip wrote no report in ${result.dir}`);
103
+ const failure = addReport(outcome, result.dir, result.report);
104
+ if (failure)
105
+ return unavailable(`${failure} in ${result.dir}`);
106
+ }
107
+ return null;
108
+ }
109
+ /**
110
+ * One knip run per scan, shared by 4.4 and 5.5. The repository's own knip config is not used
111
+ * (an empty --config), framework plugins stay enabled. A full run reads the knip steps the execution layer ran
112
+ * in the image, on the installed work copy, for every knip root whose install worked, so plugins can
113
+ * load configs such as vitest.config.ts and native dependencies match the platform they were
114
+ * installed for. A knip root whose install failed is still tried through the tool kit, in the image on the
115
+ * execution work copy: a report is used with a warning that unused dependencies may be overcounted, and a
116
+ * crash, timeout or unusable report marks the root failed (`failedRoots`). A knip step failing in a root whose
117
+ * install worked, or knip not starting at all, is not_measured. A preview runs knip through the tool kit on
118
+ * the host, in the repository.
119
+ */
120
+ export function runKnip(ctx) {
121
+ return ctx.tools.memo('knip', async () => {
122
+ const exec = ctx.mode === 'full' ? ctx.exec : undefined;
123
+ const outcome = { files: new Map(), warnings: [], failedRoots: [] };
124
+ const roots = knipRoots(ctx.repoPath);
125
+ let viaTools = roots;
126
+ if (exec) {
127
+ const stopped = fromExecution(exec, outcome);
128
+ if (stopped)
129
+ return stopped;
130
+ viaTools = roots.filter((root) => failedRootOf(exec, root, 'typescript_javascript') !== undefined);
131
+ if (viaTools.length === 0)
132
+ return outcome;
133
+ }
134
+ const tool = await ctx.tools.resolve('knip');
135
+ if (!tool)
136
+ return unavailable(ctx.tools.notFoundReason('knip'));
137
+ // A full run's tools run in the image, so they read the execution work copy, whose dependencies (if any) were
138
+ // installed there; the repository's own node_modules belong to the host platform.
139
+ const base = exec ? exec.workDir : ctx.repoPath;
140
+ const configDir = await mkdtemp(path.join(tmpdir(), 'health-knip-'));
141
+ try {
142
+ const configFile = path.join(configDir, 'knip.json');
143
+ await writeFile(configFile, '{}');
144
+ for (const root of viaTools) {
145
+ const result = await ctx.tools.run(tool, ['--reporter', 'json', '--no-progress', '--no-config-hints', '--config', configFile], { cwd: path.join(base, root), env: { DOTENV_CONFIG_QUIET: 'true' } });
146
+ const failure = failureReason(tool, result, KNIP_OK) ?? addReport(outcome, root, result.stdout);
147
+ const installRoot = exec ? failedRootOf(exec, root, 'typescript_javascript') : undefined;
148
+ if (failure === null) {
149
+ if (installRoot) {
150
+ outcome.warnings.push(`knip ran without installed dependencies because the dependency install failed in ${installRoot.dir}; unused dependencies may be overcounted`);
151
+ }
152
+ }
153
+ else if (installRoot && result.spawnError === undefined) {
154
+ outcome.failedRoots.push(root);
155
+ }
156
+ else {
157
+ return unavailable(failure);
158
+ }
159
+ }
160
+ }
161
+ finally {
162
+ await rm(configDir, { recursive: true, force: true });
163
+ }
164
+ return outcome;
165
+ });
166
+ }
167
+ export function isOutcome(value) {
168
+ return 'files' in value && value.files instanceof Map;
169
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A tool that is missing, crashed, exited unexpectedly, printed output we cannot parse or
3
+ * hit a network failure: the parameter is not_measured with the reason, in both modes, and the
4
+ * rest of the run still scores. A full run with any not_measured parameter is not comparable
5
+ * (`scan.ts`). Never a score of 0.
6
+ */
7
+ export function unavailable(reason) {
8
+ return { status: 'not_measured', reason };
9
+ }
10
+ /** Why a finished run counts as a failure, or null when its exit code is one of `okCodes`. */
11
+ export function failureReason(tool, result, okCodes) {
12
+ if (result.spawnError !== undefined)
13
+ return `${tool.name} could not start: ${result.spawnError}`;
14
+ if (result.timedOut)
15
+ return `${tool.name} timed out`;
16
+ if (result.exitCode === null || !okCodes.includes(result.exitCode)) {
17
+ const detail = result.stderr.trim().split('\n').slice(-3).join(' | ');
18
+ return `${tool.name} exited with ${String(result.exitCode)}${detail ? `: ${detail}` : ''}`;
19
+ }
20
+ return null;
21
+ }
@@ -0,0 +1,66 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, existsSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { catalog } from './catalog.js';
6
+ /** Nearest `node_modules/<pkg>/package.json` above this file. */
7
+ function findPackageJson(pkg) {
8
+ let dir = path.dirname(fileURLToPath(import.meta.url));
9
+ for (;;) {
10
+ const candidate = path.join(dir, 'node_modules', pkg, 'package.json');
11
+ if (existsSync(candidate))
12
+ return candidate;
13
+ const parent = path.dirname(dir);
14
+ if (parent === dir)
15
+ return undefined;
16
+ dir = parent;
17
+ }
18
+ }
19
+ function resolveNpm(name, pkg) {
20
+ const pkgJsonPath = findPackageJson(pkg);
21
+ if (!pkgJsonPath)
22
+ return undefined;
23
+ const json = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
24
+ const rel = typeof json.bin === 'string' ? json.bin : json.bin?.[pkg] ?? Object.values(json.bin ?? {})[0];
25
+ if (!rel)
26
+ return undefined;
27
+ return {
28
+ name,
29
+ command: process.execPath,
30
+ prefixArgs: [path.join(path.dirname(pkgJsonPath), rel)],
31
+ version: json.version,
32
+ };
33
+ }
34
+ function findOnPath(bin, env) {
35
+ const extensions = process.platform === 'win32' ? (env.PATHEXT ?? '.EXE;.CMD').split(';') : [''];
36
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
37
+ if (dir === '')
38
+ continue;
39
+ for (const ext of extensions) {
40
+ const candidate = path.join(dir, bin + ext);
41
+ try {
42
+ accessSync(candidate, constants.X_OK);
43
+ return candidate;
44
+ }
45
+ catch {
46
+ // not here
47
+ }
48
+ }
49
+ }
50
+ return undefined;
51
+ }
52
+ export async function resolveTool(name, deps) {
53
+ const entry = catalog[name];
54
+ if (entry.kind === 'npm')
55
+ return resolveNpm(name, entry.pkg);
56
+ const exe = findOnPath(entry.bin, deps.env);
57
+ if (!exe)
58
+ return undefined;
59
+ const result = spawnSync(exe, entry.versionArgs, { encoding: 'utf8', env: deps.env });
60
+ if (result.error || result.status !== 0)
61
+ return undefined;
62
+ const match = entry.versionPattern.exec(`${result.stdout}\n${result.stderr}`);
63
+ if (!match?.[1])
64
+ return undefined;
65
+ return { name, command: exe, prefixArgs: [], version: match[1] };
66
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ /**
6
+ * Copy these working-tree files (same relative paths) into a new temp directory.
7
+ * Tools that read their own config run there, so no repository config can change a score.
8
+ * A path missing from the working tree (tracked but deleted, or a broken symlink) is skipped.
9
+ */
10
+ export async function createWorkCopy(repoPath, relPaths) {
11
+ const dir = await mkdtemp(path.join(tmpdir(), 'health-work-'));
12
+ try {
13
+ for (const rel of relPaths) {
14
+ const src = path.join(repoPath, rel);
15
+ if (!existsSync(src))
16
+ continue;
17
+ const dest = path.join(dir, rel);
18
+ await mkdir(path.dirname(dest), { recursive: true });
19
+ await copyFile(src, dest);
20
+ }
21
+ }
22
+ catch (err) {
23
+ await rm(dir, { recursive: true, force: true });
24
+ throw err;
25
+ }
26
+ return { dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
27
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Minimal INI reader for mypy's `mypy.ini`, `.mypy.ini` and `setup.cfg`: sections, `key = value`
3
+ * and `key: value`, `;` and `#` comment lines, indented continuation lines, no interpolation.
4
+ * Keys before any section go to the section "".
5
+ */
6
+ export function parseIni(text) {
7
+ const sections = {};
8
+ let current = '';
9
+ let lastKey;
10
+ for (const rawLine of text.split(/\r\n|\r|\n/)) {
11
+ if (rawLine.trim() === '') {
12
+ lastKey = undefined;
13
+ continue;
14
+ }
15
+ if (/^\s*[;#]/.test(rawLine))
16
+ continue;
17
+ if (/^\s/.test(rawLine) && lastKey !== undefined) {
18
+ const section = (sections[current] ??= {});
19
+ section[lastKey] = `${section[lastKey] ?? ''}\n${rawLine.trim()}`;
20
+ continue;
21
+ }
22
+ const header = /^\s*\[([^\]]*)\]/.exec(rawLine);
23
+ if (header) {
24
+ current = (header[1] ?? '').trim();
25
+ sections[current] ??= {};
26
+ lastKey = undefined;
27
+ continue;
28
+ }
29
+ const pair = /^\s*([^=:]+?)\s*[=:]\s*(.*?)\s*$/.exec(rawLine);
30
+ if (pair?.[1] !== undefined) {
31
+ const section = (sections[current] ??= {});
32
+ lastKey = pair[1];
33
+ section[lastKey] = pair[2] ?? '';
34
+ }
35
+ }
36
+ return sections;
37
+ }