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,120 @@
1
+ import path from 'node:path';
2
+ import { globLayers, moduleDirs, publicEntryGlob } from '../presets.js';
3
+ import { rootOf, rootPackageDir } from './roots.js';
4
+ const stem = (file) => path.posix.basename(file, '.py');
5
+ function empty() {
6
+ return { layers: new Map(), moduleOf: new Map(), entries: new Set(), layerModule: new Map() };
7
+ }
8
+ function entryNames(preset) {
9
+ const entry = preset.modules.public_entry;
10
+ const list = Array.isArray(entry) ? entry : [];
11
+ return { file: list.includes('__init__.py'), names: list.filter((e) => e !== '__init__.py') };
12
+ }
13
+ function django(preset, files, unitDir, tracked) {
14
+ const out = empty();
15
+ const prefix = unitDir === '.' ? '' : `${unitDir}/`;
16
+ const apps = [...tracked]
17
+ .filter((p) => p.startsWith(prefix) && path.posix.basename(p) === 'apps.py')
18
+ .map((p) => path.posix.dirname(p))
19
+ .sort((a, b) => b.length - a.length);
20
+ const entries = entryNames(preset);
21
+ for (const file of files) {
22
+ const app = apps.find((a) => (a === '.' ? true : file.startsWith(`${a}/`)));
23
+ if (app === undefined)
24
+ continue;
25
+ const rel = app === '.' ? file : file.slice(app.length + 1);
26
+ const parts = rel.split('/');
27
+ const key = parts.length > 1 ? parts[0] : stem(rel);
28
+ out.moduleOf.set(file, app);
29
+ if (key === '__init__') {
30
+ if (entries.file && parts.length === 1)
31
+ out.entries.add(file);
32
+ continue;
33
+ }
34
+ const inLayers = Object.entries(preset.layers)
35
+ .filter((entry) => Array.isArray(entry[1]) && entry[1].includes(key))
36
+ .map(([name]) => name);
37
+ if (inLayers.length > 0)
38
+ out.layers.set(file, inLayers);
39
+ out.layerModule.set(file, `${app}/${key}`);
40
+ if (entries.names.includes(key))
41
+ out.entries.add(file);
42
+ }
43
+ return out;
44
+ }
45
+ function fastapi(preset, files, roots) {
46
+ const out = empty();
47
+ const layerOf = new Map();
48
+ for (const [name, list] of Object.entries(preset.layers))
49
+ if (Array.isArray(list))
50
+ for (const segment of list)
51
+ layerOf.set(segment, name);
52
+ const inFiles = new Set(files);
53
+ const moduleRoots = new Set();
54
+ for (const file of files) {
55
+ const root = rootOf(roots, file);
56
+ if (root === undefined)
57
+ continue;
58
+ const base = rootPackageDir(root);
59
+ const segments = path.posix.dirname(file) === base ? [] : path.posix.dirname(file).slice(base.length + 1).split('/');
60
+ if (stem(file) !== '__init__')
61
+ segments.push(stem(file));
62
+ for (let i = segments.length - 1; i >= 0; i -= 1) {
63
+ const layer = layerOf.get(segments[i]);
64
+ if (layer !== undefined) {
65
+ out.layers.set(file, [layer]);
66
+ break;
67
+ }
68
+ }
69
+ // Modules are directories below the root package: those holding router.py and the direct children of features/modules.
70
+ const dir = path.posix.dirname(file);
71
+ if (dir !== base && path.posix.basename(file) === 'router.py')
72
+ moduleRoots.add(dir);
73
+ for (let d = dir; d.length > base.length; d = path.posix.dirname(d)) {
74
+ const parent = path.posix.dirname(d);
75
+ if (parent.length >= base.length && ['features', 'modules'].includes(path.posix.basename(parent)))
76
+ moduleRoots.add(d);
77
+ }
78
+ }
79
+ const sorted = [...moduleRoots].sort((a, b) => b.length - a.length);
80
+ const entries = entryNames(preset);
81
+ for (const file of files) {
82
+ const dir = sorted.find((m) => file.startsWith(`${m}/`));
83
+ if (dir === undefined)
84
+ continue;
85
+ out.moduleOf.set(file, dir);
86
+ if (entries.file && path.posix.dirname(file) === dir && path.posix.basename(file) === '__init__.py' && inFiles.has(file))
87
+ out.entries.add(file);
88
+ }
89
+ return out;
90
+ }
91
+ function generic(preset, files, unitDir, roots) {
92
+ const out = empty();
93
+ const prefix = unitDir === '.' ? '' : `${unitDir}/`;
94
+ const rels = files.map((f) => f.slice(prefix.length));
95
+ const prefixes = ['', ...roots.map((r) => `${path.posix.relative(unitDir, rootPackageDir(r))}/`)];
96
+ const full = new Map(rels.map((rel, i) => [rel, files[i]]));
97
+ for (const [rel, layer] of globLayers(preset.layers, rels, prefixes))
98
+ out.layers.set(full.get(rel), [layer]);
99
+ const globs = Array.isArray(preset.modules.roots) ? preset.modules.roots : [];
100
+ const dirs = moduleDirs(globs, rels, prefixes);
101
+ const entryGlob = publicEntryGlob(preset, 'python');
102
+ for (const rel of rels) {
103
+ const dir = dirs.get(rel);
104
+ if (dir === undefined)
105
+ continue;
106
+ const file = full.get(rel);
107
+ out.moduleOf.set(file, `${prefix}${dir}`);
108
+ if (entryGlob !== undefined && path.posix.dirname(rel) === dir && path.posix.basename(rel) === entryGlob)
109
+ out.entries.add(file);
110
+ }
111
+ return out;
112
+ }
113
+ /** Layers, modules and public entries of a unit's in-scope Python files under the preset. `files` are repo-relative. */
114
+ export function classifyPython(preset, files, unitDir, roots, tracked) {
115
+ if (preset.preset === 'django')
116
+ return django(preset, files, unitDir, tracked);
117
+ if (preset.preset === 'fastapi')
118
+ return fastapi(preset, files, roots);
119
+ return generic(preset, files, unitDir, roots);
120
+ }
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
3
+ /** Repo-relative directory of the root package. */
4
+ export const rootPackageDir = (root) => (root.dir === '.' ? root.name : `${root.dir}/${root.name}`);
5
+ /**
6
+ * First-party root packages of a unit. Source roots: `<unit>/src` (when it holds in-scope Python files and has no
7
+ * `__init__.py`) and the unit directory. Root packages are the identifier-named child directories of a source root
8
+ * that hold an in-scope Python file at any depth, with or without `__init__.py`. When a name occurs under both
9
+ * source roots the `src` one wins.
10
+ */
11
+ export function pythonRoots(unitDir, files, tracked, warnings) {
12
+ const at = (rel) => (unitDir === '.' ? rel : `${unitDir}/${rel}`);
13
+ const srcDir = at('src');
14
+ const srcHasFiles = files.some((f) => f.startsWith(`${srcDir}/`));
15
+ const srcIsRoot = srcHasFiles && !tracked.has(`${srcDir}/__init__.py`);
16
+ const sourceRoots = srcIsRoot ? [srcDir, unitDir] : [unitDir];
17
+ const found = new Map();
18
+ for (const sourceRoot of sourceRoots) {
19
+ const prefix = sourceRoot === '.' ? '' : `${sourceRoot}/`;
20
+ const children = new Set();
21
+ for (const file of files) {
22
+ if (!file.startsWith(prefix))
23
+ continue;
24
+ const parts = file.slice(prefix.length).split('/');
25
+ if (parts.length >= 2)
26
+ children.add(parts[0]);
27
+ }
28
+ for (const name of [...children].sort()) {
29
+ if (!IDENTIFIER.test(name))
30
+ continue;
31
+ if (sourceRoot === unitDir && srcIsRoot && name === 'src')
32
+ continue;
33
+ const existing = found.get(name);
34
+ if (existing !== undefined) {
35
+ warnings.push(`${path.posix.join(existing.dir, name)} and ${path.posix.join(sourceRoot, name)} share the package name ${name}, the src one is used`);
36
+ continue;
37
+ }
38
+ found.set(name, { dir: sourceRoot, name });
39
+ }
40
+ }
41
+ return [...found.values()];
42
+ }
43
+ /** Dotted module name of a file below a root, or undefined when it is not in the root or a segment is not an identifier. */
44
+ export function moduleName(root, file) {
45
+ const prefix = root.dir === '.' ? '' : `${root.dir}/`;
46
+ if (!file.startsWith(`${prefix}${root.name}/`) || !file.endsWith('.py'))
47
+ return undefined;
48
+ const parts = file.slice(prefix.length, -3).split('/');
49
+ if (parts[parts.length - 1] === '__init__')
50
+ parts.pop();
51
+ return parts.every((p) => IDENTIFIER.test(p)) ? parts.join('.') : undefined;
52
+ }
53
+ export function rootOf(roots, file) {
54
+ return roots.find((root) => {
55
+ const dir = rootPackageDir(root);
56
+ return file.startsWith(`${dir}/`);
57
+ });
58
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,64 @@
1
+ /** Strongly connected components of two or more nodes (Tarjan, iterative). Self loops are ignored. */
2
+ export function cyclicNodes(nodes, edges) {
3
+ const adjacency = new Map();
4
+ for (const node of nodes)
5
+ adjacency.set(node, []);
6
+ for (const [from, to] of edges) {
7
+ if (from !== to && adjacency.has(from) && adjacency.has(to))
8
+ adjacency.get(from)?.push(to);
9
+ }
10
+ const index = new Map();
11
+ const low = new Map();
12
+ const onStack = new Set();
13
+ const stack = [];
14
+ const result = new Set();
15
+ let counter = 0;
16
+ for (const start of adjacency.keys()) {
17
+ if (index.has(start))
18
+ continue;
19
+ const work = [{ node: start, next: 0 }];
20
+ index.set(start, counter);
21
+ low.set(start, counter);
22
+ counter += 1;
23
+ stack.push(start);
24
+ onStack.add(start);
25
+ while (work.length > 0) {
26
+ const frame = work[work.length - 1];
27
+ const neighbours = adjacency.get(frame.node);
28
+ if (frame.next < neighbours.length) {
29
+ const to = neighbours[frame.next];
30
+ frame.next += 1;
31
+ if (!index.has(to)) {
32
+ index.set(to, counter);
33
+ low.set(to, counter);
34
+ counter += 1;
35
+ stack.push(to);
36
+ onStack.add(to);
37
+ work.push({ node: to, next: 0 });
38
+ }
39
+ else if (onStack.has(to)) {
40
+ low.set(frame.node, Math.min(low.get(frame.node), index.get(to)));
41
+ }
42
+ continue;
43
+ }
44
+ work.pop();
45
+ const parent = work[work.length - 1];
46
+ if (parent)
47
+ low.set(parent.node, Math.min(low.get(parent.node), low.get(frame.node)));
48
+ if (low.get(frame.node) === index.get(frame.node)) {
49
+ const component = [];
50
+ for (;;) {
51
+ const member = stack.pop();
52
+ onStack.delete(member);
53
+ component.push(member);
54
+ if (member === frame.node)
55
+ break;
56
+ }
57
+ if (component.length >= 2)
58
+ for (const member of component)
59
+ result.add(member);
60
+ }
61
+ }
62
+ }
63
+ return result;
64
+ }
@@ -0,0 +1,371 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import ts from 'typescript';
5
+ import { classifyImports, hasUseClient } from './classify.js';
6
+ import { findDeclaredConfig } from './config.js';
7
+ import { isDomainFile, isImpure } from './domain.js';
8
+ import { presetRules } from './rules.js';
9
+ import { readPackageJson } from '../../../manifest/package-json.js';
10
+ import { trackedPaths } from '../../../scope/tracked.js';
11
+ import { ciInvokes } from '../../../scripts/expand.js';
12
+ import { nearestTsConfig } from '../../ts/tsconfig.js';
13
+ import { choosePreset, globLayers, globMatcher, moduleDirs, publicEntryGlob } from '../presets.js';
14
+ import { edgeKey } from '../types.js';
15
+ const GROUP = 'typescript_javascript';
16
+ const TOOL = 'dependency-cruiser';
17
+ function setup(ctx) {
18
+ return ctx.tools.memo(`d3:ts:setup:${ctx.repoPath}`, async () => {
19
+ const warnings = [];
20
+ const tracked = new Set(trackedPaths(ctx.repoPath));
21
+ const byPackage = new Map();
22
+ for (const file of ctx.scope.files) {
23
+ if (file.group !== GROUP)
24
+ continue;
25
+ const list = byPackage.get(file.package) ?? [];
26
+ list.push(file);
27
+ byPackage.set(file.package, list);
28
+ }
29
+ const ciHits = ciInvokes(ctx.repoPath, ['depcruise', 'dependency-cruiser'], { warnings });
30
+ const configCache = new Map();
31
+ const units = [];
32
+ for (const [pkg, files] of [...byPackage].sort(([a], [b]) => a.localeCompare(b))) {
33
+ const logic = files.filter((f) => f.role === 'logic');
34
+ if (logic.length === 0)
35
+ continue;
36
+ const manifestPath = path.posix.join(pkg, 'package.json');
37
+ const manifest = readPackageJson(ctx.repoPath, pkg);
38
+ if (manifest === undefined && tracked.has(manifestPath))
39
+ warnings.push(`${manifestPath}: invalid JSON, treated as absent`);
40
+ const deps = manifest?.dependencies ?? {};
41
+ const preset = choosePreset(ctx.methodology, GROUP, { nextjs: 'next' in deps, express: 'express' in deps && !('next' in deps) });
42
+ const declared = findDeclaredConfig(ctx.repoPath, pkg, warnings, configCache);
43
+ const weight = logic.reduce((sum, f) => sum + f.logicLines, 0);
44
+ const level31 = declared === undefined ? 'none' : ciHits.length > 0 ? 'enforced' : 'declared';
45
+ units.push({
46
+ package: pkg,
47
+ files,
48
+ logic,
49
+ weight,
50
+ preset,
51
+ declared,
52
+ rules: {
53
+ package: pkg,
54
+ group: GROUP,
55
+ weight,
56
+ preset: preset.preset,
57
+ rulesSource: declared === undefined ? 'preset' : 'declared',
58
+ ...(declared === undefined ? {} : { rulesFile: declared.file }),
59
+ level31,
60
+ },
61
+ });
62
+ }
63
+ return { units, warnings };
64
+ });
65
+ }
66
+ /** The installed version, read from the package's own manifest (its `exports` map hides `package.json`). */
67
+ function dcVersion() {
68
+ for (let dir = path.dirname(fileURLToPath(import.meta.url)); dir !== path.dirname(dir); dir = path.dirname(dir)) {
69
+ const manifest = path.join(dir, 'node_modules', TOOL, 'package.json');
70
+ if (!existsSync(manifest))
71
+ continue;
72
+ try {
73
+ return JSON.parse(readFileSync(manifest, 'utf8')).version;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ return undefined;
80
+ }
81
+ const configHost = { ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => undefined };
82
+ async function runCruise(cruise, input, extensions, warnings) {
83
+ const attempt = async (tsConfig) => {
84
+ // `paths` aliases need the parsed config as the transpile options; without it the resolver reads `baseUrl` from the process cwd.
85
+ const resolveOptions = {
86
+ extensionAlias: { '.js': ['.ts', '.tsx', '.js'], '.mjs': ['.mts', '.mjs'], '.cjs': ['.cts', '.cjs'] },
87
+ extensions,
88
+ };
89
+ const parsed = tsConfig === undefined ? undefined : ts.getParsedCommandLineOfConfigFile(tsConfig, {}, configHost);
90
+ if (tsConfig !== undefined && parsed === undefined)
91
+ throw new Error('the config could not be parsed');
92
+ const hasRules = input.ruleSet !== undefined && ((input.ruleSet.forbidden?.length ?? 0) > 0 || (input.ruleSet.allowed?.length ?? 0) > 0);
93
+ const options = {
94
+ baseDir: input.baseDir,
95
+ validate: hasRules,
96
+ cache: false,
97
+ tsPreCompilationDeps: true,
98
+ doNotFollow: { path: 'node_modules' },
99
+ ...(hasRules ? { ruleSet: input.ruleSet } : {}),
100
+ ...(tsConfig === undefined ? {} : { tsConfig: { fileName: tsConfig } }),
101
+ };
102
+ // dependency-cruiser gives tsconfig-paths-webpack-plugin `baseUrl: "./"` when the config has no baseUrl, and the plugin resolves
103
+ // that against the process cwd, so `paths` without `baseUrl` (the Next.js default) pointed at the wrong directory and every alias
104
+ // import was unresolved. With a baseUrl in the transpile options it passes none and the plugin uses the config's own directory.
105
+ const transpile = parsed !== undefined && parsed.options.paths !== undefined && parsed.options.baseUrl === undefined
106
+ ? { ...parsed, options: { ...parsed.options, baseUrl: String(parsed.options.pathsBasePath ?? path.dirname(tsConfig)) } }
107
+ : parsed;
108
+ const result = await cruise(input.files, options, resolveOptions, transpile === undefined ? undefined : { tsConfig: transpile });
109
+ const output = result.output;
110
+ return { modules: output.modules ?? [], violations: output.summary?.violations ?? [] };
111
+ };
112
+ if (input.tsConfig === undefined)
113
+ return attempt(undefined);
114
+ try {
115
+ return await attempt(input.tsConfig);
116
+ }
117
+ catch (err) {
118
+ warnings.push(`${path.relative(input.baseDir, input.tsConfig)}: could not be used by ${TOOL}, aliases are unresolved (${err instanceof Error ? err.message : String(err)})`);
119
+ return attempt(undefined);
120
+ }
121
+ }
122
+ function isTypeOnlyEdge(occurrences) {
123
+ return occurrences.length > 0 && occurrences.every((o) => o.typeOnly);
124
+ }
125
+ function isRuntimeEdge(occurrences) {
126
+ if (occurrences.length === 0)
127
+ return true;
128
+ return occurrences.some((o) => (o.kind === 'static' || o.kind === 'require') && o.topLevel && !o.typeOnly);
129
+ }
130
+ export const tsAdapter = {
131
+ group: GROUP,
132
+ async rules(ctx) {
133
+ const { units, warnings } = await setup(ctx);
134
+ return { units: units.map((u) => u.rules), warnings };
135
+ },
136
+ async graph(ctx) {
137
+ return ctx.tools.memo(`d3:ts:graph:${ctx.repoPath}`, () => buildGraph(ctx));
138
+ },
139
+ };
140
+ async function buildGraph(ctx) {
141
+ const { units } = await setup(ctx);
142
+ const warnings = [];
143
+ let cruise;
144
+ try {
145
+ cruise = (await import(TOOL)).cruise;
146
+ }
147
+ catch (err) {
148
+ return { unavailable: `${TOOL} could not be loaded: ${err instanceof Error ? err.message : String(err)}` };
149
+ }
150
+ const version = dcVersion();
151
+ if (version !== undefined)
152
+ ctx.tools.recordVersion(TOOL, version);
153
+ // dependency-cruiser follows symlinks, so its resolved paths are relative to the real repo root (macOS tmpdir sits behind /var -> /private/var).
154
+ const root = realpathSync(ctx.repoPath);
155
+ const extensions = [...ctx.methodology.scope.source_files.extensions.typescript, ...ctx.methodology.scope.source_files.extensions.javascript];
156
+ const scoped = new Map();
157
+ for (const file of ctx.scope.files)
158
+ if (file.group === GROUP)
159
+ scoped.set(file.path, file);
160
+ const unitByPackage = new Map(units.map((u) => [u.package, u]));
161
+ const relative = (unit, file) => (unit.package === '.' ? file : file.slice(unit.package.length + 1));
162
+ // Layer classification and the preset rules of preset-governed units.
163
+ const dcRules = [];
164
+ const meta = new Map();
165
+ for (const unit of units) {
166
+ if (unit.declared !== undefined)
167
+ continue;
168
+ const rels = unit.files.map((f) => relative(unit, f.path));
169
+ const toFull = new Map(rels.map((rel, i) => [rel, unit.files[i].path]));
170
+ const layerFiles = new Map();
171
+ const add = (layer, rel) => {
172
+ const set = layerFiles.get(layer) ?? new Set();
173
+ set.add(toFull.get(rel));
174
+ layerFiles.set(layer, set);
175
+ };
176
+ for (const [rel, layer] of globLayers(unit.preset.layers, rels))
177
+ add(layer, rel);
178
+ const contentLayers = Object.entries(unit.preset.layers).filter((entry) => typeof entry[1] === 'string').map(([name]) => name);
179
+ if (contentLayers.includes('client') || contentLayers.includes('server_only')) {
180
+ const isServerDir = globMatcher(['server/**']);
181
+ for (const rel of rels) {
182
+ const full = toFull.get(rel);
183
+ const source = ctx.scope.getTsSourceFile(full);
184
+ if (contentLayers.includes('client') && hasUseClient(source))
185
+ add('client', rel);
186
+ if (contentLayers.includes('server_only')) {
187
+ const imports = classifyImports(source).some((o) => o.specifier === 'server-only');
188
+ if (imports || path.posix.basename(rel).includes('.server.') || isServerDir(rel))
189
+ add('server_only', rel);
190
+ }
191
+ }
192
+ }
193
+ const generated = presetRules(unit.package, unit.preset.rules, layerFiles, unit.files.map((f) => f.path));
194
+ dcRules.push(...generated.rules);
195
+ for (const [name, value] of generated.meta)
196
+ meta.set(name, value);
197
+ }
198
+ // One graph call per distinct tsconfig; every call carries all generated preset rules.
199
+ const groups = new Map();
200
+ for (const unit of units) {
201
+ const key = nearestTsConfig(root, unit.package === '.' ? '' : unit.package) ?? '';
202
+ const list = groups.get(key) ?? [];
203
+ list.push(...unit.files.map((f) => f.path));
204
+ groups.set(key, list);
205
+ }
206
+ const allEdges = new Map();
207
+ const violations = [];
208
+ try {
209
+ for (const [tsConfig, files] of groups) {
210
+ const result = await runCruise(cruise, { baseDir: root, files, tsConfig: tsConfig === '' ? undefined : tsConfig, ruleSet: { forbidden: dcRules } }, extensions, warnings);
211
+ for (const module of result.modules) {
212
+ if (!scoped.has(module.source))
213
+ continue;
214
+ const perTarget = allEdges.get(module.source) ?? new Map();
215
+ for (const dependency of module.dependencies) {
216
+ if (dependency.resolved === module.source)
217
+ continue;
218
+ const specifiers = perTarget.get(dependency.resolved) ?? new Set();
219
+ specifiers.add(dependency.module);
220
+ perTarget.set(dependency.resolved, specifiers);
221
+ }
222
+ allEdges.set(module.source, perTarget);
223
+ }
224
+ violations.push(...result.violations);
225
+ }
226
+ // One extra call per distinct declared rules file; it supplies only violations.
227
+ const declaredFiles = new Map();
228
+ for (const unit of units) {
229
+ if (unit.declared === undefined)
230
+ continue;
231
+ const entry = declaredFiles.get(unit.declared.file) ?? { config: unit.declared, governed: new Set() };
232
+ entry.governed.add(unit.package);
233
+ declaredFiles.set(unit.declared.file, entry);
234
+ }
235
+ for (const { config, governed } of declaredFiles.values()) {
236
+ const dir = path.posix.dirname(config.file);
237
+ const prefix = dir === '.' ? '' : `${dir}/`;
238
+ const files = [...scoped.keys()].filter((f) => f.startsWith(prefix)).map((f) => f.slice(prefix.length));
239
+ if (files.length === 0)
240
+ continue;
241
+ const baseDir = dir === '.' ? root : path.join(root, dir);
242
+ const ruleSet = {
243
+ forbidden: config.forbidden,
244
+ ...(config.allowed ? { allowed: config.allowed, allowedSeverity: config.allowedSeverity } : {}),
245
+ };
246
+ const result = await runCruise(cruise, { baseDir, files, tsConfig: nearestTsConfig(root, dir), ruleSet }, extensions, warnings);
247
+ for (const violation of result.violations) {
248
+ const from = path.posix.join(dir, violation.from);
249
+ const unit = unitByPackage.get(scoped.get(from)?.package ?? '');
250
+ if (!unit || !governed.has(unit.package))
251
+ continue;
252
+ const joined = path.posix.join(dir, violation.to);
253
+ const local = (violation.dependencyTypes ?? []).includes('local');
254
+ if (scoped.has(joined))
255
+ violations.push({ ...violation, from, to: joined });
256
+ else if (!local)
257
+ violations.push({ ...violation, from });
258
+ }
259
+ }
260
+ }
261
+ catch (err) {
262
+ return { unavailable: `${TOOL} failed: ${err instanceof Error ? err.message : String(err)}` };
263
+ }
264
+ // Facts per unit.
265
+ const occurrencesOf = new Map();
266
+ const occurrences = (file) => {
267
+ let list = occurrencesOf.get(file);
268
+ if (!list) {
269
+ list = classifyImports(ctx.scope.getTsSourceFile(file));
270
+ occurrencesOf.set(file, list);
271
+ }
272
+ return list;
273
+ };
274
+ const edgeOccurrences = (from, to) => {
275
+ const specifiers = allEdges.get(from)?.get(to);
276
+ return specifiers ? occurrences(from).filter((o) => specifiers.has(o.specifier)) : [];
277
+ };
278
+ const moduleOf = new Map();
279
+ const entryOf = new Set();
280
+ const trackedSet = new Set(trackedPaths(ctx.repoPath));
281
+ const entryChecks = new Map();
282
+ for (const unit of units) {
283
+ const roots = Array.isArray(unit.preset.modules.roots) ? unit.preset.modules.roots : [];
284
+ const entryGlob = publicEntryGlob(unit.preset, GROUP);
285
+ const isEntry = entryGlob === undefined ? () => false : globMatcher([entryGlob.includes('/') ? entryGlob : `**/${entryGlob}`]);
286
+ entryChecks.set(unit.package, { roots, isEntry });
287
+ const rels = unit.files.map((f) => relative(unit, f.path));
288
+ const modules = moduleDirs(roots, rels);
289
+ unit.files.forEach((file, i) => {
290
+ const rel = rels[i];
291
+ const dir = modules.get(rel);
292
+ const full = dir === undefined ? undefined : unit.package === '.' ? dir : `${unit.package}/${dir}`;
293
+ moduleOf.set(file.path, full);
294
+ if (dir !== undefined && path.posix.dirname(rel) === dir && isEntry(rel))
295
+ entryOf.add(file.path);
296
+ });
297
+ }
298
+ const result = [];
299
+ for (const unit of units) {
300
+ const unitFiles = new Set(unit.files.map((f) => f.path));
301
+ const edges = [];
302
+ for (const file of unit.files) {
303
+ for (const to of [...(allEdges.get(file.path)?.keys() ?? [])].sort()) {
304
+ if (!scoped.has(to))
305
+ continue;
306
+ const found = edgeOccurrences(file.path, to);
307
+ edges.push({ from: file.path, to, fromTest: file.role === 'test', runtime: isRuntimeEdge(found) });
308
+ }
309
+ }
310
+ const violating = new Set();
311
+ const violationRules = {};
312
+ for (const violation of violations) {
313
+ if (violation.type !== 'dependency' || (violation.rule.severity !== 'error' && violation.rule.severity !== 'warn'))
314
+ continue;
315
+ if (!unitFiles.has(violation.from) || scoped.get(violation.from)?.role === 'test')
316
+ continue;
317
+ const info = meta.get(violation.rule.name);
318
+ if (info?.excludesTypeOnly && isTypeOnlyEdge(edgeOccurrences(violation.from, violation.to)))
319
+ continue;
320
+ const key = edgeKey(violation.from, violation.to);
321
+ violating.add(key);
322
+ (violationRules[key] ??= []).push(info?.rule ?? violation.rule.name);
323
+ }
324
+ const logicFiles = unit.logic.map((f) => f.path).sort();
325
+ const moduleDirsOfUnit = new Set(logicFiles.map((f) => moduleOf.get(f)).filter((m) => m !== undefined));
326
+ const crossings = [];
327
+ if (moduleDirsOfUnit.size >= 2) {
328
+ const prefix = unit.package === '.' ? '' : `${unit.package}/`;
329
+ const check = entryChecks.get(unit.package);
330
+ for (const file of unit.files) {
331
+ if (file.role === 'test')
332
+ continue;
333
+ const fromModule = moduleOf.get(file.path);
334
+ if (fromModule === undefined)
335
+ continue;
336
+ for (const to of [...(allEdges.get(file.path)?.keys() ?? [])].sort()) {
337
+ let toModule;
338
+ let toEntry;
339
+ if (scoped.has(to)) {
340
+ toModule = moduleOf.get(to);
341
+ toEntry = entryOf.has(to);
342
+ }
343
+ else if (trackedSet.has(to) && to.startsWith(prefix) && !to.includes('node_modules/')) {
344
+ // A barrel is not a scoped file, but it is the canonical public entry: count the crossing.
345
+ const rel = to.slice(prefix.length);
346
+ const dir = moduleDirs(check.roots, [rel]).get(rel);
347
+ toModule = dir === undefined ? undefined : `${prefix}${dir}`;
348
+ toEntry = dir !== undefined && path.posix.dirname(rel) === dir && check.isEntry(rel);
349
+ }
350
+ else
351
+ continue;
352
+ if (toModule !== undefined && toModule !== fromModule)
353
+ crossings.push({ from: file.path, to, toEntry });
354
+ }
355
+ }
356
+ }
357
+ const domainFiles = logicFiles.filter(isDomainFile);
358
+ const impure = domainFiles.filter((file) => isImpure(ctx.scope.getTsSourceFile(file), edges.filter((e) => e.from === file).map((e) => e.to), ctx.methodology.d3));
359
+ result.push({
360
+ ...unit.rules,
361
+ logicFiles,
362
+ edges,
363
+ violating: [...violating].sort(),
364
+ violationRules,
365
+ moduleCount: moduleDirsOfUnit.size,
366
+ crossings,
367
+ domain: { files: domainFiles, impure },
368
+ });
369
+ }
370
+ return { units: result, warnings };
371
+ }