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,173 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import ignore from 'ignore';
|
|
6
|
+
import picomatch from 'picomatch';
|
|
7
|
+
import { analyzePythonFiles, pythonVersion } from '../python/scope.js';
|
|
8
|
+
import { detectPackages, packageOf } from './packages.js';
|
|
9
|
+
import { hasGeneratedMarker, looksMinified, physicalLines } from './rules.js';
|
|
10
|
+
import { analyzeTsFile, parseTsFile } from './ts-lines.js';
|
|
11
|
+
import { ToolError, } from './types.js';
|
|
12
|
+
export class NotAGitRepositoryError extends Error {
|
|
13
|
+
constructor(repoPath) {
|
|
14
|
+
super(`${repoPath} is not a git repository`);
|
|
15
|
+
this.name = 'NotAGitRepositoryError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function sha256(text) {
|
|
19
|
+
return createHash('sha256').update(text).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
function readIfPresent(file) {
|
|
22
|
+
try {
|
|
23
|
+
return readFileSync(file, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function trackedFiles(repoPath) {
|
|
30
|
+
const result = spawnSync('git', ['-C', repoPath, 'ls-files', '-z'], {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
33
|
+
});
|
|
34
|
+
if (result.error || result.status !== 0)
|
|
35
|
+
throw new NotAGitRepositoryError(repoPath);
|
|
36
|
+
return result.stdout.split('\0').filter((entry) => entry !== '');
|
|
37
|
+
}
|
|
38
|
+
function languageOf(file, methodology) {
|
|
39
|
+
const ext = path.posix.extname(file).toLowerCase();
|
|
40
|
+
const { extensions } = methodology.scope.source_files;
|
|
41
|
+
if (extensions.typescript.includes(ext))
|
|
42
|
+
return 'typescript';
|
|
43
|
+
if (extensions.javascript.includes(ext))
|
|
44
|
+
return 'javascript';
|
|
45
|
+
if (extensions.python.includes(ext))
|
|
46
|
+
return 'python';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function groupOf(language) {
|
|
50
|
+
return language === 'python' ? 'python' : 'typescript_javascript';
|
|
51
|
+
}
|
|
52
|
+
/** Glob entries only: scope.yaml mixes globs with prose rules, which are code in rules.ts. */
|
|
53
|
+
function globsOnly(entries) {
|
|
54
|
+
return entries.filter((entry) => !/\s/.test(entry));
|
|
55
|
+
}
|
|
56
|
+
export async function buildScope(repoPath, methodology) {
|
|
57
|
+
const cfg = methodology.scope;
|
|
58
|
+
const tracked = trackedFiles(repoPath);
|
|
59
|
+
const fixedGlobs = [
|
|
60
|
+
...cfg.exclusions.vendored,
|
|
61
|
+
...cfg.exclusions.build_output,
|
|
62
|
+
...cfg.exclusions.generated,
|
|
63
|
+
...cfg.exclusions.minified,
|
|
64
|
+
...cfg.exclusions.migrations,
|
|
65
|
+
...cfg.exclusions.declarations,
|
|
66
|
+
...cfg.exclusions.test_data,
|
|
67
|
+
].filter((entry) => !/\s/.test(entry));
|
|
68
|
+
const isFixedExcluded = picomatch(fixedGlobs, { dot: true });
|
|
69
|
+
const isTest = {
|
|
70
|
+
typescript_javascript: picomatch(globsOnly(cfg.file_roles.test.typescript_javascript), { dot: true }),
|
|
71
|
+
python: picomatch(globsOnly(cfg.file_roles.test.python), { dot: true }),
|
|
72
|
+
};
|
|
73
|
+
const isConfig = picomatch(globsOnly(cfg.file_roles.config), { dot: true });
|
|
74
|
+
const healthignoreText = readIfPresent(path.join(repoPath, '.healthignore'));
|
|
75
|
+
const healthignore = healthignoreText === null ? null : ignore().add(healthignoreText);
|
|
76
|
+
const gitleaksText = readIfPresent(path.join(repoPath, '.gitleaksignore'));
|
|
77
|
+
const afterFixed = tracked.filter((file) => !isFixedExcluded(file));
|
|
78
|
+
const packages = detectPackages(repoPath, afterFixed);
|
|
79
|
+
const warnings = [];
|
|
80
|
+
let ignoredLines = 0;
|
|
81
|
+
let sourceExtLines = 0;
|
|
82
|
+
const candidates = [];
|
|
83
|
+
for (const file of afterFixed) {
|
|
84
|
+
const language = languageOf(file, methodology);
|
|
85
|
+
if (!language)
|
|
86
|
+
continue;
|
|
87
|
+
const text = readIfPresent(path.join(repoPath, file));
|
|
88
|
+
if (text === null)
|
|
89
|
+
continue; // tracked but deleted in the working tree
|
|
90
|
+
const normalized = text.replace(/\r\n?/g, '\n');
|
|
91
|
+
if (hasGeneratedMarker(normalized) || looksMinified(normalized))
|
|
92
|
+
continue;
|
|
93
|
+
const lines = physicalLines(normalized);
|
|
94
|
+
sourceExtLines += lines;
|
|
95
|
+
if (healthignore?.ignores(file)) {
|
|
96
|
+
ignoredLines += lines;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
candidates.push({ file, language, text: normalized });
|
|
100
|
+
}
|
|
101
|
+
const pyFiles = candidates.filter((c) => c.language === 'python').map((c) => c.file);
|
|
102
|
+
const pyInfo = analyzePythonFiles(repoPath, pyFiles);
|
|
103
|
+
const pyVersion = pyFiles.length > 0 ? pythonVersion() : undefined;
|
|
104
|
+
const tsCache = new Map();
|
|
105
|
+
const textByPath = new Map(candidates.map((c) => [c.file, c.text]));
|
|
106
|
+
const files = [];
|
|
107
|
+
for (const { file, language, text } of candidates) {
|
|
108
|
+
const group = groupOf(language);
|
|
109
|
+
const base = { path: file, language, group, package: packageOf(file, packages) };
|
|
110
|
+
let role;
|
|
111
|
+
let codeLines;
|
|
112
|
+
if (isTest[group](file)) {
|
|
113
|
+
role = 'test';
|
|
114
|
+
}
|
|
115
|
+
else if (isConfig(file)) {
|
|
116
|
+
role = 'config';
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
role = null; // decided below from whether the file defines anything callable
|
|
120
|
+
}
|
|
121
|
+
if (group === 'python') {
|
|
122
|
+
const info = pyInfo[file];
|
|
123
|
+
if (!info)
|
|
124
|
+
throw new ToolError(`scope_helper.py returned nothing for ${file}`);
|
|
125
|
+
if (!info.parsed)
|
|
126
|
+
warnings.push(`Python file ${file} did not parse; counted non-blank lines not starting with #`);
|
|
127
|
+
codeLines = info.lines;
|
|
128
|
+
if (role === null)
|
|
129
|
+
role = info.hasDef ? 'logic' : null;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const info = analyzeTsFile(file, text);
|
|
133
|
+
tsCache.set(file, info.sourceFile);
|
|
134
|
+
codeLines = info.codeLines;
|
|
135
|
+
if (role === null)
|
|
136
|
+
role = info.definesCallable ? 'logic' : null;
|
|
137
|
+
}
|
|
138
|
+
if (role === null)
|
|
139
|
+
continue; // barrels, type-only and constant-only files are not logic files
|
|
140
|
+
files.push({
|
|
141
|
+
...base,
|
|
142
|
+
role,
|
|
143
|
+
logicLines: role === 'logic' ? codeLines : 0,
|
|
144
|
+
sourceLines: role === 'logic' || role === 'test' ? codeLines : 0,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
148
|
+
const logic = files.filter((f) => f.role === 'logic');
|
|
149
|
+
const usedPackages = [...new Set(files.map((f) => f.package))].sort();
|
|
150
|
+
return {
|
|
151
|
+
files,
|
|
152
|
+
excludedLinesPercent: sourceExtLines === 0 ? 0 : (ignoredLines / sourceExtLines) * 100,
|
|
153
|
+
packages: usedPackages,
|
|
154
|
+
logicFiles: logic.length,
|
|
155
|
+
logicLines: logic.reduce((sum, f) => sum + f.logicLines, 0),
|
|
156
|
+
sourceLines: files.reduce((sum, f) => sum + f.sourceLines, 0),
|
|
157
|
+
warnings,
|
|
158
|
+
healthignoreSha256: healthignoreText === null ? null : sha256(healthignoreText),
|
|
159
|
+
gitleaksignoreSha256: gitleaksText === null ? null : sha256(gitleaksText),
|
|
160
|
+
...(pyVersion === undefined ? {} : { pythonVersion: pyVersion }),
|
|
161
|
+
getTsSourceFile(file) {
|
|
162
|
+
const cached = tsCache.get(file);
|
|
163
|
+
if (cached)
|
|
164
|
+
return cached;
|
|
165
|
+
const text = textByPath.get(file);
|
|
166
|
+
if (text === undefined)
|
|
167
|
+
throw new Error(`${file} is not an in-scope TypeScript or JavaScript file`);
|
|
168
|
+
const parsed = parseTsFile(file, text);
|
|
169
|
+
tsCache.set(file, parsed);
|
|
170
|
+
return parsed;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import picomatch from 'picomatch';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
function readIfPresent(file) {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(file, 'utf8');
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function workspacePatterns(repoPath) {
|
|
14
|
+
const patterns = [];
|
|
15
|
+
const pkg = readIfPresent(path.join(repoPath, 'package.json'));
|
|
16
|
+
if (pkg !== null) {
|
|
17
|
+
try {
|
|
18
|
+
const json = JSON.parse(pkg);
|
|
19
|
+
const ws = json.workspaces;
|
|
20
|
+
if (Array.isArray(ws))
|
|
21
|
+
patterns.push(...ws.filter((w) => typeof w === 'string'));
|
|
22
|
+
else if (ws && typeof ws === 'object' && 'packages' in ws && Array.isArray(ws.packages)) {
|
|
23
|
+
patterns.push(...ws.packages.filter((w) => typeof w === 'string'));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// A malformed package.json declares no workspaces.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const pnpm = readIfPresent(path.join(repoPath, 'pnpm-workspace.yaml'));
|
|
31
|
+
if (pnpm !== null) {
|
|
32
|
+
try {
|
|
33
|
+
const doc = parse(pnpm);
|
|
34
|
+
if (doc && Array.isArray(doc.packages)) {
|
|
35
|
+
patterns.push(...doc.packages.filter((w) => typeof w === 'string'));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Ignore a malformed pnpm-workspace.yaml.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const pyproject = readIfPresent(path.join(repoPath, 'pyproject.toml'));
|
|
43
|
+
if (pyproject !== null) {
|
|
44
|
+
const section = /\[tool\.uv\.workspace\]([\s\S]*?)(?:\n\[|$)/.exec(pyproject);
|
|
45
|
+
const members = section?.[1] ? /members\s*=\s*\[([\s\S]*?)\]/.exec(section[1]) : null;
|
|
46
|
+
if (members?.[1]) {
|
|
47
|
+
for (const match of members[1].matchAll(/["']([^"']+)["']/g)) {
|
|
48
|
+
if (match[1])
|
|
49
|
+
patterns.push(match[1]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return patterns;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Workspace package directories (repo-relative posix, never "."), found from
|
|
57
|
+
* workspace declarations and from directories that have their own pyproject.toml.
|
|
58
|
+
*/
|
|
59
|
+
export function detectPackages(repoPath, trackedFiles) {
|
|
60
|
+
const patterns = workspacePatterns(repoPath).map((p) => p.replace(/^\.\//, '').replace(/\/+$/, ''));
|
|
61
|
+
const positive = patterns.filter((p) => !p.startsWith('!'));
|
|
62
|
+
const negative = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
|
|
63
|
+
const matchesDeclared = positive.length > 0 ? picomatch(positive, { dot: true, ignore: negative }) : null;
|
|
64
|
+
const found = new Set();
|
|
65
|
+
for (const file of trackedFiles) {
|
|
66
|
+
const base = path.posix.basename(file);
|
|
67
|
+
if (base !== 'package.json' && base !== 'pyproject.toml')
|
|
68
|
+
continue;
|
|
69
|
+
const dir = path.posix.dirname(file);
|
|
70
|
+
if (dir === '.')
|
|
71
|
+
continue;
|
|
72
|
+
if (base === 'pyproject.toml')
|
|
73
|
+
found.add(dir);
|
|
74
|
+
else if (matchesDeclared?.(dir))
|
|
75
|
+
found.add(dir);
|
|
76
|
+
}
|
|
77
|
+
return [...found].sort();
|
|
78
|
+
}
|
|
79
|
+
/** Deepest package directory containing the file, else ".". */
|
|
80
|
+
export function packageOf(file, packages) {
|
|
81
|
+
let best = '.';
|
|
82
|
+
for (const pkg of packages) {
|
|
83
|
+
if (file.startsWith(`${pkg}/`) && (best === '.' || pkg.length > best.length))
|
|
84
|
+
best = pkg;
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// The only hard-coded rule constants: the prose exclusions in scope.yaml and the
|
|
2
|
+
// "defines a function, method or class" test.
|
|
3
|
+
const GENERATED_MARKERS = ['@generated', 'Code generated'];
|
|
4
|
+
const GENERATED_HEAD_LINES = 5;
|
|
5
|
+
const MINIFIED_AVG_LINE_LENGTH = 300;
|
|
6
|
+
export function hasGeneratedMarker(text) {
|
|
7
|
+
const head = text.split('\n', GENERATED_HEAD_LINES).join('\n');
|
|
8
|
+
return GENERATED_MARKERS.some((marker) => head.includes(marker));
|
|
9
|
+
}
|
|
10
|
+
export function looksMinified(text) {
|
|
11
|
+
if (text.length === 0)
|
|
12
|
+
return false;
|
|
13
|
+
const lines = text.split('\n').length;
|
|
14
|
+
return text.length / lines > MINIFIED_AVG_LINE_LENGTH;
|
|
15
|
+
}
|
|
16
|
+
export function physicalLines(text) {
|
|
17
|
+
if (text.length === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const parts = text.split('\n');
|
|
20
|
+
return text.endsWith('\n') ? parts.length - 1 : parts.length;
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const cache = new Map();
|
|
6
|
+
/** `git ls-files -z`, posix, before the fixed exclusions and `.healthignore`. Memoized per repository. */
|
|
7
|
+
export function trackedPaths(repoPath) {
|
|
8
|
+
const key = path.resolve(repoPath);
|
|
9
|
+
const cached = cache.get(key);
|
|
10
|
+
if (cached)
|
|
11
|
+
return cached;
|
|
12
|
+
const result = spawnSync('git', ['-C', key, 'ls-files', '-z'], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
13
|
+
const paths = result.error || result.status !== 0 ? [] : result.stdout.split('\0').filter((entry) => entry !== '');
|
|
14
|
+
cache.set(key, paths);
|
|
15
|
+
return paths;
|
|
16
|
+
}
|
|
17
|
+
export function resetTrackedCache() {
|
|
18
|
+
cache.clear();
|
|
19
|
+
}
|
|
20
|
+
/** UTF-8 text from the working tree; null when the path is not tracked, is missing or is over 2 MB. */
|
|
21
|
+
export function readTracked(repoPath, rel) {
|
|
22
|
+
const normalized = path.posix.normalize(rel.split(path.sep).join('/'));
|
|
23
|
+
if (!trackedPaths(repoPath).includes(normalized))
|
|
24
|
+
return null;
|
|
25
|
+
const file = path.join(path.resolve(repoPath), normalized);
|
|
26
|
+
try {
|
|
27
|
+
if (statSync(file).size > MAX_BYTES)
|
|
28
|
+
return null;
|
|
29
|
+
return readFileSync(file, 'utf8');
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
const CALLABLE_KINDS = new Set([
|
|
3
|
+
ts.SyntaxKind.FunctionDeclaration,
|
|
4
|
+
ts.SyntaxKind.FunctionExpression,
|
|
5
|
+
ts.SyntaxKind.ArrowFunction,
|
|
6
|
+
ts.SyntaxKind.MethodDeclaration,
|
|
7
|
+
ts.SyntaxKind.Constructor,
|
|
8
|
+
ts.SyntaxKind.GetAccessor,
|
|
9
|
+
ts.SyntaxKind.SetAccessor,
|
|
10
|
+
ts.SyntaxKind.ClassDeclaration,
|
|
11
|
+
ts.SyntaxKind.ClassExpression,
|
|
12
|
+
]);
|
|
13
|
+
function scriptKindFor(file) {
|
|
14
|
+
const ext = file.slice(file.lastIndexOf('.')).toLowerCase();
|
|
15
|
+
if (ext === '.tsx')
|
|
16
|
+
return ts.ScriptKind.TSX;
|
|
17
|
+
if (ext === '.jsx')
|
|
18
|
+
return ts.ScriptKind.JSX;
|
|
19
|
+
if (ext === '.js' || ext === '.mjs' || ext === '.cjs')
|
|
20
|
+
return ts.ScriptKind.JS;
|
|
21
|
+
return ts.ScriptKind.TS;
|
|
22
|
+
}
|
|
23
|
+
export function parseTsFile(file, text) {
|
|
24
|
+
return ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, scriptKindFor(file));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Classify lines using the token stream: a line is code when a token (not trivia)
|
|
28
|
+
* touches it. Multi-line template literals and JSX text are tokens, so their lines
|
|
29
|
+
* are code. Comments, JSDoc and shebangs are trivia.
|
|
30
|
+
*/
|
|
31
|
+
export function analyzeTsFile(file, text) {
|
|
32
|
+
const sourceFile = parseTsFile(file, text);
|
|
33
|
+
const lineHasToken = new Set();
|
|
34
|
+
let definesCallable = false;
|
|
35
|
+
const visit = (node) => {
|
|
36
|
+
if (node.kind >= ts.SyntaxKind.FirstJSDocNode && node.kind <= ts.SyntaxKind.LastJSDocNode)
|
|
37
|
+
return;
|
|
38
|
+
if (CALLABLE_KINDS.has(node.kind))
|
|
39
|
+
definesCallable = true;
|
|
40
|
+
const children = node.getChildren(sourceFile);
|
|
41
|
+
if (children.length === 0) {
|
|
42
|
+
const start = node.getStart(sourceFile);
|
|
43
|
+
const end = node.getEnd();
|
|
44
|
+
if (end > start) {
|
|
45
|
+
const first = sourceFile.getLineAndCharacterOfPosition(start).line;
|
|
46
|
+
const last = sourceFile.getLineAndCharacterOfPosition(end - 1).line;
|
|
47
|
+
for (let line = first; line <= last; line += 1)
|
|
48
|
+
lineHasToken.add(line);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const child of children)
|
|
53
|
+
visit(child);
|
|
54
|
+
};
|
|
55
|
+
visit(sourceFile);
|
|
56
|
+
const lines = text.split('\n');
|
|
57
|
+
const codeLineNumbers = new Set();
|
|
58
|
+
for (const line of lineHasToken) {
|
|
59
|
+
if ((lines[line] ?? '').trim() !== '')
|
|
60
|
+
codeLineNumbers.add(line + 1);
|
|
61
|
+
}
|
|
62
|
+
return { sourceFile, definesCallable, codeLines: codeLineNumbers.size, codeLineNumbers };
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Weights after not-applicable and not-measured weight is spread in proportion. Null-score entries get 0. */
|
|
2
|
+
export function effectiveWeights(params) {
|
|
3
|
+
const live = params.reduce((sum, p) => (p.score === null ? sum : sum + p.weight), 0);
|
|
4
|
+
const total = params.reduce((sum, p) => sum + p.weight, 0);
|
|
5
|
+
return params.map((p) => (p.score === null || live === 0 ? 0 : (p.weight / live) * total));
|
|
6
|
+
}
|
|
7
|
+
/** Weighted mean of the scored parameters. Null when none is scored. */
|
|
8
|
+
export function dimensionScore(params) {
|
|
9
|
+
let weighted = 0;
|
|
10
|
+
let weights = 0;
|
|
11
|
+
for (const p of params) {
|
|
12
|
+
if (p.score === null)
|
|
13
|
+
continue;
|
|
14
|
+
weighted += p.score * p.weight;
|
|
15
|
+
weights += p.weight;
|
|
16
|
+
}
|
|
17
|
+
return weights === 0 ? null : weighted / weights;
|
|
18
|
+
}
|
|
19
|
+
/** health = min(mean, min + capMargin) over the scored dimensions. Null when none is scored. */
|
|
20
|
+
export function healthScore(dimensionScores, capMargin) {
|
|
21
|
+
const scores = dimensionScores.filter((s) => s !== null);
|
|
22
|
+
if (scores.length === 0)
|
|
23
|
+
return null;
|
|
24
|
+
const mean = scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
25
|
+
return Math.min(mean, Math.min(...scores) + capMargin);
|
|
26
|
+
}
|
|
27
|
+
/** Round half up to `digits` decimals. The tolerance makes a value written as x.x5 round as written; negative zero becomes 0. */
|
|
28
|
+
export function roundHalfUp(value, digits = 0) {
|
|
29
|
+
const factor = 10 ** digits;
|
|
30
|
+
const rounded = Math.floor(value * factor + 0.5 + 1e-9) / factor;
|
|
31
|
+
return rounded === 0 ? 0 : rounded;
|
|
32
|
+
}
|
|
33
|
+
export function bandFor(displayedHealth, bands) {
|
|
34
|
+
const band = bands.find((b) => displayedHealth >= b.min && displayedHealth <= b.max);
|
|
35
|
+
if (!band)
|
|
36
|
+
throw new Error(`No band for health ${displayedHealth}`);
|
|
37
|
+
return band;
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { scoreRaw } from './score.js';
|
|
2
|
+
/**
|
|
3
|
+
* Combine the units of one parameter into one raw value and score.
|
|
4
|
+
* ratio: sum numerators and denominators, score once. Units with a failed raw (their install root's install
|
|
5
|
+
* failed) take no part in the sums; the score is multiplied by the share of the logic lines that is not failed,
|
|
6
|
+
* so a broken package costs its share and cannot be hidden.
|
|
7
|
+
* unit_weighted: score each unit, combine by weight. A failed unit scores 0.
|
|
8
|
+
*/
|
|
9
|
+
export function poolParameter(def, units) {
|
|
10
|
+
const scored = units.map((unit) => ({
|
|
11
|
+
...unit,
|
|
12
|
+
score: scoreRaw(def, unit.raw, unit.group),
|
|
13
|
+
}));
|
|
14
|
+
if (def.pooling === 'ratio') {
|
|
15
|
+
const live = units.filter((unit) => unit.raw.kind !== 'failed');
|
|
16
|
+
const pooled = poolRatio(def, live, scored);
|
|
17
|
+
if (live.length === units.length)
|
|
18
|
+
return pooled;
|
|
19
|
+
return { ...pooled, score: pooled.score * liveShare(live, units) };
|
|
20
|
+
}
|
|
21
|
+
const totalWeight = scored.reduce((sum, unit) => sum + unit.weight, 0);
|
|
22
|
+
if (totalWeight === 0)
|
|
23
|
+
return { raw: null, score: def.raw.when_empty, units: scored };
|
|
24
|
+
const score = scored.reduce((sum, unit) => sum + unit.score * unit.weight, 0) / totalWeight;
|
|
25
|
+
return { raw: null, score, units: scored };
|
|
26
|
+
}
|
|
27
|
+
/** Share of the logic lines (of the units, when none has a line) that belongs to `live`. */
|
|
28
|
+
function liveShare(live, all) {
|
|
29
|
+
const total = all.reduce((sum, unit) => sum + unit.weight, 0);
|
|
30
|
+
if (total === 0)
|
|
31
|
+
return live.length / all.length;
|
|
32
|
+
return live.reduce((sum, unit) => sum + unit.weight, 0) / total;
|
|
33
|
+
}
|
|
34
|
+
function poolRatio(def, units, scored) {
|
|
35
|
+
let numerator = 0;
|
|
36
|
+
let denominator = 0;
|
|
37
|
+
let scale = null;
|
|
38
|
+
let summed = null;
|
|
39
|
+
for (const unit of units) {
|
|
40
|
+
// number raws (5.1 severity points) are summed, their denominator is ignored.
|
|
41
|
+
if (unit.raw.kind === 'number') {
|
|
42
|
+
summed = (summed ?? 0) + unit.raw.value;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (unit.raw.kind !== 'ratio')
|
|
46
|
+
throw new Error(`${def.id}: ratio pooling needs ratio raws`);
|
|
47
|
+
if (scale !== null && scale !== unit.raw.scale) {
|
|
48
|
+
throw new Error(`${def.id}: units disagree on ratio scale`);
|
|
49
|
+
}
|
|
50
|
+
scale = unit.raw.scale;
|
|
51
|
+
numerator += unit.raw.numerator;
|
|
52
|
+
denominator += unit.raw.denominator;
|
|
53
|
+
}
|
|
54
|
+
if (summed !== null) {
|
|
55
|
+
if (scale !== null)
|
|
56
|
+
throw new Error(`${def.id}: ratio pooling cannot mix number and ratio raws`);
|
|
57
|
+
const raw = { kind: 'number', value: summed };
|
|
58
|
+
return { raw, score: scoreRaw(def, raw), units: scored };
|
|
59
|
+
}
|
|
60
|
+
if (scale === null || denominator === 0) {
|
|
61
|
+
return { raw: null, score: def.raw.when_empty, units: scored };
|
|
62
|
+
}
|
|
63
|
+
const raw = { kind: 'ratio', numerator, denominator, scale };
|
|
64
|
+
return { raw, score: scoreRaw(def, raw), units: scored };
|
|
65
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
function clamp(value, lo, hi) {
|
|
2
|
+
return Math.min(hi, Math.max(lo, value));
|
|
3
|
+
}
|
|
4
|
+
/** The numeric value of a ratio or number raw. A ratio with no denominator has no value. */
|
|
5
|
+
function rawValue(raw) {
|
|
6
|
+
if (raw.kind === 'number')
|
|
7
|
+
return raw.value;
|
|
8
|
+
if (raw.kind === 'ratio') {
|
|
9
|
+
return raw.denominator === 0 ? null : (raw.numerator / raw.denominator) * raw.scale;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`Raw of kind ${raw.kind} has no numeric value`);
|
|
12
|
+
}
|
|
13
|
+
function piecewiseLinear(anchors, value) {
|
|
14
|
+
const sorted = [...anchors].sort((a, b) => a.raw - b.raw);
|
|
15
|
+
const first = sorted[0];
|
|
16
|
+
const last = sorted[sorted.length - 1];
|
|
17
|
+
if (!first || !last)
|
|
18
|
+
throw new Error('piecewise_linear needs anchors');
|
|
19
|
+
if (value <= first.raw)
|
|
20
|
+
return first.score;
|
|
21
|
+
if (value >= last.raw)
|
|
22
|
+
return last.score;
|
|
23
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
24
|
+
const lo = sorted[i - 1];
|
|
25
|
+
const hi = sorted[i];
|
|
26
|
+
if (lo && hi && value <= hi.raw) {
|
|
27
|
+
return lo.score + ((value - lo.raw) / (hi.raw - lo.raw)) * (hi.score - lo.score);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return last.score;
|
|
31
|
+
}
|
|
32
|
+
function levelScore(levels, level) {
|
|
33
|
+
const found = levels.find((entry) => entry.level === level);
|
|
34
|
+
if (!found)
|
|
35
|
+
throw new Error(`Unknown level "${level}"`);
|
|
36
|
+
return found.score;
|
|
37
|
+
}
|
|
38
|
+
/** Score one raw value. A ratio with no denominator scores `raw.when_empty`. */
|
|
39
|
+
export function scoreRaw(def, raw, group) {
|
|
40
|
+
if (raw.kind === 'failed')
|
|
41
|
+
return 0;
|
|
42
|
+
const scoring = def.scoring;
|
|
43
|
+
switch (scoring.method) {
|
|
44
|
+
case 'piecewise_linear': {
|
|
45
|
+
const value = rawValue(raw);
|
|
46
|
+
return value === null ? def.raw.when_empty : piecewiseLinear(scoring.anchors, value);
|
|
47
|
+
}
|
|
48
|
+
case 'levels': {
|
|
49
|
+
if (raw.kind !== 'level')
|
|
50
|
+
throw new Error(`${def.id}: levels scoring needs a level raw`);
|
|
51
|
+
if (Array.isArray(scoring.levels))
|
|
52
|
+
return levelScore(scoring.levels, raw.level);
|
|
53
|
+
if (!group)
|
|
54
|
+
throw new Error(`${def.id}: a language group is needed to score this level`);
|
|
55
|
+
const forGroup = scoring.levels[group];
|
|
56
|
+
if (!forGroup)
|
|
57
|
+
throw new Error(`${def.id}: no levels for group ${group}`);
|
|
58
|
+
return levelScore(forGroup, raw.level);
|
|
59
|
+
}
|
|
60
|
+
case 'mean_of_components': {
|
|
61
|
+
if (raw.kind !== 'components')
|
|
62
|
+
throw new Error(`${def.id}: needs a components raw`);
|
|
63
|
+
const scores = scoring.components.map((component) => {
|
|
64
|
+
const part = raw.components[component.key];
|
|
65
|
+
if (!part)
|
|
66
|
+
throw new Error(`${def.id}: missing component ${component.key}`);
|
|
67
|
+
if (component.method === 'levels') {
|
|
68
|
+
if (part.kind !== 'level' || !component.levels) {
|
|
69
|
+
throw new Error(`${def.id}.${component.key}: needs a level raw`);
|
|
70
|
+
}
|
|
71
|
+
return levelScore(component.levels, part.level);
|
|
72
|
+
}
|
|
73
|
+
const value = rawValue(part);
|
|
74
|
+
if (value === null)
|
|
75
|
+
return part.kind === 'ratio' ? (part.emptyScore ?? def.raw.when_empty) : def.raw.when_empty;
|
|
76
|
+
return clamp(value, 0, 100);
|
|
77
|
+
});
|
|
78
|
+
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { dimensionScore, healthScore } from './aggregate.js';
|
|
2
|
+
function weighted(def, param, overrides) {
|
|
3
|
+
const override = overrides.get(def.id);
|
|
4
|
+
if (override !== undefined)
|
|
5
|
+
return { weight: def.weight, score: override };
|
|
6
|
+
if (param?.status === 'failed')
|
|
7
|
+
return { weight: def.weight, score: 0 };
|
|
8
|
+
if (param?.status === 'measured')
|
|
9
|
+
return { weight: def.weight, score: param.score ?? 0 };
|
|
10
|
+
return { weight: def.weight, score: null };
|
|
11
|
+
}
|
|
12
|
+
/** The unrounded health and dimension scores if the overridden parameters had the given scores. Reads the stored parameters only. */
|
|
13
|
+
export function healthWith(run, methodology, overrides) {
|
|
14
|
+
const dimension = new Map();
|
|
15
|
+
for (const dim of methodology.dimensions) {
|
|
16
|
+
dimension.set(dim.id, dimensionScore(dim.parameters.map((def) => weighted(def, run.parameters.find((p) => p.id === def.id), overrides))));
|
|
17
|
+
}
|
|
18
|
+
return { health: healthScore([...dimension.values()], methodology.capMargin), dimension };
|
|
19
|
+
}
|
|
20
|
+
/** The lowest-scoring anchor or level above `score` that has a descriptor. Null when there is none or the parameter has no descriptors. */
|
|
21
|
+
export function nextCandidate(def, score) {
|
|
22
|
+
const scoring = def.scoring;
|
|
23
|
+
let candidates;
|
|
24
|
+
if (scoring.method === 'piecewise_linear')
|
|
25
|
+
candidates = scoring.anchors;
|
|
26
|
+
else if (scoring.method === 'levels')
|
|
27
|
+
candidates = Array.isArray(scoring.levels) ? scoring.levels : Object.values(scoring.levels).flat();
|
|
28
|
+
else
|
|
29
|
+
return null;
|
|
30
|
+
let best;
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
if (!candidate.descriptor || candidate.score <= score + 1e-9)
|
|
33
|
+
continue;
|
|
34
|
+
if (!best || candidate.score < best.score)
|
|
35
|
+
best = { score: candidate.score, descriptor: candidate.descriptor };
|
|
36
|
+
}
|
|
37
|
+
return best ? { score: best.score, descriptor: best.descriptor.replace(/\s+/g, ' ').trim() } : null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const INFO_FLAGS = new Set(['--help', '--version', '-h', '-V']);
|
|
2
|
+
const TSC_NON_CHECKS = new Set(['--init', '--help', '--version', '-v', '-h']);
|
|
3
|
+
const BIOME_CHECKS = new Set(['check', 'lint', 'ci']);
|
|
4
|
+
/** Tools that run checks the methodology does not recognise (a finding on 6.2). */
|
|
5
|
+
export const UNRECOGNISED_RUNNERS = new Set(['turbo', 'nx', 'lerna', 'next', 'ng', 'react-scripts']);
|
|
6
|
+
/** `python -m unittest` is the tool `unittest` once its prefix is removed; `node --test` is the tool `node` with that flag. */
|
|
7
|
+
function testToolNames(rec) {
|
|
8
|
+
const tools = new Set();
|
|
9
|
+
let nodeTest = false;
|
|
10
|
+
for (const entry of rec.testRunners) {
|
|
11
|
+
if (entry === 'node --test')
|
|
12
|
+
nodeTest = true;
|
|
13
|
+
else
|
|
14
|
+
tools.add(entry.replace(/^python3? -m /, ''));
|
|
15
|
+
}
|
|
16
|
+
return { tools, nodeTest };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The checks a command runs, by tool, after prefix removal. A check is recognised by tool, not by language group, so `tsc` counts
|
|
20
|
+
* for a Python unit in a mixed package and `mypy` for a TypeScript one.
|
|
21
|
+
*/
|
|
22
|
+
export function checkKinds(cmd, rec) {
|
|
23
|
+
const out = new Set();
|
|
24
|
+
const tool = cmd.tool;
|
|
25
|
+
if (tool === null)
|
|
26
|
+
return out;
|
|
27
|
+
if (cmd.argv.some((a) => INFO_FLAGS.has(a)))
|
|
28
|
+
return out;
|
|
29
|
+
const first = cmd.argv.find((a) => !a.startsWith('-'));
|
|
30
|
+
if (rec.typecheckers.includes(tool)) {
|
|
31
|
+
if (tool === 'tsc' && cmd.argv.some((a) => TSC_NON_CHECKS.has(a)))
|
|
32
|
+
return out;
|
|
33
|
+
out.add('typecheck');
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
if (rec.linters.includes(tool)) {
|
|
37
|
+
if (tool === 'ruff' && first === 'format')
|
|
38
|
+
return out;
|
|
39
|
+
if (tool === 'biome' && !(first !== undefined && BIOME_CHECKS.has(first)))
|
|
40
|
+
return out;
|
|
41
|
+
out.add('lint');
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
const { tools, nodeTest } = testToolNames(rec);
|
|
45
|
+
if (tool === 'tox' || tool === 'nox') {
|
|
46
|
+
if (tools.has(tool) && cmd.readable !== true)
|
|
47
|
+
out.add('test');
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
if (tools.has(tool) || (nodeTest && tool === 'node' && cmd.argv.includes('--test')))
|
|
51
|
+
out.add('test');
|
|
52
|
+
return out;
|
|
53
|
+
}
|