vibecodedshit 0.0.0-stage → 0.2.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 +64 -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 +8 -0
- package/dist/card/previous.js +38 -0
- package/dist/card/render.js +71 -0
- package/dist/cli.js +121 -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 +25 -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/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 +9 -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
package/dist/run/scan.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import ts from 'typescript';
|
|
5
|
+
import { createDockerRunner, imageDigest } from '../exec/docker.js';
|
|
6
|
+
import { runExecution } from '../exec/run.js';
|
|
7
|
+
import { executionRoots, failedRootDirs } from '../exec/roots.js';
|
|
8
|
+
import { feedbackLoop } from '../measure/p6-3.js';
|
|
9
|
+
import { measurers as allMeasurers } from '../measure/registry.js';
|
|
10
|
+
import { computeHotspots } from '../report-only/hotspots.js';
|
|
11
|
+
import { loadMethodology, packageRoot } from '../methodology/load.js';
|
|
12
|
+
import { buildScope } from '../scope/build.js';
|
|
13
|
+
import { poolParameter } from '../scoring/pool.js';
|
|
14
|
+
import { createImageToolKit } from '../tools/image.js';
|
|
15
|
+
import { createToolKit } from '../tools/kit.js';
|
|
16
|
+
import { breakdownFor, dimensionScores, healthFrom } from './score-run.js';
|
|
17
|
+
export class NoLogicFilesError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super('The repository has no logic file, so it is not scored');
|
|
20
|
+
this.name = 'NoLogicFilesError';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function git(repoPath, args) {
|
|
24
|
+
const result = spawnSync('git', ['-C', repoPath, ...args], { encoding: 'utf8' });
|
|
25
|
+
return result.error || result.status !== 0 ? null : result.stdout.trim();
|
|
26
|
+
}
|
|
27
|
+
function healthVersion() {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
|
|
29
|
+
return pkg.version;
|
|
30
|
+
}
|
|
31
|
+
function baseRun(opts) {
|
|
32
|
+
const repoPath = path.resolve(opts.repoPath);
|
|
33
|
+
const gitVersion = spawnSync('git', ['--version'], { encoding: 'utf8' }).stdout.trim().replace(/^git version /, '');
|
|
34
|
+
return {
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
valid: true,
|
|
37
|
+
warnings: [],
|
|
38
|
+
methodology: { version: opts.methodologyVersion },
|
|
39
|
+
mode: opts.mode,
|
|
40
|
+
comparable: false,
|
|
41
|
+
comparableReason: 'preview, not comparable',
|
|
42
|
+
asOf: opts.asOf,
|
|
43
|
+
layers: ['static'],
|
|
44
|
+
repo: {
|
|
45
|
+
path: repoPath,
|
|
46
|
+
headCommit: git(repoPath, ['rev-parse', '--verify', '-q', 'HEAD']),
|
|
47
|
+
treeHash: git(repoPath, ['rev-parse', '--verify', '-q', 'HEAD^{tree}']),
|
|
48
|
+
},
|
|
49
|
+
tools: { health: healthVersion(), node: process.version, typescript: ts.version, git: gitVersion },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** A run for a tool error: marked invalid, with no scores and no health. */
|
|
53
|
+
export function invalidRun(opts, reason) {
|
|
54
|
+
return {
|
|
55
|
+
...baseRun(opts),
|
|
56
|
+
valid: false,
|
|
57
|
+
invalidReason: reason,
|
|
58
|
+
ignoreSet: { healthignoreSha256: null, gitleaksignoreSha256: null, excludedLinesPercent: 0 },
|
|
59
|
+
scope: { logicFiles: 0, logicLines: 0, sourceLines: 0, packages: [] },
|
|
60
|
+
parameters: [],
|
|
61
|
+
dimensions: [],
|
|
62
|
+
breakdown: { byPackage: {}, byGroup: {} },
|
|
63
|
+
health: null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export async function runScan(opts, preloaded) {
|
|
67
|
+
const methodology = preloaded ?? loadMethodology(opts.methodologyVersion, opts.rootDir);
|
|
68
|
+
const repoPath = path.resolve(opts.repoPath);
|
|
69
|
+
const scope = await buildScope(repoPath, methodology);
|
|
70
|
+
if (scope.logicFiles === 0)
|
|
71
|
+
throw new NoLogicFilesError();
|
|
72
|
+
let exec;
|
|
73
|
+
let cleanup;
|
|
74
|
+
if (opts.mode === 'full') {
|
|
75
|
+
const tag = methodology.execution.runner.image;
|
|
76
|
+
({ results: exec, cleanup } = await runExecution({
|
|
77
|
+
repoPath,
|
|
78
|
+
methodology,
|
|
79
|
+
scope,
|
|
80
|
+
runner: opts.execRunner ?? createDockerRunner(methodology.execution.runner, opts.dockerSpawn),
|
|
81
|
+
image: { tag, digest: opts.imageDigest ?? (await imageDigest(tag, opts.dockerSpawn)) },
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return await measureAll(opts, methodology, repoPath, scope, exec);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await cleanup?.();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Every measurer, then scoring. The execution work copy (`exec.workDir`) still exists while this runs. */
|
|
92
|
+
async function measureAll(opts, methodology, repoPath, scope, exec) {
|
|
93
|
+
const tools = opts.tools
|
|
94
|
+
?? (opts.mode === 'full'
|
|
95
|
+
? createImageToolKit({ runner: methodology.execution.runner, repoPath, ...(opts.dockerSpawn ? { spawn: opts.dockerSpawn } : {}) })
|
|
96
|
+
: createToolKit());
|
|
97
|
+
const warnings = [...scope.warnings];
|
|
98
|
+
const reportOnly = {};
|
|
99
|
+
const parameters = [];
|
|
100
|
+
for (const dimension of methodology.dimensions) {
|
|
101
|
+
for (const def of dimension.parameters) {
|
|
102
|
+
if (def.layer === 'execution' && opts.mode === 'preview') {
|
|
103
|
+
parameters.push({
|
|
104
|
+
id: def.id,
|
|
105
|
+
status: 'not_measured',
|
|
106
|
+
reason: 'execution layer, not run in preview',
|
|
107
|
+
weight: def.weight,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const measurer = (opts.measurers ?? allMeasurers).find((m) => m.id === def.id);
|
|
112
|
+
if (!measurer) {
|
|
113
|
+
parameters.push({ id: def.id, status: 'not_measured', reason: 'not implemented yet', weight: def.weight });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const measurement = await measurer.measure({
|
|
117
|
+
methodology,
|
|
118
|
+
param: def,
|
|
119
|
+
scope,
|
|
120
|
+
repoPath,
|
|
121
|
+
asOf: opts.asOf,
|
|
122
|
+
mode: opts.mode,
|
|
123
|
+
tools,
|
|
124
|
+
...(exec ? { exec } : {}),
|
|
125
|
+
});
|
|
126
|
+
if (measurement.status === 'measured') {
|
|
127
|
+
Object.assign(reportOnly, measurement.reportOnly);
|
|
128
|
+
warnings.push(...(measurement.warnings ?? []));
|
|
129
|
+
const pooled = poolParameter(def, measurement.units);
|
|
130
|
+
const failedDirs = exec ? failedRootDirs(exec, scope, measurement.units) : [];
|
|
131
|
+
parameters.push({
|
|
132
|
+
id: def.id,
|
|
133
|
+
status: 'measured',
|
|
134
|
+
...(failedDirs.length > 0 ? { reason: `install failed in ${failedDirs.join(', ')}` } : {}),
|
|
135
|
+
weight: def.weight,
|
|
136
|
+
raw: pooled.raw,
|
|
137
|
+
score: pooled.score,
|
|
138
|
+
units: pooled.units,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
else if (measurement.status === 'not_measured') {
|
|
142
|
+
parameters.push({ id: def.id, status: 'not_measured', reason: measurement.reason, weight: def.weight });
|
|
143
|
+
}
|
|
144
|
+
else if (measurement.status === 'failed') {
|
|
145
|
+
parameters.push({ id: def.id, status: 'failed', reason: measurement.reason, weight: def.weight, score: 0 });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
parameters.push({ id: def.id, status: 'not_applicable', reason: measurement.reason, weight: def.weight });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const complexityByFile = reportOnly.complexityByFile;
|
|
153
|
+
if (Array.isArray(complexityByFile)) {
|
|
154
|
+
const hotspots = computeHotspots(repoPath, opts.asOf, complexityByFile);
|
|
155
|
+
reportOnly.hotspots = hotspots.hotspots;
|
|
156
|
+
warnings.push(...hotspots.warnings);
|
|
157
|
+
}
|
|
158
|
+
reportOnly.pythonInScope = scope.files.some((f) => f.language === 'python');
|
|
159
|
+
const dimensions = dimensionScores(methodology, parameters);
|
|
160
|
+
const base = baseRun(opts);
|
|
161
|
+
const unmeasured = parameters.filter((p) => p.status === 'not_measured').map((p) => `${p.id} (${p.reason})`);
|
|
162
|
+
return {
|
|
163
|
+
...base,
|
|
164
|
+
...(opts.mode === 'full'
|
|
165
|
+
? {
|
|
166
|
+
layers: ['static', 'execution'],
|
|
167
|
+
comparable: unmeasured.length === 0,
|
|
168
|
+
comparableReason: unmeasured.length === 0 ? 'all parameters measured' : `not comparable, not measured: ${unmeasured.join(', ')}`,
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
171
|
+
warnings,
|
|
172
|
+
tools: {
|
|
173
|
+
...base.tools,
|
|
174
|
+
...(exec ? { image: exec.image } : {}),
|
|
175
|
+
...(scope.pythonVersion === undefined ? {} : { python: scope.pythonVersion }),
|
|
176
|
+
...(Object.keys(tools.versions()).length === 0 ? {} : { static: tools.versions() }),
|
|
177
|
+
},
|
|
178
|
+
...(exec ? { execution: { feedbackLoop: feedbackLoop(exec), roots: executionRoots(exec) } } : {}),
|
|
179
|
+
...(Object.keys(reportOnly).length === 0 ? {} : { reportOnly }),
|
|
180
|
+
ignoreSet: {
|
|
181
|
+
healthignoreSha256: scope.healthignoreSha256,
|
|
182
|
+
gitleaksignoreSha256: scope.gitleaksignoreSha256,
|
|
183
|
+
excludedLinesPercent: scope.excludedLinesPercent,
|
|
184
|
+
},
|
|
185
|
+
scope: {
|
|
186
|
+
logicFiles: scope.logicFiles,
|
|
187
|
+
logicLines: scope.logicLines,
|
|
188
|
+
sourceLines: scope.sourceLines,
|
|
189
|
+
packages: scope.packages,
|
|
190
|
+
},
|
|
191
|
+
parameters,
|
|
192
|
+
dimensions,
|
|
193
|
+
breakdown: breakdownFor(methodology, parameters),
|
|
194
|
+
health: healthFrom(methodology, dimensions),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { bandFor, dimensionScore, healthScore, roundHalfUp } from '../scoring/aggregate.js';
|
|
2
|
+
import { poolParameter } from '../scoring/pool.js';
|
|
3
|
+
function liveScore(def, param, units) {
|
|
4
|
+
if (!param)
|
|
5
|
+
return null;
|
|
6
|
+
if (param.status === 'failed')
|
|
7
|
+
return 0;
|
|
8
|
+
if (param.status !== 'measured')
|
|
9
|
+
return null;
|
|
10
|
+
return poolParameter(def, units ?? param.units ?? []).score;
|
|
11
|
+
}
|
|
12
|
+
export function dimensionScores(methodology, parameters, filter) {
|
|
13
|
+
return methodology.dimensions.map((dimension) => {
|
|
14
|
+
const weighted = dimension.parameters.map((def) => {
|
|
15
|
+
const param = parameters.find((p) => p.id === def.id);
|
|
16
|
+
if (filter && param?.status === 'measured') {
|
|
17
|
+
const units = (param.units ?? []).filter(filter);
|
|
18
|
+
// A package or language with no units for a parameter has no data for it.
|
|
19
|
+
return { weight: def.weight, score: units.length === 0 ? null : liveScore(def, param, units) };
|
|
20
|
+
}
|
|
21
|
+
return { weight: def.weight, score: liveScore(def, param) };
|
|
22
|
+
});
|
|
23
|
+
return { id: dimension.id, key: dimension.key, score: dimensionScore(weighted) };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function healthFrom(methodology, dimensions) {
|
|
27
|
+
const score = healthScore(dimensions.map((d) => d.score), methodology.capMargin);
|
|
28
|
+
if (score === null)
|
|
29
|
+
return null;
|
|
30
|
+
const displayed = roundHalfUp(score);
|
|
31
|
+
return { score, displayed, band: bandFor(displayed, methodology.bands).key };
|
|
32
|
+
}
|
|
33
|
+
export function breakdownFor(methodology, parameters) {
|
|
34
|
+
const units = parameters.flatMap((p) => p.units ?? []);
|
|
35
|
+
const packages = [...new Set(units.map((u) => u.package))].sort();
|
|
36
|
+
const groups = [...new Set(units.map((u) => u.group))].sort();
|
|
37
|
+
const toRecord = (dims) => Object.fromEntries(dims.map((d) => [String(d.id), d.score]));
|
|
38
|
+
return {
|
|
39
|
+
byPackage: Object.fromEntries(packages.map((pkg) => [pkg, toRecord(dimensionScores(methodology, parameters, (u) => u.package === pkg))])),
|
|
40
|
+
byGroup: Object.fromEntries(groups.map((group) => [group, toRecord(dimensionScores(methodology, parameters, (u) => u.group === group))])),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|