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,26 @@
1
+ import { d3Gap, getD3Graph } from './d3/index.js';
2
+ import { internalEdges } from './d3/types.js';
3
+ import { unavailable } from '../tools/outcome.js';
4
+ /** Violations per 1,000 internal import edges. */
5
+ export const p32 = {
6
+ id: '3.2',
7
+ layer: 'static',
8
+ async measure(ctx) {
9
+ const gap = d3Gap(ctx);
10
+ if (gap !== undefined)
11
+ return { status: 'not_measured', reason: gap };
12
+ const graph = await getD3Graph(ctx);
13
+ if ('unavailable' in graph)
14
+ return unavailable(graph.unavailable);
15
+ return {
16
+ status: 'measured',
17
+ units: graph.units.map((unit) => ({
18
+ package: unit.package,
19
+ group: unit.group,
20
+ weight: unit.weight,
21
+ raw: { kind: 'ratio', numerator: unit.violating.length, denominator: internalEdges(unit).length, scale: 1000 },
22
+ })),
23
+ ...(graph.warnings.length === 0 ? {} : { warnings: graph.warnings.map((w) => `3.2: ${w}`) }),
24
+ };
25
+ },
26
+ };
@@ -0,0 +1,37 @@
1
+ import { d3Gap, getD3Graph } from './d3/index.js';
2
+ import { cyclicNodes } from './d3/scc.js';
3
+ import { unavailable } from '../tools/outcome.js';
4
+ /** Logic files in a strongly connected component of two or more files of the runtime import graph, per language group. */
5
+ export const p33 = {
6
+ id: '3.3',
7
+ layer: 'static',
8
+ async measure(ctx) {
9
+ const gap = d3Gap(ctx);
10
+ if (gap !== undefined)
11
+ return { status: 'not_measured', reason: gap };
12
+ const graph = await getD3Graph(ctx);
13
+ if ('unavailable' in graph)
14
+ return unavailable(graph.unavailable);
15
+ const cyclic = new Map();
16
+ for (const group of new Set(graph.units.map((u) => u.group))) {
17
+ const members = graph.units.filter((u) => u.group === group);
18
+ const nodes = members.flatMap((u) => u.logicFiles);
19
+ const edges = members.flatMap((u) => u.edges.filter((e) => e.runtime && !e.fromTest).map((e) => [e.from, e.to]));
20
+ cyclic.set(group, cyclicNodes(nodes, edges));
21
+ }
22
+ return {
23
+ status: 'measured',
24
+ units: graph.units.map((unit) => ({
25
+ package: unit.package,
26
+ group: unit.group,
27
+ weight: unit.weight,
28
+ raw: {
29
+ kind: 'ratio',
30
+ numerator: unit.logicFiles.filter((f) => cyclic.get(unit.group)?.has(f)).length,
31
+ denominator: unit.logicFiles.length,
32
+ scale: 100,
33
+ },
34
+ })),
35
+ };
36
+ },
37
+ };
@@ -0,0 +1,26 @@
1
+ import { d3Gap, getD3Graph } from './d3/index.js';
2
+ import { unavailable } from '../tools/outcome.js';
3
+ /** Cross-module imports that land on the module's public entry. Not applicable without two modules in a unit. */
4
+ export const p34 = {
5
+ id: '3.4',
6
+ layer: 'static',
7
+ async measure(ctx) {
8
+ const gap = d3Gap(ctx);
9
+ if (gap !== undefined)
10
+ return { status: 'not_measured', reason: gap };
11
+ const graph = await getD3Graph(ctx);
12
+ if ('unavailable' in graph)
13
+ return unavailable(graph.unavailable);
14
+ const units = graph.units
15
+ .filter((unit) => unit.moduleCount >= 2)
16
+ .map((unit) => ({
17
+ package: unit.package,
18
+ group: unit.group,
19
+ weight: unit.weight,
20
+ raw: { kind: 'ratio', numerator: unit.crossings.filter((c) => c.toEntry).length, denominator: unit.crossings.length, scale: 100 },
21
+ }));
22
+ if (units.length === 0)
23
+ return { status: 'not_applicable', reason: ctx.param.not_applicable_when ?? 'fewer than two modules' };
24
+ return { status: 'measured', units };
25
+ },
26
+ };
@@ -0,0 +1,26 @@
1
+ import { d3Gap, getD3Graph } from './d3/index.js';
2
+ import { unavailable } from '../tools/outcome.js';
3
+ /** Domain files that import infrastructure. Not applicable without a domain layer. */
4
+ export const p35 = {
5
+ id: '3.5',
6
+ layer: 'static',
7
+ async measure(ctx) {
8
+ const gap = d3Gap(ctx);
9
+ if (gap !== undefined)
10
+ return { status: 'not_measured', reason: gap };
11
+ const graph = await getD3Graph(ctx);
12
+ if ('unavailable' in graph)
13
+ return unavailable(graph.unavailable);
14
+ const units = graph.units
15
+ .filter((unit) => unit.domain.files.length > 0)
16
+ .map((unit) => ({
17
+ package: unit.package,
18
+ group: unit.group,
19
+ weight: unit.weight,
20
+ raw: { kind: 'ratio', numerator: unit.domain.impure.length, denominator: unit.domain.files.length, scale: 100 },
21
+ }));
22
+ if (units.length === 0)
23
+ return { status: 'not_applicable', reason: ctx.param.not_applicable_when ?? 'no domain layer' };
24
+ return { status: 'measured', units };
25
+ },
26
+ };
@@ -0,0 +1,184 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { createRequire } from 'node:module';
5
+ import path from 'node:path';
6
+ import { ESLint } from 'eslint';
7
+ import sonarjs from 'eslint-plugin-sonarjs';
8
+ import tseslint from 'typescript-eslint';
9
+ import { ToolError } from '../scope/types.js';
10
+ import { failureReason, unavailable } from '../tools/outcome.js';
11
+ import { createWorkCopy } from '../tools/workcopy.js';
12
+ import { healthPlugin } from './eslint/health-plugin.js';
13
+ import { emptyTallies, logicFiles, tallyUnits, unitId } from './units.js';
14
+ /** Functions with cognitive complexity strictly greater than this count (methodology 4.1: "strictly greater than 15"). */
15
+ const COMPLEXITY_THRESHOLD = 15;
16
+ const MESSAGE = /from (\d+) to the 0 allowed/;
17
+ const require = createRequire(import.meta.url);
18
+ function installedVersion(pkg) {
19
+ const json = JSON.parse(readFileSync(require.resolve(`${pkg}/package.json`), 'utf8'));
20
+ return json.version;
21
+ }
22
+ /** TypeScript and JavaScript: the ESLint Node API with an inline config. No repository code runs, no repository config is read. */
23
+ async function lintTypeScript(repoPath, files) {
24
+ const eslint = new ESLint({
25
+ overrideConfigFile: true,
26
+ cwd: repoPath,
27
+ ignore: false,
28
+ overrideConfig: [
29
+ {
30
+ files: ['**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx}'],
31
+ languageOptions: {
32
+ parser: tseslint.parser,
33
+ sourceType: 'module',
34
+ parserOptions: { ecmaFeatures: { jsx: true } },
35
+ },
36
+ plugins: { sonarjs, health: healthPlugin },
37
+ rules: { 'sonarjs/cognitive-complexity': ['error', 0], 'health/function': 'error' },
38
+ },
39
+ ],
40
+ });
41
+ const byPath = new Map(files.map((f) => [path.resolve(repoPath, f.path), f]));
42
+ const lint = await eslint.lintFiles([...byPath.keys()]);
43
+ const results = [];
44
+ let unparsed = 0;
45
+ for (const result of lint) {
46
+ const file = byPath.get(path.resolve(result.filePath));
47
+ if (!file)
48
+ continue;
49
+ if (result.messages.some((m) => m.fatal && m.ruleId === null)) {
50
+ unparsed += 1;
51
+ continue;
52
+ }
53
+ const entry = { file: file.path, group: file.group, functions: 0, complexities: [] };
54
+ for (const message of result.messages) {
55
+ if (message.ruleId === 'health/function') {
56
+ entry.functions += 1;
57
+ }
58
+ else if (message.ruleId === 'sonarjs/cognitive-complexity') {
59
+ const match = MESSAGE.exec(message.message);
60
+ if (!match?.[1])
61
+ throw new ToolError(`eslint-plugin-sonarjs message not understood: ${message.message}`);
62
+ entry.complexities.push(Number(match[1]));
63
+ }
64
+ }
65
+ results.push(entry);
66
+ }
67
+ return { results, unparsed };
68
+ }
69
+ /** complexipy's line for a file it could not parse; the report still holds every other file. */
70
+ const UNPARSED = /Failed to process /g;
71
+ const ANSI = /\x1b\[[0-9;]*m/g;
72
+ /**
73
+ * Python: complexipy in a work copy, so no repository config is read. complexipy exits 1 when any function is over
74
+ * --max-complexity-allowed (--ignore-complexity does not change that, 7.x and 8.x) or a file fails to parse; it still
75
+ * writes the report, so exit 1 is accepted and the report decides.
76
+ */
77
+ async function runComplexipy(ctx, files) {
78
+ const tool = await ctx.tools.resolve('complexipy');
79
+ if (!tool)
80
+ return unavailable(ctx.tools.notFoundReason('complexipy'));
81
+ const copy = await createWorkCopy(ctx.repoPath, files.map((f) => f.path));
82
+ const outDir = await mkdtemp(path.join(tmpdir(), 'health-complexipy-'));
83
+ try {
84
+ const outFile = path.join(outDir, 'complexipy.json');
85
+ const result = await ctx.tools.run(tool, ['.', '--max-complexity-allowed', String(COMPLEXITY_THRESHOLD), '--no-ignore', '--ignore-complexity', '--output-format', 'json', '--output', outFile, '--quiet'], { cwd: copy.dir });
86
+ const failure = failureReason(tool, result, [0, 1]);
87
+ if (failure)
88
+ return unavailable(failure);
89
+ let entries;
90
+ try {
91
+ entries = JSON.parse(readFileSync(outFile, 'utf8'));
92
+ if (!Array.isArray(entries))
93
+ throw new Error('not an array');
94
+ }
95
+ catch {
96
+ return unavailable('complexipy printed no parseable JSON report');
97
+ }
98
+ const byRel = new Map(files.map((f) => [f.path, f]));
99
+ const perFile = new Map();
100
+ for (const entry of entries) {
101
+ if (typeof entry.path !== 'string' || typeof entry.complexity !== 'number') {
102
+ return unavailable('complexipy report has an unexpected shape');
103
+ }
104
+ const toRel = (p) => path.relative(copy.dir, path.resolve(copy.dir, p)).split(path.sep).join('/');
105
+ // `path` is the file; if a complexipy release makes it the directory, `file_name` completes it.
106
+ let rel = toRel(entry.path);
107
+ if (!byRel.has(rel) && typeof entry.file_name === 'string')
108
+ rel = toRel(path.join(entry.path, entry.file_name));
109
+ const file = byRel.get(rel);
110
+ if (!file)
111
+ continue;
112
+ const item = perFile.get(rel) ?? { file: rel, group: file.group, functions: 0, complexities: [] };
113
+ item.functions += 1;
114
+ if (entry.complexity > 0)
115
+ item.complexities.push(entry.complexity);
116
+ perFile.set(rel, item);
117
+ }
118
+ const unparsed = `${result.stdout}\n${result.stderr}`.replace(ANSI, '').match(UNPARSED)?.length ?? 0;
119
+ return { results: [...perFile.values()], unparsed };
120
+ }
121
+ finally {
122
+ await copy.cleanup();
123
+ await rm(outDir, { recursive: true, force: true });
124
+ }
125
+ }
126
+ export const p41 = {
127
+ id: '4.1',
128
+ layer: 'static',
129
+ async measure(ctx) {
130
+ const tsFiles = logicFiles(ctx.scope, 'typescript_javascript');
131
+ const pyFiles = logicFiles(ctx.scope, 'python');
132
+ const warnings = [];
133
+ const perFile = [];
134
+ let unparsed = 0;
135
+ if (tsFiles.length > 0) {
136
+ const ts = await lintTypeScript(ctx.repoPath, tsFiles);
137
+ perFile.push(...ts.results);
138
+ unparsed += ts.unparsed;
139
+ ctx.tools.recordVersion('eslint', installedVersion('eslint'));
140
+ ctx.tools.recordVersion('eslint-plugin-sonarjs', installedVersion('eslint-plugin-sonarjs'));
141
+ ctx.tools.recordVersion('@typescript-eslint/parser', installedVersion('@typescript-eslint/parser'));
142
+ }
143
+ if (pyFiles.length > 0) {
144
+ const py = await runComplexipy(ctx, pyFiles);
145
+ if (!('results' in py))
146
+ return py;
147
+ perFile.push(...py.results);
148
+ unparsed += py.unparsed;
149
+ }
150
+ if (unparsed > 0)
151
+ warnings.push(`4.1: ${unparsed} files could not be parsed`);
152
+ const tallies = emptyTallies([...tsFiles, ...pyFiles]);
153
+ const byPath = new Map([...tsFiles, ...pyFiles].map((f) => [f.path, f]));
154
+ const complexityByFile = [];
155
+ for (const item of perFile.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0))) {
156
+ const file = byPath.get(item.file);
157
+ if (!file)
158
+ continue;
159
+ const tally = tallies.get(unitId(file));
160
+ if (!tally)
161
+ continue;
162
+ const over = item.complexities.filter((c) => c > COMPLEXITY_THRESHOLD).length;
163
+ tally.denominator += item.functions;
164
+ tally.numerator += over;
165
+ const sum = item.complexities.reduce((a, b) => a + b, 0);
166
+ if (sum > 0) {
167
+ complexityByFile.push({
168
+ file: item.file,
169
+ group: item.group,
170
+ functions: item.functions,
171
+ overThreshold: over,
172
+ complexitySum: sum,
173
+ complexityMax: Math.max(...item.complexities),
174
+ });
175
+ }
176
+ }
177
+ return {
178
+ status: 'measured',
179
+ units: tallyUnits(tallies),
180
+ reportOnly: { complexityByFile },
181
+ warnings,
182
+ };
183
+ },
184
+ };
@@ -0,0 +1,27 @@
1
+ /** Files with more than this many logic lines are large (methodology 4.2). */
2
+ const LARGE_FILE_LOGIC_LINES = 500;
3
+ export const p42 = {
4
+ id: '4.2',
5
+ layer: 'static',
6
+ async measure({ scope }) {
7
+ const units = new Map();
8
+ for (const file of scope.files) {
9
+ if (file.role !== 'logic')
10
+ continue;
11
+ const key = `${file.package}\0${file.group}`;
12
+ const unit = units.get(key) ?? { package: file.package, group: file.group, large: 0, total: 0 };
13
+ unit.total += file.logicLines;
14
+ if (file.logicLines > LARGE_FILE_LOGIC_LINES)
15
+ unit.large += file.logicLines;
16
+ units.set(key, unit);
17
+ }
18
+ const ordered = [...units.values()].sort((a, b) => a.package.localeCompare(b.package) || a.group.localeCompare(b.group));
19
+ const result = ordered.map((unit) => ({
20
+ package: unit.package,
21
+ group: unit.group,
22
+ weight: unit.total,
23
+ raw: { kind: 'ratio', numerator: unit.large, denominator: unit.total, scale: 100 },
24
+ }));
25
+ return { status: 'measured', units: result };
26
+ },
27
+ };
@@ -0,0 +1,106 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { failureReason, unavailable } from '../tools/outcome.js';
6
+ import { createWorkCopy } from '../tools/workcopy.js';
7
+ import { emptyTallies, logicFiles, tallyUnits, unitId } from './units.js';
8
+ /** Methodology 4.3: min-tokens 50, min-lines 5. */
9
+ const MIN_TOKENS = '50';
10
+ const MIN_LINES = '5';
11
+ /** Physical lines the way jscpd counts them: a trailing newline does not start another line. */
12
+ function physicalLines(text) {
13
+ if (text === '')
14
+ return 0;
15
+ const lines = text.split(/\r\n|\r|\n/);
16
+ return text.endsWith('\n') || text.endsWith('\r') ? lines.length - 1 : lines.length;
17
+ }
18
+ function isRange(fragment) {
19
+ return !!fragment && typeof fragment.name === 'string' && typeof fragment.start === 'number' && typeof fragment.end === 'number';
20
+ }
21
+ /**
22
+ * Duplicated lines per file, the way jscpd's `duplicatedLines` counts them: each clone once, with the
23
+ * lines of the fragment it is attributed to. A clone belongs to its firstFile, or to its secondFile when
24
+ * firstFile is out of scope; a clone with neither file in scope is dropped. Returns null on a malformed clone.
25
+ */
26
+ export function attributeClones(clones, inScope, rel) {
27
+ const byFile = new Map();
28
+ for (const clone of clones) {
29
+ if (!isRange(clone.firstFile) || !isRange(clone.secondFile))
30
+ return null;
31
+ const target = [clone.firstFile, clone.secondFile].find((f) => inScope(rel(f.name)));
32
+ if (!target)
33
+ continue;
34
+ const file = rel(target.name);
35
+ byFile.set(file, (byFile.get(file) ?? 0) + target.end - target.start + 1);
36
+ }
37
+ return byFile;
38
+ }
39
+ export const p43 = {
40
+ id: '4.3',
41
+ layer: 'static',
42
+ async measure(ctx) {
43
+ const files = [...logicFiles(ctx.scope, 'typescript_javascript'), ...logicFiles(ctx.scope, 'python')];
44
+ const tallies = emptyTallies(files);
45
+ if (files.length === 0)
46
+ return { status: 'measured', units: [] };
47
+ const tool = await ctx.tools.resolve('jscpd');
48
+ if (!tool)
49
+ return unavailable(ctx.tools.notFoundReason('jscpd'));
50
+ const copy = await createWorkCopy(ctx.repoPath, files.map((f) => f.path));
51
+ const outDir = await mkdtemp(path.join(tmpdir(), 'health-jscpd-'));
52
+ try {
53
+ const result = await ctx.tools.run(tool, [
54
+ '.',
55
+ '--min-tokens', MIN_TOKENS,
56
+ '--min-lines', MIN_LINES,
57
+ '--reporters', 'json',
58
+ '--output', outDir,
59
+ '--format', 'typescript,javascript,jsx,tsx,python',
60
+ '--max-lines', '1000000',
61
+ '--max-size', '100mb',
62
+ '--no-gitignore',
63
+ '--no-colors',
64
+ ], { cwd: copy.dir });
65
+ const failure = failureReason(tool, result, [0]);
66
+ if (failure)
67
+ return unavailable(failure);
68
+ let duplicates;
69
+ try {
70
+ const report = JSON.parse(readFileSync(path.join(outDir, 'jscpd-report.json'), 'utf8'));
71
+ if (!Array.isArray(report.duplicates))
72
+ throw new Error('no duplicates array');
73
+ duplicates = report.duplicates;
74
+ }
75
+ catch {
76
+ return unavailable('jscpd report is missing or has an unexpected shape');
77
+ }
78
+ // jscpd 5 reports line totals per format, not per file, so the per-unit numbers are rebuilt here:
79
+ // lines are the file's physical lines (jscpd skips files shorter than min-lines), duplicated
80
+ // lines are the clones attributed to the file, each clone counted once.
81
+ const counted = new Map();
82
+ for (const file of files) {
83
+ const lines = physicalLines(readFileSync(path.join(copy.dir, file.path), 'utf8'));
84
+ if (lines >= Number(MIN_LINES))
85
+ counted.set(file.path, lines);
86
+ }
87
+ const rel = (name) => path.relative(copy.dir, path.resolve(copy.dir, name)).split(path.sep).join('/');
88
+ const duplicated = attributeClones(duplicates, (file) => counted.has(file), rel);
89
+ if (!duplicated)
90
+ return unavailable('jscpd report has an unexpected shape');
91
+ for (const file of files) {
92
+ const tally = tallies.get(unitId(file));
93
+ const lines = counted.get(file.path);
94
+ if (!tally || lines === undefined)
95
+ continue;
96
+ tally.denominator += lines;
97
+ tally.numerator += duplicated.get(file.path) ?? 0;
98
+ }
99
+ return { status: 'measured', units: tallyUnits(tallies) };
100
+ }
101
+ finally {
102
+ await copy.cleanup();
103
+ await rm(outDir, { recursive: true, force: true });
104
+ }
105
+ },
106
+ };