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,58 @@
1
+ import { roundHalfUp } from '../scoring/aggregate.js';
2
+ import { healthWith, nextCandidate } from '../scoring/what-if.js';
3
+ import { rawText } from './format.js';
4
+ export const ROADMAP_LIMIT = 10;
5
+ const STDOUT_LIMIT = 3;
6
+ const NO_OVERRIDES = new Map();
7
+ /** The descriptor of the lowest-scoring anchor or level above `score`. Null when there is none or the parameter has no descriptors. */
8
+ export function targetDescriptor(def, score) {
9
+ return nextCandidate(def, score)?.descriptor ?? null;
10
+ }
11
+ /**
12
+ * The parameters ranked by Gain: the whole health points the displayed health would rise by if that one parameter
13
+ * alone reached 100. Parameters at 100 and the not applicable or not measured ones are not listed. Empty when the
14
+ * run has no health score. Items with no Gain are listed only to fill the list up to the limit.
15
+ */
16
+ export function buildRoadmap(run, methodology) {
17
+ if (!run.health)
18
+ return [];
19
+ const base = healthWith(run, methodology, NO_OVERRIDES);
20
+ if (base.health === null)
21
+ return [];
22
+ const items = [];
23
+ const dimensionOf = new Map();
24
+ for (const dim of methodology.dimensions) {
25
+ for (const def of dim.parameters) {
26
+ const param = run.parameters.find((p) => p.id === def.id);
27
+ if (!param || (param.status !== 'measured' && param.status !== 'failed'))
28
+ continue;
29
+ const score = param.status === 'failed' ? 0 : (param.score ?? 0);
30
+ if (score >= 100)
31
+ continue;
32
+ const after = healthWith(run, methodology, new Map([[def.id, 100]])).health ?? base.health;
33
+ items.push({
34
+ id: def.id,
35
+ name: def.name,
36
+ dimensionId: dim.id,
37
+ dimensionName: dim.name,
38
+ raw: param.status === 'measured' ? rawText(param, def) : null,
39
+ score,
40
+ gain: roundHalfUp(after) - run.health.displayed,
41
+ gainPrecise: after - base.health,
42
+ target: targetDescriptor(def, score),
43
+ });
44
+ dimensionOf.set(def.id, base.dimension.get(dim.id) ?? 0);
45
+ }
46
+ }
47
+ items.sort((a, b) => b.gain - a.gain || b.gainPrecise - a.gainPrecise || (dimensionOf.get(a.id) ?? 0) - (dimensionOf.get(b.id) ?? 0) || a.id.localeCompare(b.id, 'en', { numeric: true }));
48
+ return items.slice(0, ROADMAP_LIMIT);
49
+ }
50
+ export function gainText(gain) {
51
+ return gain > 0 ? `+${gain}` : '+0 · no gain';
52
+ }
53
+ /** The top lines `health report` prints after the report path: `<rank>. <id> <name> (<dimension>) +<gain> — <target>`. */
54
+ export function roadmapLines(run, methodology) {
55
+ return buildRoadmap(run, methodology)
56
+ .slice(0, STDOUT_LIMIT)
57
+ .map((item, i) => `${i + 1}. ${item.id} ${item.name} (${item.dimensionName}) ${gainText(item.gain)}${item.target ? ` — ${item.target}` : ''}`);
58
+ }
@@ -0,0 +1,83 @@
1
+ export const SCARE_LIMIT = 3;
2
+ export const NO_SCARES = 'Nothing in the checks below is a scare. The scan looks at committed secrets and known advisories, not access rules, so this is not a clean bill of health.';
3
+ const records = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'object' && v !== null) : null;
4
+ const text = (value) => (typeof value === 'string' || typeof value === 'number' ? String(value) : '');
5
+ const SEVERITY_RANK = { critical: 4, high: 3, moderate: 2, low: 1 };
6
+ const measured = (param) => param?.status === 'measured';
7
+ function levels(param) {
8
+ if (!measured(param))
9
+ return [];
10
+ const fromRaw = param.raw?.kind === 'level' ? [param.raw.level] : [];
11
+ return [...fromRaw, ...(param.units ?? []).flatMap((u) => (u.raw.kind === 'level' ? [u.raw.level] : []))];
12
+ }
13
+ function secretScares(run, param) {
14
+ if (!measured(param))
15
+ return [];
16
+ const only = run.reportOnly ?? {};
17
+ const tree = records(only.secretsInTree) ?? [];
18
+ const scares = [];
19
+ if (tree.some((f) => f.supabaseRole === 'service_role')) {
20
+ scares.push('Anyone with access to your git repo can read and change all the data in your Supabase.');
21
+ }
22
+ // A Supabase anon key is public by design, so it is not a leaked secret.
23
+ const others = tree.filter((f) => f.supabaseRole !== 'service_role' && f.supabaseRole !== 'anon');
24
+ const first = others[0];
25
+ if (first) {
26
+ const more = others.length > 1 ? ` (${others.length} secrets in all)` : '';
27
+ scares.push(`Anyone with access to your git repo can use a secret committed in ${text(first.file)} (${text(first.ruleId)})${more}.`);
28
+ }
29
+ return scares;
30
+ }
31
+ function dependencyScare(run, param) {
32
+ if (!measured(param) || (param.score ?? 100) > 50)
33
+ return [];
34
+ const advisories = records(run.reportOnly?.productionDependencyVulnerabilities?.advisories) ?? [];
35
+ const worst = [...advisories].sort((a, b) => (SEVERITY_RANK[text(b.severity)] ?? 0) - (SEVERITY_RANK[text(a.severity)] ?? 0))[0];
36
+ const detail = worst
37
+ ? ` (${advisories.length} known ${advisories.length === 1 ? 'advisory' : 'advisories'}, the worst in ${text(worst.name)} ${text(worst.version)})`
38
+ : '';
39
+ return [`Your live app runs on dependencies with publicly known security holes${detail}.`];
40
+ }
41
+ function historyScare(run, param) {
42
+ if (!measured(param))
43
+ return [];
44
+ const history = records(run.reportOnly?.secretsInHistory?.findings) ?? [];
45
+ const inTree = new Set((records(run.reportOnly?.secretsInTree) ?? []).map((f) => `${text(f.file)}\0${text(f.ruleId)}`));
46
+ const gone = history.filter((f) => !inTree.has(`${text(f.file)}\0${text(f.ruleId)}`));
47
+ if (gone.length === 0)
48
+ return [];
49
+ return [`Deleting a key from the code did not kill it: ${gone.length === 1 ? 'a secret is' : `${gone.length} secrets are`} still readable in your git history.`];
50
+ }
51
+ /**
52
+ * The scares in severity order: plain-English consequences, not metrics. Only measured or failed parameters trigger
53
+ * one, so a preview simply has fewer. Never padded. Report only, never scored.
54
+ */
55
+ export function buildScares(run) {
56
+ if (!run.valid)
57
+ return [];
58
+ const param = (id) => run.parameters.find((p) => p.id === id);
59
+ const scoreOf = (id) => {
60
+ const p = param(id);
61
+ return p && (p.status === 'measured' || p.status === 'failed') ? (p.status === 'failed' ? 0 : (p.score ?? 0)) : null;
62
+ };
63
+ const scares = [...secretScares(run, param('5.4')), ...dependencyScare(run, param('5.1')), ...historyScare(run, param('5.4'))];
64
+ if (levels(param('6.1')).some((l) => l === 'install_fails' || l === 'build_fails')) {
65
+ scares.push("A clean copy of this code doesn't install or build.");
66
+ }
67
+ if (scoreOf('1.1') === 0 || scoreOf('1.2') === 0)
68
+ scares.push('Nothing checks your changes, so your users find the breakage first.');
69
+ if (levels(param('5.3')).includes('none'))
70
+ scares.push('Two installs of the same commit can ship different code.');
71
+ if (scoreOf('6.4') === 0)
72
+ scares.push('Nothing stops broken code from reaching main.');
73
+ return scares.slice(0, SCARE_LIMIT);
74
+ }
75
+ /** What the scan could not check, named so an empty list is not read as clean. */
76
+ export function unchecked(run) {
77
+ const names = [['5.4', 'secrets'], ['5.1', 'dependency advisories']];
78
+ return names.filter(([id]) => !run.parameters.some((p) => p.id === id && (p.status === 'measured' || p.status === 'failed'))).map(([, name]) => name);
79
+ }
80
+ /** The lines `health report` prints after the Roadmap lines: `Scare: <text>`. */
81
+ export function scareLines(run) {
82
+ return buildScares(run).map((s) => `Scare: ${s}`);
83
+ }
@@ -0,0 +1,27 @@
1
+ /** Inline CSS. No external font, image or request. */
2
+ export const STYLE = `
3
+ :root { color-scheme: light dark; --bg: #fafaf8; --fg: #1c1c1a; --muted: #6b6b66; --line: #dcdcd4; --accent: #2b5fd9; --warn: #a15c00; --card: #ffffff; }
4
+ @media (prefers-color-scheme: dark) { :root { --bg: #151514; --fg: #ecece6; --muted: #9a9a92; --line: #33332f; --accent: #7aa2ff; --warn: #e0a040; --card: #1e1e1c; } }
5
+ * { box-sizing: border-box; }
6
+ body { margin: 0; padding: 32px 16px 64px; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
7
+ main { max-width: 960px; margin: 0 auto; }
8
+ h1 { font-size: 22px; margin: 0 0 4px; }
9
+ h2 { font-size: 17px; margin: 40px 0 8px; padding-top: 16px; border-top: 1px solid var(--line); }
10
+ .muted { color: var(--muted); }
11
+ .health { display: flex; gap: 24px; align-items: baseline; margin: 16px 0 8px; }
12
+ .health .score { font-size: 56px; font-weight: 700; line-height: 1; }
13
+ .health .band { font-size: 22px; font-weight: 600; }
14
+ .label { display: inline-block; padding: 2px 10px; border: 1px solid var(--warn); color: var(--warn); border-radius: 4px; font-weight: 600; }
15
+ table { width: 100%; border-collapse: collapse; background: var(--card); font-size: 14px; }
16
+ th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
17
+ th { font-weight: 600; color: var(--muted); }
18
+ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
19
+ .bar { position: relative; min-width: 80px; height: 10px; background: var(--line); border-radius: 5px; overflow: hidden; margin: 6px 0; }
20
+ .bar span { display: block; height: 100%; background: var(--accent); }
21
+ .bar i { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--bg); }
22
+ a { color: var(--accent); }
23
+ code { font: 13px ui-monospace, monospace; word-break: break-all; }
24
+ .status-not_measured, .status-not_applicable { color: var(--muted); }
25
+ .status-failed { color: var(--warn); font-weight: 600; }
26
+ .note { color: var(--warn); margin: 4px 0 8px; }
27
+ `;
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ const HOTSPOT_LIMIT = 20;
3
+ export const SHALLOW_WARNING = 'history is shallow; churn is a lower bound';
4
+ /** `[asOf minus 12 months, end of asOf day]`, UTC, as ISO strings git accepts. */
5
+ export function churnWindow(asOf) {
6
+ const start = new Date(`${asOf}T00:00:00Z`);
7
+ start.setUTCFullYear(start.getUTCFullYear() - 1);
8
+ return { since: `${start.toISOString().slice(0, 10)}T00:00:00+00:00`, until: `${asOf}T23:59:59+00:00` };
9
+ }
10
+ function git(repoPath, args) {
11
+ const result = spawnSync('git', ['-C', repoPath, ...args], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
12
+ return { status: result.error ? null : result.status, stdout: result.stdout };
13
+ }
14
+ /** Commits per path in the window, merges and renames ignored. */
15
+ function churnByPath(repoPath, asOf) {
16
+ const { since, until } = churnWindow(asOf);
17
+ const log = git(repoPath, ['log', `--since=${since}`, `--until=${until}`, '--no-merges', '--no-renames', '--name-only', '-z', '--format=']);
18
+ if (log.status !== 0)
19
+ return null;
20
+ const churn = new Map();
21
+ for (const name of log.stdout.split('\0')) {
22
+ if (name === '')
23
+ continue;
24
+ churn.set(name, (churn.get(name) ?? 0) + 1);
25
+ }
26
+ return churn;
27
+ }
28
+ /**
29
+ * Files ranked by churn times complexity. Files with no churn or no complexity are left out.
30
+ * Only the files in `complexity` (logic files in scope that exist at HEAD) can appear.
31
+ */
32
+ export function computeHotspots(repoPath, asOf, complexity) {
33
+ const warnings = [];
34
+ const churn = churnByPath(repoPath, asOf);
35
+ if (churn === null)
36
+ return { hotspots: [], warnings: ['hotspots: git history could not be read'] };
37
+ if (git(repoPath, ['rev-parse', '--is-shallow-repository']).stdout.trim() === 'true')
38
+ warnings.push(SHALLOW_WARNING);
39
+ const hotspots = [];
40
+ for (const entry of complexity) {
41
+ const commits = churn.get(entry.file) ?? 0;
42
+ if (commits === 0 || entry.complexitySum === 0)
43
+ continue;
44
+ hotspots.push({ path: entry.file, churn: commits, complexity: entry.complexitySum, score: commits * entry.complexitySum });
45
+ }
46
+ hotspots.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
47
+ return { hotspots: hotspots.slice(0, HOTSPOT_LIMIT), warnings };
48
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ROUTE_FILE, SLICE_FILE, TASK_PLACEHOLDER } from './files.js';
4
+ /** The problems with `dir` compared to the files regenerated from run.json. Empty when the facts match and every Task is written. */
5
+ export function checkRouteFiles(dir, expected) {
6
+ if (!existsSync(dir))
7
+ return [`${dir} does not exist`];
8
+ const problems = [];
9
+ const known = new Set(expected.map((f) => f.name));
10
+ for (const name of readdirSync(dir)) {
11
+ if ((name === ROUTE_FILE || SLICE_FILE.test(name)) && !known.has(name))
12
+ problems.push(`${name}: not part of this Route`);
13
+ }
14
+ for (const file of expected) {
15
+ const full = path.join(dir, file.name);
16
+ if (!existsSync(full)) {
17
+ problems.push(`${file.name}: missing`);
18
+ continue;
19
+ }
20
+ const actual = readFileSync(full, 'utf8');
21
+ if (!actual.startsWith(file.fixed)) {
22
+ problems.push(`${file.name}: facts differ from run.json`);
23
+ continue;
24
+ }
25
+ if (file.name === ROUTE_FILE) {
26
+ if (actual !== file.content)
27
+ problems.push(`${file.name}: facts differ from run.json`);
28
+ continue;
29
+ }
30
+ const task = actual.slice(file.fixed.length);
31
+ if (task.includes(TASK_PLACEHOLDER))
32
+ problems.push(`${file.name}: Task placeholder not replaced`);
33
+ else if (task.trim() === '')
34
+ problems.push(`${file.name}: Task is empty`);
35
+ }
36
+ return problems;
37
+ }
@@ -0,0 +1,74 @@
1
+ import { nearestDescriptor, rawText } from '../report/format.js';
2
+ import { outOfReachLine } from './select.js';
3
+ export const FACTS_START = '<!-- health:facts:start -->';
4
+ export const FACTS_END = '<!-- health:facts:end -->';
5
+ export const TASK_PLACEHOLDER = '<!-- health:task -->';
6
+ export const ROUTE_FILE = 'ROUTE.md';
7
+ export const SLICE_FILE = /^\d{2}-.+\.md$/;
8
+ const WHERE_LIMIT = 10;
9
+ const kebab = (text) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
10
+ export function sliceFileName(slice) {
11
+ return `${String(slice.position).padStart(2, '0')}-${slice.id.replace('.', '-')}-${kebab(slice.name)}.md`;
12
+ }
13
+ function whereLines(run, slice, param) {
14
+ const units = [...(param?.units ?? [])]
15
+ .sort((a, b) => a.score - b.score || a.package.localeCompare(b.package) || a.group.localeCompare(b.group))
16
+ .slice(0, WHERE_LIMIT)
17
+ .map((u) => `- package \`${u.package}\` (${u.group}), score ${Math.round(u.score)}`);
18
+ const hotspots = slice.id === '4.1' && Array.isArray(run.reportOnly?.hotspots)
19
+ ? run.reportOnly.hotspots
20
+ .flatMap((h) => (typeof h === 'object' && h !== null && typeof h.path === 'string' ? [h.path] : []))
21
+ .slice(0, WHERE_LIMIT)
22
+ .map((file) => `- file \`${file}\` (hotspot)`)
23
+ : [];
24
+ const lines = [...units, ...hotspots];
25
+ return lines.length > 0 ? lines : ['- the whole repository'];
26
+ }
27
+ function sliceFacts(run, def, slice) {
28
+ const param = run.parameters.find((p) => p.id === slice.id);
29
+ const current = param?.status === 'measured' ? rawText(param, def) : 'no usable result';
30
+ const nearest = nearestDescriptor(def, slice.fromScore);
31
+ return [
32
+ FACTS_START,
33
+ `**Goal:** Raise ${slice.id} ${slice.name} from ${Math.round(slice.fromScore)} to ${Math.round(slice.toScore)}: "${slice.descriptor}"`,
34
+ '',
35
+ `**Why:** Today it is ${current} (score ${Math.round(slice.fromScore)})${nearest ? `, which the methodology describes as "${nearest}"` : ''}.`,
36
+ '',
37
+ '**Where:**',
38
+ ...whereLines(run, slice, param),
39
+ '',
40
+ `**Health effect:** health ${slice.healthBefore} -> ${slice.healthAfter} once this Slice and the ones before it are done.`,
41
+ '',
42
+ `**Done when:** re-running /codebase-health shows ${slice.id} ${slice.name} at ${Math.round(slice.toScore)} or higher ("${slice.descriptor}").`,
43
+ '',
44
+ '**Rules:** Raise the score by changing the code, not the measurement. Do not edit .healthignore, .gitleaksignore, lint, type or test configuration to hide findings, and do not delete, skip or weaken tests.',
45
+ FACTS_END,
46
+ ].join('\n');
47
+ }
48
+ function sliceFile(run, methodology, slice) {
49
+ const def = methodology.dimensions.flatMap((d) => d.parameters).find((p) => p.id === slice.id);
50
+ if (!def)
51
+ throw new Error(`No parameter ${slice.id} in the methodology`);
52
+ const fixed = `# ${String(slice.position).padStart(2, '0')}. ${slice.id} ${slice.name}\n\n${sliceFacts(run, def, slice)}\n\n## Task\n`;
53
+ return { name: sliceFileName(slice), fixed, content: `${fixed}\n${TASK_PLACEHOLDER}\n` };
54
+ }
55
+ function routeIndex(plan) {
56
+ const lines = [`# Route to ${plan.target.name}`, '', `${plan.slices.length} Slices take health from ${plan.from} to ${plan.to}. Hand them to your agent one at a time, in this order, as a goal each.`];
57
+ if (!plan.reached)
58
+ lines.push('', outOfReachLine(plan));
59
+ if (plan.unmeasured.length > 0)
60
+ lines.push('', `Not measured in this run, so this Route leaves them out: ${plan.unmeasured.join(', ')}.`);
61
+ if (plan.slices.length > 0) {
62
+ lines.push('', '| # | Slice | Health after |', '| --- | --- | --- |');
63
+ for (const slice of plan.slices)
64
+ lines.push(`| ${slice.position} | [${slice.id} ${slice.name}](${sliceFileName(slice)}) | ${slice.healthAfter} |`);
65
+ }
66
+ const text = `${lines.join('\n')}\n`;
67
+ return { name: ROUTE_FILE, fixed: text, content: text };
68
+ }
69
+ /** ROUTE.md and one file per Slice, the same on every call for the same run.json. Empty unless the plan is a Route. */
70
+ export function buildRouteFiles(run, methodology, plan) {
71
+ if (plan.kind !== 'route')
72
+ return [];
73
+ return [routeIndex(plan), ...plan.slices.map((slice) => sliceFile(run, methodology, slice))];
74
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The fixed order Slices are written in (ADR 0001): secrets and known vulnerabilities, then the repo installs, builds
3
+ * and verifies, then the safety net, then types and lint, then CI and docs. Structural refactors and major upgrades
4
+ * (3.x, 4.x, 5.2) come last. It is advice about working order, so it lives here and not in the methodology YAML.
5
+ */
6
+ const PRECEDENCE = [
7
+ '5.4', '5.1',
8
+ '6.1', '5.3', '5.5', '6.2', '6.5',
9
+ '1.1', '1.4', '1.2', '1.3', '1.5',
10
+ '2.1', '2.2', '2.5', '2.3', '2.4',
11
+ '6.4', '6.6', '6.3',
12
+ ];
13
+ const LAST_GROUPS = ['3', '4'];
14
+ /** Lower ranks are written first. Parameters not in the list go last: 3.x, then 4.x, then everything else (5.2). */
15
+ export function precedenceRank(id) {
16
+ const listed = PRECEDENCE.indexOf(id);
17
+ if (listed >= 0)
18
+ return listed;
19
+ const [dimension = '', index = '0'] = id.split('.');
20
+ const group = LAST_GROUPS.indexOf(dimension);
21
+ return PRECEDENCE.length + (group >= 0 ? group : LAST_GROUPS.length) * 100 + Number(index);
22
+ }
@@ -0,0 +1,114 @@
1
+ import { roundHalfUp } from '../scoring/aggregate.js';
2
+ import { healthWith, nextCandidate } from '../scoring/what-if.js';
3
+ import { precedenceRank } from './order.js';
4
+ export const PREVIEW_LINE = 'Install Docker for a full run to get your Route';
5
+ /** Vibe-coded and Fragile aim at Stable, Stable at Agent-ready, Agent-ready at nothing. The bounds come from the YAML. */
6
+ export function targetBand(current, methodology) {
7
+ const bands = [...methodology.bands].sort((a, b) => a.min - b.min);
8
+ const stable = bands.findIndex((b) => b.key === 'stable');
9
+ const at = bands.findIndex((b) => b.key === current);
10
+ if (stable < 0 || at < 0 || at > stable)
11
+ return null;
12
+ return at < stable ? (bands[stable] ?? null) : (bands[stable + 1] ?? null);
13
+ }
14
+ function currentScore(run, id, overrides) {
15
+ const override = overrides.get(id);
16
+ if (override !== undefined)
17
+ return override;
18
+ const param = run.parameters.find((p) => p.id === id);
19
+ if (param?.status === 'failed')
20
+ return 0;
21
+ if (param?.status === 'measured')
22
+ return param.score ?? 0;
23
+ return null;
24
+ }
25
+ /** The greedy Steps: each is the one that raises the displayed health most, until the Target band is reached or no Step adds health. */
26
+ function selectSteps(run, methodology, target) {
27
+ const overrides = new Map();
28
+ const steps = [];
29
+ for (;;) {
30
+ const base = healthWith(run, methodology, overrides);
31
+ if (base.health === null)
32
+ return { steps, reached: false };
33
+ const displayed = roundHalfUp(base.health);
34
+ if (displayed >= target.min)
35
+ return { steps, reached: true };
36
+ let best = null;
37
+ for (const dim of methodology.dimensions) {
38
+ for (const def of dim.parameters) {
39
+ const score = currentScore(run, def.id, overrides);
40
+ if (score === null)
41
+ continue;
42
+ const candidate = nextCandidate(def, score);
43
+ if (!candidate)
44
+ continue;
45
+ const after = healthWith(run, methodology, new Map([...overrides, [def.id, candidate.score]])).health;
46
+ if (after === null || after - base.health <= 1e-9)
47
+ continue;
48
+ const entry = {
49
+ step: { id: def.id, name: def.name, dimensionId: dim.id, fromScore: score, toScore: candidate.score, descriptor: candidate.descriptor },
50
+ rise: roundHalfUp(after) - displayed,
51
+ precise: after - base.health,
52
+ dimensionScore: base.dimension.get(dim.id) ?? 0,
53
+ };
54
+ if (!best ||
55
+ entry.rise > best.rise ||
56
+ (entry.rise === best.rise &&
57
+ (entry.precise > best.precise + 1e-9 ||
58
+ (Math.abs(entry.precise - best.precise) <= 1e-9 &&
59
+ (entry.dimensionScore < best.dimensionScore || (entry.dimensionScore === best.dimensionScore && entry.step.id.localeCompare(best.step.id, 'en', { numeric: true }) < 0)))))) {
60
+ best = entry;
61
+ }
62
+ }
63
+ }
64
+ if (!best)
65
+ return { steps, reached: false };
66
+ overrides.set(best.step.id, best.step.toScore);
67
+ steps.push(best.step);
68
+ }
69
+ }
70
+ /** The Route for a run: Steps chosen by Gain, then written as Slices in the fixed precedence with the health after each. */
71
+ export function buildRoute(run, methodology) {
72
+ if (!run.health)
73
+ return { kind: 'none' };
74
+ const target = targetBand(run.health.band, methodology);
75
+ if (!target)
76
+ return { kind: 'none' };
77
+ if (run.mode === 'preview')
78
+ return { kind: 'preview' };
79
+ const { steps, reached } = selectSteps(run, methodology, target);
80
+ const ordered = [...steps].sort((a, b) => precedenceRank(a.id) - precedenceRank(b.id) || a.toScore - b.toScore);
81
+ const overrides = new Map();
82
+ let before = run.health.displayed;
83
+ const slices = ordered.map((step, i) => {
84
+ overrides.set(step.id, step.toScore);
85
+ const after = roundHalfUp(healthWith(run, methodology, overrides).health ?? before);
86
+ const slice = { ...step, position: i + 1, healthBefore: before, healthAfter: after };
87
+ before = after;
88
+ return slice;
89
+ });
90
+ return {
91
+ kind: 'route',
92
+ target,
93
+ from: run.health.displayed,
94
+ to: before,
95
+ reached,
96
+ unmeasured: run.parameters.filter((p) => p.status === 'not_measured').map((p) => p.id),
97
+ slices,
98
+ };
99
+ }
100
+ /** The stdout lines: the summary, plus the out-of-reach line when the Target band is not reached. A preview gets its one line, Agent-ready nothing. */
101
+ export function routeLines(plan) {
102
+ if (plan.kind === 'preview')
103
+ return [PREVIEW_LINE];
104
+ if (plan.kind === 'none')
105
+ return [];
106
+ const lines = [`Route to ${plan.target.name}: ${plan.slices.length} Slices, ${plan.from} -> ${plan.to}`];
107
+ if (!plan.reached)
108
+ lines.push(outOfReachLine(plan));
109
+ return lines;
110
+ }
111
+ export function outOfReachLine(plan) {
112
+ const reason = plan.unmeasured.length > 0 ? `until ${plan.unmeasured.join(', ')} are measured` : 'with the parameters measured, because no further Step adds health';
113
+ return `${plan.target.name} is out of reach ${reason}; this Route gets you to ${plan.to}`;
114
+ }
@@ -0,0 +1,13 @@
1
+ import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ROUTE_FILE, SLICE_FILE } from './files.js';
4
+ /** Deletes the earlier ROUTE.md and Slice files in `dir` (nothing else), then writes the new ones. */
5
+ export function writeRouteFiles(dir, files) {
6
+ mkdirSync(dir, { recursive: true });
7
+ for (const name of readdirSync(dir)) {
8
+ if (name === ROUTE_FILE || SLICE_FILE.test(name))
9
+ rmSync(path.join(dir, name), { force: true });
10
+ }
11
+ for (const file of files)
12
+ writeFileSync(path.join(dir, file.name), file.content, 'utf8');
13
+ }
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { z } from 'zod';
3
+ export class RunReadError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'RunReadError';
7
+ }
8
+ }
9
+ const nullableScore = z.number().nullable();
10
+ /** The fields `report` and `diff` read. Unknown fields pass through untouched. */
11
+ const runFileSchema = z.looseObject({
12
+ schemaVersion: z.literal(1),
13
+ valid: z.boolean(),
14
+ invalidReason: z.string().optional(),
15
+ warnings: z.array(z.string()),
16
+ methodology: z.looseObject({ version: z.string() }),
17
+ mode: z.enum(['preview', 'full']),
18
+ comparable: z.boolean(),
19
+ comparableReason: z.string().optional(),
20
+ asOf: z.string(),
21
+ layers: z.array(z.enum(['static', 'execution'])),
22
+ repo: z.looseObject({ path: z.string(), headCommit: z.string().nullable(), treeHash: z.string().nullable() }),
23
+ tools: z.record(z.string(), z.unknown()),
24
+ execution: z
25
+ .looseObject({
26
+ feedbackLoop: z.looseObject({ typecheckSeconds: z.number(), lintSeconds: z.number(), testSeconds: z.number(), totalSeconds: z.number() }),
27
+ roots: z
28
+ .array(z.looseObject({
29
+ dir: z.string(),
30
+ group: z.string(),
31
+ install: z.enum(['ok', 'failed', 'timeout']).nullable(),
32
+ exitCode: z.number().nullable(),
33
+ }))
34
+ .optional(),
35
+ })
36
+ .optional(),
37
+ reportOnly: z.record(z.string(), z.unknown()).optional(),
38
+ ignoreSet: z.looseObject({
39
+ healthignoreSha256: z.string().nullable(),
40
+ gitleaksignoreSha256: z.string().nullable(),
41
+ excludedLinesPercent: z.number(),
42
+ }),
43
+ scope: z.looseObject({ logicFiles: z.number(), logicLines: z.number(), sourceLines: z.number(), packages: z.array(z.string()) }),
44
+ parameters: z.array(z.looseObject({
45
+ id: z.string(),
46
+ status: z.enum(['measured', 'not_applicable', 'not_measured', 'failed']),
47
+ reason: z.string().optional(),
48
+ weight: z.number(),
49
+ raw: z.looseObject({ kind: z.enum(['ratio', 'level', 'number', 'components', 'failed']) }).nullable().optional(),
50
+ score: z.number().optional(),
51
+ units: z.array(z.looseObject({ package: z.string(), group: z.string(), weight: z.number(), raw: z.looseObject({}), score: z.number() })).optional(),
52
+ })),
53
+ dimensions: z.array(z.looseObject({ id: z.number(), key: z.string(), score: nullableScore })),
54
+ breakdown: z.looseObject({
55
+ byPackage: z.record(z.string(), z.record(z.string(), nullableScore)),
56
+ byGroup: z.record(z.string(), z.record(z.string(), nullableScore)),
57
+ }),
58
+ health: z.looseObject({ score: z.number(), displayed: z.number(), band: z.string() }).nullable(),
59
+ });
60
+ /** Parse a run.json document. Throws RunReadError naming the file for anything but a valid version 1 run. */
61
+ export function parseRun(text, file) {
62
+ let doc;
63
+ try {
64
+ doc = JSON.parse(text);
65
+ }
66
+ catch (err) {
67
+ throw new RunReadError(`${file}: not valid JSON (${err instanceof Error ? err.message : String(err)})`);
68
+ }
69
+ if (typeof doc === 'object' && doc !== null && 'schemaVersion' in doc && doc.schemaVersion !== 1) {
70
+ throw new RunReadError(`${file}: unsupported schemaVersion ${JSON.stringify(doc.schemaVersion)} (this CLI reads 1)`);
71
+ }
72
+ const result = runFileSchema.safeParse(doc);
73
+ if (!result.success) {
74
+ const issue = result.error.issues[0];
75
+ const where = issue && issue.path.length > 0 ? issue.path.join('.') : '(root)';
76
+ throw new RunReadError(`${file}: not a run.json: ${where}: ${issue?.message ?? 'invalid'}`);
77
+ }
78
+ return result.data;
79
+ }
80
+ export function readRun(file) {
81
+ let text;
82
+ try {
83
+ text = readFileSync(file, 'utf8');
84
+ }
85
+ catch (err) {
86
+ throw new RunReadError(`${file}: cannot read (${err instanceof Error ? err.message : String(err)})`);
87
+ }
88
+ return parseRun(text, file);
89
+ }
@@ -0,0 +1,19 @@
1
+ import { poolParameter } from '../scoring/pool.js';
2
+ import { dimensionScores, healthFrom } from './score-run.js';
3
+ /** Re-derive every score from the stored raw values. */
4
+ export function recompute(run, methodology) {
5
+ const defs = new Map(methodology.dimensions.flatMap((d) => d.parameters).map((p) => [p.id, p]));
6
+ const parameters = run.parameters.map((param) => {
7
+ const def = defs.get(param.id);
8
+ if (!def)
9
+ throw new Error(`run.json names unknown parameter ${param.id}`);
10
+ if (param.status === 'failed')
11
+ return { ...param, score: 0 };
12
+ if (param.status !== 'measured')
13
+ return param;
14
+ const pooled = poolParameter(def, param.units ?? []);
15
+ return { ...param, raw: pooled.raw, score: pooled.score, units: pooled.units };
16
+ });
17
+ const dimensions = dimensionScores(methodology, parameters);
18
+ return { parameters, dimensions, health: healthFrom(methodology, dimensions) };
19
+ }