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.
Files changed (232) hide show
  1. package/LICENSE +95 -0
  2. package/README.md +64 -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 +8 -0
  8. package/dist/card/previous.js +38 -0
  9. package/dist/card/render.js +71 -0
  10. package/dist/cli.js +121 -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 +25 -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/manifest/package-json.js +36 -0
  55. package/dist/manifest/python.js +106 -0
  56. package/dist/measure/d3/index.js +45 -0
  57. package/dist/measure/d3/presets.js +100 -0
  58. package/dist/measure/d3/py/adapter.js +67 -0
  59. package/dist/measure/d3/py/config.js +80 -0
  60. package/dist/measure/d3/py/contracts.js +57 -0
  61. package/dist/measure/d3/py/domain.js +57 -0
  62. package/dist/measure/d3/py/graph.js +214 -0
  63. package/dist/measure/d3/py/interpreter.js +31 -0
  64. package/dist/measure/d3/py/layers.js +120 -0
  65. package/dist/measure/d3/py/roots.js +58 -0
  66. package/dist/measure/d3/py/types.js +1 -0
  67. package/dist/measure/d3/scc.js +64 -0
  68. package/dist/measure/d3/ts/adapter.js +371 -0
  69. package/dist/measure/d3/ts/classify.js +107 -0
  70. package/dist/measure/d3/ts/config.js +318 -0
  71. package/dist/measure/d3/ts/domain.js +23 -0
  72. package/dist/measure/d3/ts/rules.js +65 -0
  73. package/dist/measure/d3/types.js +5 -0
  74. package/dist/measure/deps/cvss.js +66 -0
  75. package/dist/measure/deps/declared.js +195 -0
  76. package/dist/measure/deps/files.js +40 -0
  77. package/dist/measure/deps/lockfiles.js +308 -0
  78. package/dist/measure/deps/osv.js +122 -0
  79. package/dist/measure/deps/registry.js +142 -0
  80. package/dist/measure/deps/resolved.js +78 -0
  81. package/dist/measure/deps/units.js +5 -0
  82. package/dist/measure/eslint/health-plugin.js +19 -0
  83. package/dist/measure/ops/docs-commands.js +73 -0
  84. package/dist/measure/ops/docs-resolve.js +217 -0
  85. package/dist/measure/ops/entry-points.js +49 -0
  86. package/dist/measure/ops/env-example.js +20 -0
  87. package/dist/measure/ops/env-reads.js +46 -0
  88. package/dist/measure/ops/runtime.js +73 -0
  89. package/dist/measure/p1-1.js +8 -0
  90. package/dist/measure/p1-2.js +8 -0
  91. package/dist/measure/p1-3.js +8 -0
  92. package/dist/measure/p1-4.js +41 -0
  93. package/dist/measure/p1-5.js +59 -0
  94. package/dist/measure/p2-1.js +8 -0
  95. package/dist/measure/p2-2.js +44 -0
  96. package/dist/measure/p2-3.js +81 -0
  97. package/dist/measure/p2-4.js +288 -0
  98. package/dist/measure/p2-5.js +8 -0
  99. package/dist/measure/p3-1.js +16 -0
  100. package/dist/measure/p3-2.js +26 -0
  101. package/dist/measure/p3-3.js +37 -0
  102. package/dist/measure/p3-4.js +26 -0
  103. package/dist/measure/p3-5.js +26 -0
  104. package/dist/measure/p4-1.js +184 -0
  105. package/dist/measure/p4-2.js +27 -0
  106. package/dist/measure/p4-3.js +106 -0
  107. package/dist/measure/p4-4.js +309 -0
  108. package/dist/measure/p5-1.js +117 -0
  109. package/dist/measure/p5-2.js +80 -0
  110. package/dist/measure/p5-3.js +21 -0
  111. package/dist/measure/p5-4.js +161 -0
  112. package/dist/measure/p5-5.js +158 -0
  113. package/dist/measure/p6-1.js +20 -0
  114. package/dist/measure/p6-2.js +20 -0
  115. package/dist/measure/p6-3.js +40 -0
  116. package/dist/measure/p6-4.js +31 -0
  117. package/dist/measure/p6-5.js +60 -0
  118. package/dist/measure/p6-6.js +54 -0
  119. package/dist/measure/py/typecheck-config.js +95 -0
  120. package/dist/measure/registry.js +31 -0
  121. package/dist/measure/safety/py-tests.js +124 -0
  122. package/dist/measure/safety/ts-barrels.js +68 -0
  123. package/dist/measure/safety/ts-cases.js +111 -0
  124. package/dist/measure/safety/ts-credits.js +101 -0
  125. package/dist/measure/safety/ts-imports.js +63 -0
  126. package/dist/measure/safety/ts-resolve.js +43 -0
  127. package/dist/measure/ts/directives.js +37 -0
  128. package/dist/measure/ts/exports.js +60 -0
  129. package/dist/measure/ts/tsconfig.js +179 -0
  130. package/dist/measure/types.js +1 -0
  131. package/dist/measure/units.js +41 -0
  132. package/dist/methodology/load.js +159 -0
  133. package/dist/methodology/schema.js +105 -0
  134. package/dist/python/deadcode.js +27 -0
  135. package/dist/python/env.js +13 -0
  136. package/dist/python/helper.js +24 -0
  137. package/dist/python/imports.js +40 -0
  138. package/dist/python/nox.js +16 -0
  139. package/dist/python/scope.js +35 -0
  140. package/dist/python/tests.js +19 -0
  141. package/dist/python/typing.js +27 -0
  142. package/dist/report/escape.js +5 -0
  143. package/dist/report/format.js +75 -0
  144. package/dist/report/render.js +213 -0
  145. package/dist/report/roadmap.js +58 -0
  146. package/dist/report/scares.js +83 -0
  147. package/dist/report/style.js +27 -0
  148. package/dist/report-only/hotspots.js +48 -0
  149. package/dist/route/check.js +37 -0
  150. package/dist/route/files.js +74 -0
  151. package/dist/route/order.js +22 -0
  152. package/dist/route/select.js +114 -0
  153. package/dist/route/write.js +13 -0
  154. package/dist/run/read.js +89 -0
  155. package/dist/run/recompute.js +19 -0
  156. package/dist/run/scan.js +196 -0
  157. package/dist/run/schema.js +1 -0
  158. package/dist/run/score-run.js +42 -0
  159. package/dist/run/write.js +4 -0
  160. package/dist/scope/build.js +173 -0
  161. package/dist/scope/packages.js +87 -0
  162. package/dist/scope/rules.js +21 -0
  163. package/dist/scope/tracked.js +34 -0
  164. package/dist/scope/ts-lines.js +63 -0
  165. package/dist/scope/types.js +7 -0
  166. package/dist/scoring/aggregate.js +38 -0
  167. package/dist/scoring/pool.js +65 -0
  168. package/dist/scoring/score.js +81 -0
  169. package/dist/scoring/what-if.js +38 -0
  170. package/dist/scripts/checks.js +53 -0
  171. package/dist/scripts/ci.js +139 -0
  172. package/dist/scripts/entry-points/find.js +31 -0
  173. package/dist/scripts/entry-points/index.js +18 -0
  174. package/dist/scripts/entry-points/justfile.js +62 -0
  175. package/dist/scripts/entry-points/makefile.js +66 -0
  176. package/dist/scripts/entry-points/nox.js +29 -0
  177. package/dist/scripts/entry-points/package-json.js +21 -0
  178. package/dist/scripts/entry-points/pyproject.js +73 -0
  179. package/dist/scripts/entry-points/tox.js +130 -0
  180. package/dist/scripts/expand.js +300 -0
  181. package/dist/scripts/triggers.js +151 -0
  182. package/dist/scripts/types.js +1 -0
  183. package/dist/submit/constants.js +9 -0
  184. package/dist/submit/payload.js +42 -0
  185. package/dist/submit/repo-identity.js +46 -0
  186. package/dist/submit/send.js +29 -0
  187. package/dist/tools/catalog.js +46 -0
  188. package/dist/tools/image.js +180 -0
  189. package/dist/tools/kit.js +71 -0
  190. package/dist/tools/knip.js +169 -0
  191. package/dist/tools/outcome.js +21 -0
  192. package/dist/tools/resolve.js +66 -0
  193. package/dist/tools/workcopy.js +27 -0
  194. package/dist/util/ini.js +37 -0
  195. package/dist/util/pep440.js +163 -0
  196. package/dist/util/toml.js +27 -0
  197. package/docker/Dockerfile +142 -0
  198. package/docker/bin/health-capture +5 -0
  199. package/docker/bin/health-coverage-install +5 -0
  200. package/docker/bin/health-eslint +66 -0
  201. package/docker/bin/health-pytest +10 -0
  202. package/docker/bin/health-tsc +10 -0
  203. package/docker/bin/health-venv +11 -0
  204. package/docker/config/eslint.config.mjs +4 -0
  205. package/docker/config/eslint.rules.mjs +65 -0
  206. package/docker/config/eslint.untyped.config.mjs +4 -0
  207. package/docker/config/knip.json +1 -0
  208. package/docker/config/ruff.toml +7 -0
  209. package/methodology/LICENSE +396 -0
  210. package/methodology/v1.0/METHODOLOGY.md +1176 -0
  211. package/methodology/v1.0/dimensions/1-safety-net.yaml +240 -0
  212. package/methodology/v1.0/dimensions/2-type-static-rigor.yaml +289 -0
  213. package/methodology/v1.0/dimensions/3-architecture-boundaries.yaml +266 -0
  214. package/methodology/v1.0/dimensions/4-complexity-maintainability.yaml +192 -0
  215. package/methodology/v1.0/dimensions/5-dependency-security-hygiene.yaml +223 -0
  216. package/methodology/v1.0/dimensions/6-operability-feedback-loop.yaml +279 -0
  217. package/methodology/v1.0/methodology.yaml +242 -0
  218. package/methodology/v1.0/presets/django.yaml +43 -0
  219. package/methodology/v1.0/presets/express.yaml +47 -0
  220. package/methodology/v1.0/presets/fastapi.yaml +52 -0
  221. package/methodology/v1.0/presets/generic.yaml +41 -0
  222. package/methodology/v1.0/presets/nextjs.yaml +50 -0
  223. package/methodology/v1.0/scope.yaml +115 -0
  224. package/package.json +61 -4
  225. package/python/dead_code_helper.py +265 -0
  226. package/python/env_helper.py +71 -0
  227. package/python/graph_helper.py +277 -0
  228. package/python/imports_helper.py +132 -0
  229. package/python/nox_helper.py +79 -0
  230. package/python/scope_helper.py +96 -0
  231. package/python/tests_helper.py +171 -0
  232. package/python/typing_helper.py +235 -0
@@ -0,0 +1,28 @@
1
+ import { parseCoverageRanges } from '../test-reports/coverage-ranges.js';
2
+ import { parseCoverageSummary } from '../test-reports/coverage-summary.js';
3
+ import { parseVitestJson } from '../test-reports/vitest-json.js';
4
+ /** vitest 4 has no `coverage.all`; `coverage.include` reports files no test loads. The provider is `@vitest/coverage-v8`, see `health-coverage-install`. */
5
+ export const vitest = {
6
+ results: 'results.json',
7
+ coverage: 'coverage/coverage-summary.json',
8
+ ranges: 'coverage/coverage-final.json',
9
+ argv: (invocation, globs) => [
10
+ 'npx',
11
+ '--no-install',
12
+ 'vitest',
13
+ 'run',
14
+ '--reporter=json',
15
+ '--outputFile=/out/results.json',
16
+ '--coverage.enabled',
17
+ '--coverage.provider=v8',
18
+ '--coverage.reporter=json-summary',
19
+ '--coverage.reporter=json',
20
+ '--coverage.reportsDirectory=/out/coverage',
21
+ '--coverage.reportOnFailure',
22
+ ...globs.map((glob) => `--coverage.include=${glob}`),
23
+ ...invocation.args,
24
+ ],
25
+ parseResults: parseVitestJson,
26
+ parseCoverage: parseCoverageSummary,
27
+ parseRanges: parseCoverageRanges,
28
+ };
@@ -0,0 +1,120 @@
1
+ import { emptyTallies, tallyUnits, unitId } from '../measure/units.js';
2
+ import { analyzeTsFile } from '../scope/ts-lines.js';
3
+ import { failedRootOf, withFailedUnits } from './roots.js';
4
+ import { coveredLines } from './test-reports/coverage-ranges.js';
5
+ /** Why the test run is a repository failure (1.1 to 1.3, 6.3 score 0), or null when every run has a usable results report. */
6
+ export function testFailure(exec) {
7
+ if (!exec.checksRan)
8
+ return 'install failed, so the step did not run';
9
+ for (const run of exec.test.invocations) {
10
+ if (run.outcome.status === 'timeout')
11
+ return `${run.runner} timed out in ${run.dir}`;
12
+ if (!run.results)
13
+ return `${run.runner} wrote no usable results report in ${run.dir}`;
14
+ }
15
+ return null;
16
+ }
17
+ export function totalCases(exec) {
18
+ return exec.test.invocations.reduce((sum, run) => sum + (run.results ? run.results.passed + run.results.failed + run.results.errored + run.results.skipped : 0), 0);
19
+ }
20
+ function isUnder(file, dir) {
21
+ return dir === '.' || file.startsWith(`${dir}/`);
22
+ }
23
+ /** 1.1: passed / (passed + failed + errored) per test invocation. Skipped cases are in neither. With no case anywhere every unit is 0 of 0. */
24
+ export function measurePassRate(ctx) {
25
+ const exec = ctx.exec;
26
+ if (!exec)
27
+ return { status: 'not_measured', reason: 'execution layer did not run' };
28
+ const reason = testFailure(exec);
29
+ if (reason !== null)
30
+ return { status: 'failed', reason };
31
+ const logic = ctx.scope.files.filter((f) => f.role === 'logic');
32
+ if (exec.test.invocations.length === 0)
33
+ return { status: 'measured', units: withFailedUnits(exec, ctx.scope, tallyUnits(emptyTallies(logic))) };
34
+ const units = exec.test.invocations.map((run) => {
35
+ const counts = run.results ?? { passed: 0, failed: 0, errored: 0, skipped: 0 };
36
+ return {
37
+ package: run.dir,
38
+ group: run.group,
39
+ weight: logic.filter((f) => f.group === run.group && isUnder(f.path, run.dir)).reduce((sum, f) => sum + f.logicLines, 0),
40
+ raw: { kind: 'ratio', numerator: counts.passed, denominator: counts.passed + counts.failed + counts.errored, scale: 100 },
41
+ };
42
+ });
43
+ return { status: 'measured', units: withFailedUnits(exec, ctx.scope, units) };
44
+ }
45
+ /** Code lines of a TS/JS logic file and how many ran, or null when no invocation's coverage-final.json lists the file. */
46
+ function tsLineCoverage(ctx, exec, file) {
47
+ const reported = exec.test.invocations.map((run) => run.ranges?.get(file)).filter((r) => r !== undefined);
48
+ if (reported.length === 0)
49
+ return null;
50
+ const codeLines = analyzeTsFile(file, ctx.scope.getTsSourceFile(file).text).codeLineNumbers;
51
+ const covered = new Set();
52
+ for (const ranges of reported)
53
+ for (const line of coveredLines(codeLines, ranges))
54
+ covered.add(line);
55
+ return { total: codeLines.size, covered: covered.size };
56
+ }
57
+ /**
58
+ * 1.2 (lines) and 1.3 (branches): covered over coverable across the in-scope logic files, per (package,
59
+ * group) unit. 1.2 does not take the runner's line counts, which differ between runners for the same code:
60
+ * for a TS/JS file the coverable lines are health's own code lines and a line is covered when the innermost
61
+ * statement or function range holding it ran in any invocation (see `coveredLines`). Python lines and all
62
+ * branches are the tool's counts. A logic file the coverage report lacks counts as 0 covered with its logic
63
+ * lines coverable (it adds no branches), so it cannot raise the ratio. Coverage entries outside the logic set are dropped.
64
+ * With no test case at all every unit is 0 of 0 and scores `when_empty`.
65
+ */
66
+ export function measureCoverage(ctx, kind) {
67
+ const exec = ctx.exec;
68
+ if (!exec)
69
+ return { status: 'not_measured', reason: 'execution layer did not run' };
70
+ const reason = testFailure(exec);
71
+ if (reason !== null)
72
+ return { status: 'failed', reason };
73
+ const logic = ctx.scope.files.filter((f) => f.role === 'logic');
74
+ const tallies = emptyTallies(logic);
75
+ if (totalCases(exec) === 0)
76
+ return { status: 'measured', units: withFailedUnits(exec, ctx.scope, tallyUnits(tallies)) };
77
+ const merged = new Map();
78
+ for (const run of exec.test.invocations) {
79
+ for (const [file, cov] of run.coverage ?? []) {
80
+ const had = merged.get(file);
81
+ merged.set(file, {
82
+ lines: { total: (had?.lines.total ?? 0) + cov.lines.total, covered: (had?.lines.covered ?? 0) + cov.lines.covered },
83
+ branches: { total: (had?.branches.total ?? 0) + cov.branches.total, covered: (had?.branches.covered ?? 0) + cov.branches.covered },
84
+ });
85
+ }
86
+ }
87
+ let missing = 0;
88
+ for (const file of logic) {
89
+ const tally = tallies.get(unitId(file));
90
+ if (!tally || failedRootOf(exec, file.path, file.group))
91
+ continue;
92
+ if (kind === 'lines' && file.group === 'typescript_javascript') {
93
+ const lines = tsLineCoverage(ctx, exec, file.path);
94
+ if (lines === null) {
95
+ missing += 1;
96
+ tally.denominator += file.logicLines;
97
+ }
98
+ else {
99
+ tally.numerator += lines.covered;
100
+ tally.denominator += lines.total;
101
+ }
102
+ continue;
103
+ }
104
+ const cov = merged.get(file.path);
105
+ if (cov) {
106
+ tally.numerator += cov[kind].covered;
107
+ tally.denominator += cov[kind].total;
108
+ }
109
+ else {
110
+ missing += 1;
111
+ if (kind === 'lines')
112
+ tally.denominator += file.logicLines;
113
+ }
114
+ }
115
+ return {
116
+ status: 'measured',
117
+ units: withFailedUnits(exec, ctx.scope, tallyUnits(tallies)),
118
+ ...(kind === 'lines' && missing > 0 ? { warnings: [`${missing} logic file(s) missing from the coverage report were counted as 0% covered`] } : {}),
119
+ };
120
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { readTracked } from '../scope/tracked.js';
3
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
4
+ function stringRecord(value) {
5
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
6
+ return {};
7
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === 'string'));
8
+ }
9
+ /** The `package.json` in `dir` (repo-relative, "." for the root). Missing or invalid JSON gives undefined. */
10
+ export function readPackageJson(repoPath, dir) {
11
+ const text = readTracked(repoPath, path.posix.join(dir === '' ? '.' : dir, 'package.json'));
12
+ if (text === null)
13
+ return undefined;
14
+ let json;
15
+ try {
16
+ json = JSON.parse(text);
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ if (json === null || typeof json !== 'object' || Array.isArray(json))
22
+ return undefined;
23
+ const bag = json;
24
+ const dependencies = {};
25
+ for (const field of DEPENDENCY_FIELDS)
26
+ Object.assign(dependencies, stringRecord(bag[field]));
27
+ return {
28
+ ...(typeof bag.name === 'string' ? { name: bag.name } : {}),
29
+ scripts: stringRecord(bag.scripts),
30
+ dependencies,
31
+ engines: stringRecord(bag.engines),
32
+ ...(bag.volta !== null && typeof bag.volta === 'object' ? { volta: bag.volta } : {}),
33
+ ...(typeof bag.packageManager === 'string' ? { packageManager: bag.packageManager } : {}),
34
+ ...(bag.workspaces === undefined ? {} : { workspaces: bag.workspaces }),
35
+ };
36
+ }
@@ -0,0 +1,106 @@
1
+ import path from 'node:path';
2
+ import { parse } from 'smol-toml';
3
+ import { readTracked, trackedPaths } from '../scope/tracked.js';
4
+ import { parseIni } from '../util/ini.js';
5
+ /** PEP 503 normalisation: lowercase, runs of `-`, `_` and `.` become `-`. */
6
+ export function normalizeName(name) {
7
+ return name.toLowerCase().replace(/[-_.]+/g, '-');
8
+ }
9
+ /** The distribution name at the start of a requirement string (`Django[argon2]>=4 ; python_version>"3"`). */
10
+ function requirementName(text) {
11
+ const match = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(text);
12
+ return match?.[1] === undefined ? undefined : normalizeName(match[1]);
13
+ }
14
+ function readTomlTracked(repoPath, rel) {
15
+ const text = readTracked(repoPath, rel);
16
+ if (text === null)
17
+ return undefined;
18
+ try {
19
+ return parse(text);
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ const asRecord = (value) => (typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {});
26
+ function addRequirements(out, value) {
27
+ if (!Array.isArray(value))
28
+ return;
29
+ for (const entry of value) {
30
+ if (typeof entry !== 'string')
31
+ continue;
32
+ const name = requirementName(entry);
33
+ if (name !== undefined)
34
+ out.add(name);
35
+ }
36
+ }
37
+ function addKeys(out, value) {
38
+ for (const key of Object.keys(asRecord(value))) {
39
+ if (key.toLowerCase() !== 'python')
40
+ out.add(normalizeName(key));
41
+ }
42
+ }
43
+ function addRequirementsFile(repoPath, rel, out, followIncludes) {
44
+ const text = readTracked(repoPath, rel);
45
+ if (text === null)
46
+ return;
47
+ for (const raw of text.split(/\r\n|\r|\n/)) {
48
+ const line = raw.replace(/(^|\s)#.*$/, '').trim();
49
+ if (line === '')
50
+ continue;
51
+ const include = /^(?:-r|--requirement)[\s=]+(\S+)/.exec(line);
52
+ if (include?.[1] !== undefined) {
53
+ if (followIncludes)
54
+ addRequirementsFile(repoPath, path.posix.normalize(path.posix.join(path.posix.dirname(rel), include[1])), out, false);
55
+ continue;
56
+ }
57
+ if (line.startsWith('-'))
58
+ continue;
59
+ const name = requirementName(line);
60
+ if (name !== undefined)
61
+ out.add(name);
62
+ }
63
+ }
64
+ /**
65
+ * Dependency names declared by the manifests in `dir` only (never an ancestor's): `[project]` dependencies and
66
+ * optional dependencies, `[dependency-groups]`, `[tool.poetry]` tables, `requirements*.txt` (with `-r` includes one
67
+ * level deep), `Pipfile` packages and `setup.cfg` `install_requires`. Names are PEP 503 normalised.
68
+ */
69
+ export function pythonDeclaredDeps(repoPath, dir) {
70
+ const out = new Set();
71
+ const at = (name) => (dir === '.' || dir === '' ? name : path.posix.join(dir, name));
72
+ const pyproject = readTomlTracked(repoPath, at('pyproject.toml'));
73
+ if (pyproject !== undefined) {
74
+ const project = asRecord(pyproject.project);
75
+ addRequirements(out, project.dependencies);
76
+ for (const list of Object.values(asRecord(project['optional-dependencies'])))
77
+ addRequirements(out, list);
78
+ for (const list of Object.values(asRecord(pyproject['dependency-groups'])))
79
+ addRequirements(out, list);
80
+ const poetry = asRecord(asRecord(pyproject.tool).poetry);
81
+ addKeys(out, poetry.dependencies);
82
+ addKeys(out, poetry['dev-dependencies']);
83
+ for (const group of Object.values(asRecord(poetry.group)))
84
+ addKeys(out, asRecord(group).dependencies);
85
+ }
86
+ const prefix = dir === '.' || dir === '' ? '' : `${dir}/`;
87
+ for (const file of trackedPaths(repoPath)) {
88
+ if (!file.startsWith(prefix))
89
+ continue;
90
+ const name = file.slice(prefix.length);
91
+ if (/^requirements[^/]*\.txt$/.test(name))
92
+ addRequirementsFile(repoPath, file, out, true);
93
+ }
94
+ const pipfile = readTomlTracked(repoPath, at('Pipfile'));
95
+ if (pipfile !== undefined) {
96
+ addKeys(out, pipfile.packages);
97
+ addKeys(out, pipfile['dev-packages']);
98
+ }
99
+ const setupCfg = readTracked(repoPath, at('setup.cfg'));
100
+ if (setupCfg !== null) {
101
+ const required = parseIni(setupCfg).options?.install_requires;
102
+ if (required !== undefined)
103
+ addRequirements(out, required.split('\n'));
104
+ }
105
+ return out;
106
+ }
@@ -0,0 +1,45 @@
1
+ import { pyAdapter } from './py/adapter.js';
2
+ import { tsAdapter } from './ts/adapter.js';
3
+ /** Explicit list. */
4
+ const D3_ADAPTERS = [tsAdapter, pyAdapter];
5
+ function adaptersFor(ctx) {
6
+ const groups = new Set(ctx.scope.files.filter((f) => f.role === 'logic').map((f) => f.group));
7
+ return D3_ADAPTERS.filter((adapter) => groups.has(adapter.group));
8
+ }
9
+ /**
10
+ * Why D3 cannot be measured as a whole: a language group has logic files but no adapter yet.
11
+ * Partial pooling would score only the languages that have one.
12
+ */
13
+ export function d3Gap(ctx) {
14
+ const covered = new Set(D3_ADAPTERS.map((adapter) => adapter.group));
15
+ const missing = [...new Set(ctx.scope.files.filter((f) => f.role === 'logic').map((f) => f.group))].filter((g) => !covered.has(g));
16
+ return missing.length === 0 ? undefined : `D3 is not implemented yet for ${missing.join(', ')}`;
17
+ }
18
+ /** The rules facet: needs no external tool. Memoized per scan. */
19
+ export function getD3Rules(ctx) {
20
+ return ctx.tools.memo(`d3:rules:${ctx.repoPath}`, async () => {
21
+ const units = [];
22
+ const warnings = [];
23
+ for (const adapter of adaptersFor(ctx)) {
24
+ const result = await adapter.rules(ctx);
25
+ units.push(...result.units);
26
+ warnings.push(...result.warnings);
27
+ }
28
+ return { units, warnings };
29
+ });
30
+ }
31
+ /** The graph facet. When any needed adapter is unavailable, the whole facet is (no partial pool). Memoized per scan. */
32
+ export function getD3Graph(ctx) {
33
+ return ctx.tools.memo(`d3:graph:${ctx.repoPath}`, async () => {
34
+ const units = [];
35
+ const warnings = [];
36
+ for (const adapter of adaptersFor(ctx)) {
37
+ const result = await adapter.graph(ctx);
38
+ if ('unavailable' in result)
39
+ return result;
40
+ units.push(...result.units);
41
+ warnings.push(...result.warnings);
42
+ }
43
+ return { units, warnings };
44
+ });
45
+ }
@@ -0,0 +1,100 @@
1
+ import path from 'node:path';
2
+ import picomatch from 'picomatch';
3
+ /** Detection order of the D3 dimension file. */
4
+ const DETECTION_ORDER = ['nextjs', 'express', 'django', 'fastapi', 'generic'];
5
+ const LANGUAGES = { typescript_javascript: ['typescript', 'javascript'], python: ['python'] };
6
+ /**
7
+ * The first preset in detection order whose `languages` include the group and whose detection predicate
8
+ * (`matches[key]`, supplied by the language adapter) holds. `generic` is the fallback.
9
+ */
10
+ export function choosePreset(methodology, group, matches) {
11
+ for (const key of DETECTION_ORDER) {
12
+ const preset = methodology.presets[key];
13
+ if (!preset || !preset.languages.some((l) => LANGUAGES[group].includes(l)))
14
+ continue;
15
+ if (key === 'generic' || matches[key] === true)
16
+ return preset;
17
+ }
18
+ throw new Error('methodology has no generic preset');
19
+ }
20
+ const MATCH_OPTIONS = { dot: true };
21
+ /** TypeScript and JavaScript match a glob with or without `src/`. Python passes its root package directories. */
22
+ const TS_PREFIXES = ['', 'src/'];
23
+ /** A glob `g` matches path `p` when `p` matches `prefix + g` for any of the prefixes (each empty or ending in `/`). */
24
+ export function globMatcher(globs, prefixes = TS_PREFIXES) {
25
+ const matchers = prefixes.map((prefix) => picomatch(globs.map((g) => `${prefix}${g}`), MATCH_OPTIONS));
26
+ return (rel) => matchers.some((match) => match(rel));
27
+ }
28
+ /** Leading literal directory segments of a glob (`app/**` has 1, `features/*` has 1, `**` has 0). */
29
+ function literalDepth(glob) {
30
+ const segments = glob.split('/');
31
+ let depth = 0;
32
+ for (const segment of segments.slice(0, -1)) {
33
+ if (/[*?[\]{}()!]/.test(segment))
34
+ break;
35
+ depth += 1;
36
+ }
37
+ return depth;
38
+ }
39
+ /**
40
+ * The glob layer of each file (paths relative to the package directory): the layer with the longest
41
+ * matching directory prefix, ties to the first layer listed. Files in no layer are absent.
42
+ */
43
+ export function globLayers(layers, relFiles, prefixes = TS_PREFIXES) {
44
+ const compiled = Object.entries(layers)
45
+ .filter((entry) => Array.isArray(entry[1]))
46
+ .map(([name, globs]) => ({
47
+ name,
48
+ entries: globs.map((glob) => ({ depth: literalDepth(glob), match: globMatcher([glob], prefixes) })),
49
+ }));
50
+ const result = new Map();
51
+ for (const rel of relFiles) {
52
+ let best;
53
+ for (const layer of compiled) {
54
+ for (const entry of layer.entries) {
55
+ if (!entry.match(rel))
56
+ continue;
57
+ const depth = entry.depth;
58
+ if (!best || depth > best.depth)
59
+ best = { name: layer.name, depth };
60
+ }
61
+ }
62
+ if (best)
63
+ result.set(rel, best.name);
64
+ }
65
+ return result;
66
+ }
67
+ /** Directory (relative to the package) of the deepest module root containing the file, else undefined. */
68
+ export function moduleDirs(roots, relFiles, prefixes = TS_PREFIXES) {
69
+ const isRoot = globMatcher(roots, prefixes);
70
+ const known = new Map();
71
+ const rootDir = (dir) => {
72
+ let value = known.get(dir);
73
+ if (value === undefined) {
74
+ value = isRoot(dir);
75
+ known.set(dir, value);
76
+ }
77
+ return value;
78
+ };
79
+ const result = new Map();
80
+ for (const rel of relFiles) {
81
+ let found;
82
+ for (let dir = path.posix.dirname(rel); dir !== '.' && dir !== '/'; dir = path.posix.dirname(dir)) {
83
+ if (rootDir(dir)) {
84
+ found = dir;
85
+ break;
86
+ }
87
+ }
88
+ result.set(rel, found);
89
+ }
90
+ return result;
91
+ }
92
+ /** The entry glob that applies to the group. */
93
+ export function publicEntryGlob(preset, group) {
94
+ const entry = preset.modules.public_entry;
95
+ if (typeof entry === 'string')
96
+ return entry;
97
+ if (Array.isArray(entry))
98
+ return undefined;
99
+ return entry[group];
100
+ }
@@ -0,0 +1,67 @@
1
+ import { pythonDeclaredDeps } from '../../../manifest/python.js';
2
+ import { trackedPaths } from '../../../scope/tracked.js';
3
+ import { ciInvokes } from '../../../scripts/expand.js';
4
+ import { choosePreset } from '../presets.js';
5
+ import { findPyDeclared } from './config.js';
6
+ import { buildPyGraph } from './graph.js';
7
+ import { pythonRoots } from './roots.js';
8
+ const GROUP = 'python';
9
+ function setup(ctx) {
10
+ return ctx.tools.memo(`d3:py:setup:${ctx.repoPath}`, async () => {
11
+ const warnings = [];
12
+ const tracked = new Set(trackedPaths(ctx.repoPath));
13
+ const byPackage = new Map();
14
+ for (const file of ctx.scope.files) {
15
+ if (file.group !== GROUP)
16
+ continue;
17
+ const list = byPackage.get(file.package) ?? [];
18
+ list.push(file);
19
+ byPackage.set(file.package, list);
20
+ }
21
+ const ciHits = ciInvokes(ctx.repoPath, ['lint-imports'], { warnings });
22
+ const declaredCache = new Map();
23
+ const units = [];
24
+ for (const [pkg, files] of [...byPackage].sort(([a], [b]) => a.localeCompare(b))) {
25
+ const logic = files.filter((f) => f.role === 'logic');
26
+ if (logic.length === 0)
27
+ continue;
28
+ const deps = pythonDeclaredDeps(ctx.repoPath, pkg);
29
+ const preset = choosePreset(ctx.methodology, GROUP, { django: deps.has('django'), fastapi: deps.has('fastapi') });
30
+ const declared = findPyDeclared(ctx.repoPath, pkg, warnings, declaredCache);
31
+ const weight = logic.reduce((sum, f) => sum + f.logicLines, 0);
32
+ const level31 = declared === undefined ? 'none' : ciHits.length > 0 ? 'enforced' : 'declared';
33
+ units.push({
34
+ package: pkg,
35
+ files,
36
+ logic,
37
+ weight,
38
+ preset,
39
+ declared,
40
+ roots: pythonRoots(pkg, files.map((f) => f.path), tracked, warnings),
41
+ rules: {
42
+ package: pkg,
43
+ group: GROUP,
44
+ weight,
45
+ preset: preset.preset,
46
+ rulesSource: declared === undefined ? 'preset' : 'declared',
47
+ ...(declared === undefined ? {} : { rulesFile: declared.file }),
48
+ level31,
49
+ },
50
+ });
51
+ }
52
+ return { units, warnings };
53
+ });
54
+ }
55
+ export const pyAdapter = {
56
+ group: GROUP,
57
+ async rules(ctx) {
58
+ const { units, warnings } = await setup(ctx);
59
+ return { units: units.map((u) => u.rules), warnings };
60
+ },
61
+ async graph(ctx) {
62
+ return ctx.tools.memo(`d3:py:graph:${ctx.repoPath}`, async () => {
63
+ const { units } = await setup(ctx);
64
+ return buildPyGraph(ctx, units);
65
+ });
66
+ },
67
+ };
@@ -0,0 +1,80 @@
1
+ import { parse } from 'smol-toml';
2
+ import { readTracked } from '../../../scope/tracked.js';
3
+ import { parseIni } from '../../../util/ini.js';
4
+ const DIRECTION_TYPES = new Set(['layers', 'forbidden', 'independence']);
5
+ /** The order import-linter itself tries its config files in. */
6
+ const CONFIG_NAMES = ['setup.cfg', '.importlinter', 'pyproject.toml'];
7
+ const lines = (value) => (value ?? '').split(/\r\n|\r|\n/).map((l) => l.trim()).filter((l) => l !== '');
8
+ function iniContracts(text, requireSection) {
9
+ const ini = parseIni(text);
10
+ if (requireSection && ini.importlinter === undefined)
11
+ return undefined;
12
+ const out = [];
13
+ for (const [section, values] of Object.entries(ini)) {
14
+ if (!section.startsWith('importlinter:contract:'))
15
+ continue;
16
+ out.push({
17
+ name: values.name ?? section.slice('importlinter:contract:'.length),
18
+ type: (values.type ?? '').trim(),
19
+ layers: lines(values.layers),
20
+ containers: lines(values.containers),
21
+ });
22
+ }
23
+ return out;
24
+ }
25
+ function strings(value) {
26
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
27
+ }
28
+ function tomlContracts(text, file, warnings) {
29
+ let doc;
30
+ try {
31
+ doc = parse(text);
32
+ }
33
+ catch {
34
+ warnings.push(`${file}: could not be parsed, treated as absent`);
35
+ return undefined;
36
+ }
37
+ const tool = doc.tool;
38
+ const section = tool?.importlinter;
39
+ if (section === undefined || typeof section !== 'object')
40
+ return undefined;
41
+ const list = Array.isArray(section.contracts) ? section.contracts : [];
42
+ return list
43
+ .filter((c) => typeof c === 'object' && c !== null)
44
+ .map((c, index) => ({
45
+ name: typeof c.name === 'string' ? c.name : `contract-${index + 1}`,
46
+ type: typeof c.type === 'string' ? c.type : '',
47
+ layers: strings(c.layers),
48
+ containers: strings(c.containers),
49
+ }));
50
+ }
51
+ /**
52
+ * The rules file that governs a unit: walking from the unit directory up to the repository root, at each directory
53
+ * `setup.cfg`, `.importlinter`, `pyproject.toml` in that order; the first one with at least one layers, forbidden or
54
+ * independence contract. Files with other contract types only, an unparseable file and a `setup.cfg` without
55
+ * `[importlinter]` do not govern.
56
+ */
57
+ export function findPyDeclared(repoPath, unitDir, warnings, cache) {
58
+ let dir = unitDir;
59
+ for (;;) {
60
+ for (const name of CONFIG_NAMES) {
61
+ const file = dir === '.' ? name : `${dir}/${name}`;
62
+ let result = cache.get(file);
63
+ if (result === undefined) {
64
+ const text = readTracked(repoPath, file);
65
+ let contracts;
66
+ if (text !== null)
67
+ contracts = name === 'pyproject.toml' ? tomlContracts(text, file, warnings) : iniContracts(text, name === 'setup.cfg' || name === '.importlinter');
68
+ const direction = contracts?.filter((c) => DIRECTION_TYPES.has(c.type)) ?? [];
69
+ result = direction.length > 0 ? { file, contracts: direction } : null;
70
+ cache.set(file, result);
71
+ }
72
+ if (result !== null)
73
+ return result;
74
+ }
75
+ if (dir === '.')
76
+ return undefined;
77
+ const slash = dir.lastIndexOf('/');
78
+ dir = slash === -1 ? '.' : dir.slice(0, slash);
79
+ }
80
+ }
@@ -0,0 +1,57 @@
1
+ import { moduleName } from './roots.js';
2
+ /**
3
+ * Preset rules as contracts for one unit. `layerFiles` maps a layer to the unit's files in it; `unitFiles` is every
4
+ * in-scope file of the unit. One contract per (preset rule, `to` alternative), named `<package>:<rule>` with a
5
+ * `#packages` suffix on the packages alternative. Only files in a root package have module names.
6
+ */
7
+ export function presetContracts(packageDir, rules, layerFiles, unitFiles, roots) {
8
+ const names = (files) => {
9
+ const out = new Set();
10
+ for (const file of files) {
11
+ for (const root of roots) {
12
+ const name = moduleName(root, file);
13
+ if (name !== undefined) {
14
+ out.add(name);
15
+ break;
16
+ }
17
+ }
18
+ }
19
+ return [...out].sort();
20
+ };
21
+ const union = (layers) => [...new Set(layers.flatMap((l) => [...(layerFiles.get(l) ?? [])]))];
22
+ const contracts = [];
23
+ const meta = new Map();
24
+ for (const rule of rules) {
25
+ if (rule.severity === 'info')
26
+ continue;
27
+ let fromFiles;
28
+ if (rule.from.in)
29
+ fromFiles = union(rule.from.in);
30
+ else {
31
+ const excluded = new Set(union(rule.from.not_in ?? []));
32
+ fromFiles = unitFiles.filter((f) => !excluded.has(f));
33
+ }
34
+ const source = names(fromFiles);
35
+ if (source.length === 0)
36
+ continue;
37
+ const base = `${packageDir}:${rule.name}`;
38
+ const add = (name, forbidden) => {
39
+ if (forbidden.length === 0)
40
+ return;
41
+ contracts.push({
42
+ id: name,
43
+ name,
44
+ type: 'forbidden',
45
+ source_modules: source,
46
+ forbidden_modules: forbidden,
47
+ as_packages: 'false',
48
+ allow_indirect_imports: 'true',
49
+ unmatched_ignore_imports_alerting: 'none',
50
+ });
51
+ meta.set(name, { rule: rule.name, excludesTypeOnly: rule.type_only_imports === 'excluded' });
52
+ };
53
+ add(base, rule.to.in ? names(union(rule.to.in)) : []);
54
+ add(`${base}#packages`, rule.to.packages ?? []);
55
+ }
56
+ return { contracts, meta };
57
+ }