stackvitals 0.1.0__py3-none-any.whl

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 (465) hide show
  1. mergescout/__init__.py +1 -0
  2. mergescout/archive/__init__.py +0 -0
  3. mergescout/archive/__main__.py +30 -0
  4. mergescout/archive/convert.py +83 -0
  5. mergescout/archive/events.py +55 -0
  6. mergescout/archive/manifest.py +45 -0
  7. mergescout/archive/stream.py +83 -0
  8. mergescout/bench/__init__.py +1 -0
  9. mergescout/bench/__main__.py +135 -0
  10. mergescout/bench/ci.py +257 -0
  11. mergescout/bench/deploys.py +115 -0
  12. mergescout/bench/matched.py +22 -0
  13. mergescout/bench/prior.py +42 -0
  14. mergescout/bench/report.py +230 -0
  15. mergescout/bench/shared.py +112 -0
  16. mergescout/bench/stars.py +57 -0
  17. mergescout/cli.py +89 -0
  18. mergescout/config.py +35 -0
  19. mergescout/crawl/__init__.py +0 -0
  20. mergescout/crawl/__main__.py +97 -0
  21. mergescout/crawl/chain.py +34 -0
  22. mergescout/crawl/fetch.py +146 -0
  23. mergescout/crawl/outcomes.py +69 -0
  24. mergescout/crawl/queries.py +250 -0
  25. mergescout/crawl/runner.py +176 -0
  26. mergescout/crawl/seed.py +77 -0
  27. mergescout/crawl/state.py +169 -0
  28. mergescout/crawl/writer.py +87 -0
  29. mergescout/discover/__init__.py +0 -0
  30. mergescout/discover/__main__.py +58 -0
  31. mergescout/discover/sweep.py +286 -0
  32. mergescout/github/__init__.py +0 -0
  33. mergescout/github/graphql.py +202 -0
  34. mergescout/github/identities.py +35 -0
  35. mergescout/github/releases.py +137 -0
  36. mergescout/labels.py +20 -0
  37. mergescout/mcp_server.py +228 -0
  38. mergescout/measure/__init__.py +0 -0
  39. mergescout/measure/__main__.py +75 -0
  40. mergescout/measure/api_cost.py +91 -0
  41. mergescout/measure/count_repos.py +30 -0
  42. mergescout/measure/jsonl.py +14 -0
  43. mergescout/measure/report.py +56 -0
  44. mergescout/measure/sample.py +37 -0
  45. mergescout/owners.py +22 -0
  46. mergescout/setup_check.py +64 -0
  47. mergescout/signals/__init__.py +0 -0
  48. mergescout/signals/__main__.py +96 -0
  49. mergescout/signals/bump_ledger.py +258 -0
  50. mergescout/signals/bumps.py +104 -0
  51. mergescout/signals/check.py +141 -0
  52. mergescout/signals/community_bench.py +149 -0
  53. mergescout/signals/delivery_bench.py +103 -0
  54. mergescout/signals/deps_map.py +261 -0
  55. mergescout/signals/deps_trends.py +117 -0
  56. mergescout/signals/evidence.py +94 -0
  57. mergescout/signals/exploits.py +43 -0
  58. mergescout/signals/flow_bench.py +258 -0
  59. mergescout/signals/identity.py +73 -0
  60. mergescout/signals/issues.py +76 -0
  61. mergescout/signals/labels.py +79 -0
  62. mergescout/signals/osv.py +232 -0
  63. mergescout/signals/owners_watch.py +80 -0
  64. mergescout/signals/repo_stats.py +96 -0
  65. mergescout/signals/rules.py +70 -0
  66. mergescout/signals/silver.py +33 -0
  67. mergescout/signals/top_packages.py +26 -0
  68. mergescout/signals/triage.py +71 -0
  69. mergescout/signals/triage_math.py +120 -0
  70. mergescout/signals/walls.py +91 -0
  71. mergescout/snapshot/__init__.py +0 -0
  72. mergescout/snapshot/__main__.py +215 -0
  73. mergescout/snapshot/build.py +147 -0
  74. mergescout/snapshot/publish.py +70 -0
  75. mergescout/snapshot/schema.py +34 -0
  76. mergescout/snapshot/score.py +30 -0
  77. mergescout/store/__init__.py +0 -0
  78. mergescout/store/__main__.py +46 -0
  79. mergescout/store/sync.py +148 -0
  80. mergescout/timeutil.py +11 -0
  81. mergescout/tool/__init__.py +0 -0
  82. mergescout/tool/action_pack.py +62 -0
  83. mergescout/tool/live.py +201 -0
  84. mergescout/tool/queries.py +293 -0
  85. mergescout/tool/refs.py +31 -0
  86. mergescout/tool/store.py +129 -0
  87. mergescout/tool/tracking.py +83 -0
  88. stackvitals/__init__.py +2 -0
  89. stackvitals/__main__.py +4 -0
  90. stackvitals/ci/__init__.py +1 -0
  91. stackvitals/ci/action.py +574 -0
  92. stackvitals/ci/analyze.py +577 -0
  93. stackvitals/ci/api.py +218 -0
  94. stackvitals/ci/automation.py +212 -0
  95. stackvitals/ci/bench.py +195 -0
  96. stackvitals/ci/burnin.py +102 -0
  97. stackvitals/ci/cli.py +570 -0
  98. stackvitals/ci/collect.py +239 -0
  99. stackvitals/ci/cost.py +107 -0
  100. stackvitals/ci/coverage.py +155 -0
  101. stackvitals/ci/feedback.py +699 -0
  102. stackvitals/ci/fingerprint.py +220 -0
  103. stackvitals/ci/gitlab.py +121 -0
  104. stackvitals/ci/gitlab_action.py +125 -0
  105. stackvitals/ci/impact.py +125 -0
  106. stackvitals/ci/investigate.py +59 -0
  107. stackvitals/ci/kinds.py +52 -0
  108. stackvitals/ci/monitors.py +105 -0
  109. stackvitals/ci/owners.py +116 -0
  110. stackvitals/ci/quarantine_runtime.py +90 -0
  111. stackvitals/ci/report.py +435 -0
  112. stackvitals/ci/retry.py +67 -0
  113. stackvitals/ci/selfheal.py +102 -0
  114. stackvitals/ci/shards.py +120 -0
  115. stackvitals/ci/share.py +59 -0
  116. stackvitals/ci/store.py +93 -0
  117. stackvitals/ci/testreports.py +351 -0
  118. stackvitals/ci/tickets.py +106 -0
  119. stackvitals/ci/track_record.json +11 -0
  120. stackvitals/ci/triage.py +130 -0
  121. stackvitals/ci/triggers.py +147 -0
  122. stackvitals/ci/upload.py +85 -0
  123. stackvitals/ci/validate.py +91 -0
  124. stackvitals/cli.py +111 -0
  125. stackvitals/codeowners.py +69 -0
  126. stackvitals/community/__init__.py +0 -0
  127. stackvitals/community/action.py +140 -0
  128. stackvitals/community/affiliation.py +51 -0
  129. stackvitals/community/analyze.py +507 -0
  130. stackvitals/community/build.py +56 -0
  131. stackvitals/community/chaoss.py +70 -0
  132. stackvitals/community/cli.py +79 -0
  133. stackvitals/community/collect.py +240 -0
  134. stackvitals/community/context.py +54 -0
  135. stackvitals/community/report.py +250 -0
  136. stackvitals/connectors/__init__.py +244 -0
  137. stackvitals/connectors/ai_spend.py +190 -0
  138. stackvitals/connectors/base.py +82 -0
  139. stackvitals/connectors/check.py +686 -0
  140. stackvitals/connectors/copilot.py +104 -0
  141. stackvitals/connectors/discord.py +103 -0
  142. stackvitals/connectors/discourse.py +117 -0
  143. stackvitals/connectors/incident_io.py +106 -0
  144. stackvitals/connectors/jira.py +210 -0
  145. stackvitals/connectors/linear.py +140 -0
  146. stackvitals/connectors/pagerduty.py +96 -0
  147. stackvitals/connectors/slack.py +110 -0
  148. stackvitals/connectors/sonar.py +77 -0
  149. stackvitals/connectors/store.py +120 -0
  150. stackvitals/connectors/tracker.py +108 -0
  151. stackvitals/connectors/writeback.py +105 -0
  152. stackvitals/contribute/__init__.py +1 -0
  153. stackvitals/contribute/brief.py +297 -0
  154. stackvitals/contribute/cli.py +64 -0
  155. stackvitals/core/__init__.py +1 -0
  156. stackvitals/core/actors.py +103 -0
  157. stackvitals/core/agent_ledger.py +120 -0
  158. stackvitals/core/agreements.py +95 -0
  159. stackvitals/core/ai_outcomes.py +276 -0
  160. stackvitals/core/alerts.py +224 -0
  161. stackvitals/core/appdata.py +112 -0
  162. stackvitals/core/bucket.py +190 -0
  163. stackvitals/core/bugs.py +43 -0
  164. stackvitals/core/calendar.py +34 -0
  165. stackvitals/core/capex.py +57 -0
  166. stackvitals/core/change_risk.py +181 -0
  167. stackvitals/core/changes.py +133 -0
  168. stackvitals/core/chat.py +143 -0
  169. stackvitals/core/config.py +373 -0
  170. stackvitals/core/dataflow.py +168 -0
  171. stackvitals/core/decisions.py +275 -0
  172. stackvitals/core/delivery.py +954 -0
  173. stackvitals/core/delivery_peers.py +98 -0
  174. stackvitals/core/delivery_report.py +429 -0
  175. stackvitals/core/deliverycli.py +147 -0
  176. stackvitals/core/devlake_dora.sql +485 -0
  177. stackvitals/core/digest.py +333 -0
  178. stackvitals/core/explaincli.py +107 -0
  179. stackvitals/core/explore.py +296 -0
  180. stackvitals/core/export.py +342 -0
  181. stackvitals/core/exportcli.py +117 -0
  182. stackvitals/core/findings.py +350 -0
  183. stackvitals/core/health.py +94 -0
  184. stackvitals/core/identity.py +82 -0
  185. stackvitals/core/insights.py +365 -0
  186. stackvitals/core/insightscli.py +43 -0
  187. stackvitals/core/installdoc.py +69 -0
  188. stackvitals/core/investment.py +187 -0
  189. stackvitals/core/joins.py +43 -0
  190. stackvitals/core/llmsdoc.py +40 -0
  191. stackvitals/core/merge_queue.py +70 -0
  192. stackvitals/core/metrics.py +109 -0
  193. stackvitals/core/model.py +285 -0
  194. stackvitals/core/projectcli.py +66 -0
  195. stackvitals/core/propose.py +86 -0
  196. stackvitals/core/pulse.py +85 -0
  197. stackvitals/core/recorded.py +145 -0
  198. stackvitals/core/releases.py +39 -0
  199. stackvitals/core/requests.py +528 -0
  200. stackvitals/core/resync.py +35 -0
  201. stackvitals/core/schema.py +205 -0
  202. stackvitals/core/services.py +203 -0
  203. stackvitals/core/settings.py +96 -0
  204. stackvitals/core/sqlcli.py +111 -0
  205. stackvitals/core/stats.py +222 -0
  206. stackvitals/core/store.py +125 -0
  207. stackvitals/core/summary.py +104 -0
  208. stackvitals/core/time.py +29 -0
  209. stackvitals/core/tomledit.py +149 -0
  210. stackvitals/core/uicli.py +138 -0
  211. stackvitals/core/webapp.py +79 -0
  212. stackvitals/core/what_changed.py +76 -0
  213. stackvitals/core/workspace.py +194 -0
  214. stackvitals/deps/__init__.py +1 -0
  215. stackvitals/deps/action.py +299 -0
  216. stackvitals/deps/actions_vulns.py +89 -0
  217. stackvitals/deps/cli.py +77 -0
  218. stackvitals/deps/dossier.py +341 -0
  219. stackvitals/deps/evidence.py +44 -0
  220. stackvitals/deps/fixplan.py +40 -0
  221. stackvitals/deps/guarddog.py +32 -0
  222. stackvitals/deps/hygiene.py +47 -0
  223. stackvitals/deps/imports.py +113 -0
  224. stackvitals/deps/lockfiles.py +198 -0
  225. stackvitals/deps/lookalike.py +80 -0
  226. stackvitals/deps/reach.py +59 -0
  227. stackvitals/deps/release.py +110 -0
  228. stackvitals/deps/runtimes.py +161 -0
  229. stackvitals/deps/supply.py +454 -0
  230. stackvitals/deps/vulns.py +588 -0
  231. stackvitals/editors/__init__.py +40 -0
  232. stackvitals/editors/vscode/extension.js +101 -0
  233. stackvitals/editors/vscode/marks.js +92 -0
  234. stackvitals/editors/vscode/package.json +26 -0
  235. stackvitals/flow/__init__.py +1 -0
  236. stackvitals/flow/action.py +178 -0
  237. stackvitals/flow/analyze.py +418 -0
  238. stackvitals/flow/automate.py +72 -0
  239. stackvitals/flow/build.py +46 -0
  240. stackvitals/flow/cli.py +93 -0
  241. stackvitals/flow/code_hosts.py +146 -0
  242. stackvitals/flow/collect.py +451 -0
  243. stackvitals/flow/gitlab_mr.py +75 -0
  244. stackvitals/flow/knowledge.py +202 -0
  245. stackvitals/flow/peers.py +90 -0
  246. stackvitals/flow/preflight.py +210 -0
  247. stackvitals/flow/report.py +253 -0
  248. stackvitals/flow/rules.py +135 -0
  249. stackvitals/flow/store.py +108 -0
  250. stackvitals/flow/survival.py +84 -0
  251. stackvitals/flow/triage.py +62 -0
  252. stackvitals/mcp_server.py +670 -0
  253. stackvitals/portal/__init__.py +0 -0
  254. stackvitals/portal/action.py +205 -0
  255. stackvitals/portal/batch.py +80 -0
  256. stackvitals/portal/campaigns.py +104 -0
  257. stackvitals/portal/catalog.py +104 -0
  258. stackvitals/portal/checks.py +63 -0
  259. stackvitals/portal/cli.py +172 -0
  260. stackvitals/portal/descriptor.py +105 -0
  261. stackvitals/portal/export.py +43 -0
  262. stackvitals/portal/github.py +48 -0
  263. stackvitals/portal/golden.py +95 -0
  264. stackvitals/portal/history.py +30 -0
  265. stackvitals/portal/inventory.py +264 -0
  266. stackvitals/portal/owners.py +78 -0
  267. stackvitals/portal/pages.py +153 -0
  268. stackvitals/portal/rollup.py +32 -0
  269. stackvitals/portal/scorecard.py +366 -0
  270. stackvitals/security/__init__.py +2 -0
  271. stackvitals/security/probes.py +154 -0
  272. stackvitals/security/repo_checks.py +132 -0
  273. stackvitals/security/workflows.py +111 -0
  274. stackvitals/shell.py +107 -0
  275. stackvitals/site.py +51 -0
  276. stackvitals/web/app/LICENSES.md +428 -0
  277. stackvitals/web/app/boot.js +1 -0
  278. stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
  279. stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
  280. stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
  281. stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
  282. stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
  283. stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
  284. stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
  285. stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
  286. stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
  287. stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
  288. stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
  289. stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
  290. stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
  291. stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
  292. stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
  293. stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
  294. stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
  295. stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
  296. stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
  297. stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
  298. stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
  299. stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
  300. stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
  301. stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
  302. stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
  303. stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
  304. stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
  305. stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
  306. stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
  307. stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
  308. stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
  309. stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
  310. stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
  311. stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
  312. stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
  313. stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
  314. stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
  315. stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
  316. stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
  317. stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
  318. stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
  319. stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
  320. stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
  321. stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
  322. stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
  323. stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
  324. stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
  325. stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
  326. stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
  327. stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
  328. stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
  329. stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
  330. stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
  331. stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
  332. stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
  333. stackvitals/web/app/demo/ci/impact.json +1 -0
  334. stackvitals/web/app/demo/ci/quarantine.json +1 -0
  335. stackvitals/web/app/demo/ci/tickets.json +1 -0
  336. stackvitals/web/app/demo/mobile/alerts.json +1 -0
  337. stackvitals/web/app/demo/mobile/app.json +1 -0
  338. stackvitals/web/app/demo/mobile/community.json +1 -0
  339. stackvitals/web/app/demo/mobile/connectors.json +1 -0
  340. stackvitals/web/app/demo/mobile/data.json +1 -0
  341. stackvitals/web/app/demo/mobile/decisions.json +1 -0
  342. stackvitals/web/app/demo/mobile/delivery.json +1 -0
  343. stackvitals/web/app/demo/mobile/deps.json +1 -0
  344. stackvitals/web/app/demo/mobile/flow.json +1 -0
  345. stackvitals/web/app/demo/mobile/health.json +1 -0
  346. stackvitals/web/app/demo/mobile/insights.json +1 -0
  347. stackvitals/web/app/demo/mobile/pulse.json +1 -0
  348. stackvitals/web/app/demo/mobile/security.json +1 -0
  349. stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
  350. stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
  351. stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
  352. stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
  353. stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
  354. stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
  355. stackvitals/web/app/demo/mobile/supply.json +1 -0
  356. stackvitals/web/app/demo/mobile/vulns.json +1 -0
  357. stackvitals/web/app/demo/payments-api/alerts.json +1 -0
  358. stackvitals/web/app/demo/payments-api/app.json +1 -0
  359. stackvitals/web/app/demo/payments-api/community.json +1 -0
  360. stackvitals/web/app/demo/payments-api/connectors.json +1 -0
  361. stackvitals/web/app/demo/payments-api/data.json +1 -0
  362. stackvitals/web/app/demo/payments-api/decisions.json +1 -0
  363. stackvitals/web/app/demo/payments-api/delivery.json +1 -0
  364. stackvitals/web/app/demo/payments-api/deps.json +1 -0
  365. stackvitals/web/app/demo/payments-api/flow.json +1 -0
  366. stackvitals/web/app/demo/payments-api/health.json +1 -0
  367. stackvitals/web/app/demo/payments-api/insights.json +1 -0
  368. stackvitals/web/app/demo/payments-api/pulse.json +1 -0
  369. stackvitals/web/app/demo/payments-api/security.json +1 -0
  370. stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
  371. stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
  372. stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
  373. stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
  374. stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
  375. stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
  376. stackvitals/web/app/demo/payments-api/supply.json +1 -0
  377. stackvitals/web/app/demo/payments-api/vulns.json +1 -0
  378. stackvitals/web/app/demo/portal.json +1 -0
  379. stackvitals/web/app/demo/settings.json +1 -0
  380. stackvitals/web/app/demo/web-app/alerts.json +1 -0
  381. stackvitals/web/app/demo/web-app/app.json +1 -0
  382. stackvitals/web/app/demo/web-app/community.json +1 -0
  383. stackvitals/web/app/demo/web-app/connectors.json +1 -0
  384. stackvitals/web/app/demo/web-app/data.json +1 -0
  385. stackvitals/web/app/demo/web-app/decisions.json +1 -0
  386. stackvitals/web/app/demo/web-app/delivery.json +1 -0
  387. stackvitals/web/app/demo/web-app/deps.json +1 -0
  388. stackvitals/web/app/demo/web-app/flow.json +1 -0
  389. stackvitals/web/app/demo/web-app/health.json +1 -0
  390. stackvitals/web/app/demo/web-app/insights.json +1 -0
  391. stackvitals/web/app/demo/web-app/pulse.json +1 -0
  392. stackvitals/web/app/demo/web-app/security.json +1 -0
  393. stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
  394. stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
  395. stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
  396. stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
  397. stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
  398. stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
  399. stackvitals/web/app/demo/web-app/supply.json +1 -0
  400. stackvitals/web/app/demo/web-app/vulns.json +1 -0
  401. stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
  402. stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
  403. stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
  404. stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
  405. stackvitals/web/app/help/accuracy.json +1 -0
  406. stackvitals/web/app/help/connectors.json +1 -0
  407. stackvitals/web/app/help/getting-started.json +1 -0
  408. stackvitals/web/app/help/index.json +1 -0
  409. stackvitals/web/app/help/modules.json +1 -0
  410. stackvitals/web/app/help/privacy-and-security.json +1 -0
  411. stackvitals/web/app/help/reference.json +1 -0
  412. stackvitals/web/app/main.css +1 -0
  413. stackvitals/web/app/main.js +1 -0
  414. stackvitals/web/app/pages/about.css +1 -0
  415. stackvitals/web/app/pages/about.js +1 -0
  416. stackvitals/web/app/pages/activity.css +1 -0
  417. stackvitals/web/app/pages/activity.js +1 -0
  418. stackvitals/web/app/pages/agents.css +1 -0
  419. stackvitals/web/app/pages/agents.js +2 -0
  420. stackvitals/web/app/pages/ci.css +1 -0
  421. stackvitals/web/app/pages/ci.js +1 -0
  422. stackvitals/web/app/pages/community.css +1 -0
  423. stackvitals/web/app/pages/community.js +1 -0
  424. stackvitals/web/app/pages/connections.css +1 -0
  425. stackvitals/web/app/pages/connections.js +6 -0
  426. stackvitals/web/app/pages/delivery.css +1 -0
  427. stackvitals/web/app/pages/delivery.js +45 -0
  428. stackvitals/web/app/pages/deps.css +1 -0
  429. stackvitals/web/app/pages/deps.js +7 -0
  430. stackvitals/web/app/pages/explore.css +1 -0
  431. stackvitals/web/app/pages/explore.js +7 -0
  432. stackvitals/web/app/pages/health.css +1 -0
  433. stackvitals/web/app/pages/health.js +1 -0
  434. stackvitals/web/app/pages/help.css +1 -0
  435. stackvitals/web/app/pages/help.js +6 -0
  436. stackvitals/web/app/pages/insights.css +1 -0
  437. stackvitals/web/app/pages/insights.js +1 -0
  438. stackvitals/web/app/pages/investment.css +1 -0
  439. stackvitals/web/app/pages/investment.js +3 -0
  440. stackvitals/web/app/pages/overview.css +1 -0
  441. stackvitals/web/app/pages/overview.js +1 -0
  442. stackvitals/web/app/pages/portal.css +1 -0
  443. stackvitals/web/app/pages/portal.js +51 -0
  444. stackvitals/web/app/pages/prs.css +1 -0
  445. stackvitals/web/app/pages/prs.js +4 -0
  446. stackvitals/web/app/pages/settings.css +1 -0
  447. stackvitals/web/app/pages/settings.js +1 -0
  448. stackvitals/web/app/pages/setup.css +1 -0
  449. stackvitals/web/app/pages/setup.js +2 -0
  450. stackvitals/web/app/pages/standing.css +1 -0
  451. stackvitals/web/app/pages/standing.js +1 -0
  452. stackvitals/web/app/pages/welcome-sheet.js +1 -0
  453. stackvitals/web/app/pages/welcome.js +1 -0
  454. stackvitals/web/app/settings-workflow.json +1 -0
  455. stackvitals/web/index.html +34 -0
  456. stackvitals/web/site/index.html +108 -0
  457. stackvitals/web/site/integrations.html +15 -0
  458. stackvitals/web/site/setup.html +15 -0
  459. stackvitals/web/site/site.css +68 -0
  460. stackvitals-0.1.0.dist-info/METADATA +134 -0
  461. stackvitals-0.1.0.dist-info/RECORD +465 -0
  462. stackvitals-0.1.0.dist-info/WHEEL +4 -0
  463. stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
  464. stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
  465. stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
@@ -0,0 +1,198 @@
1
+ """Packages and versions from the lockfiles projects commit: npm's package-lock, and pip, uv, Poetry and Pipenv files."""
2
+ import json
3
+ import re
4
+ import tomllib
5
+ from collections import deque
6
+ from pathlib import Path
7
+ from urllib.parse import urlparse
8
+
9
+ from mergescout.signals.bumps import _norm
10
+
11
+ SUPPORTED = "package-lock.json, npm-shrinkwrap.json, requirements*.txt, uv.lock, poetry.lock, Pipfile.lock"
12
+ REGISTRY_HOSTS = {"npm": ("registry.npmjs.org", "registry.yarnpkg.com"), "pypi": ("pypi.org", "files.pythonhosted.org")}
13
+ REQUIREMENT = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:==\s*([^\s,;]+))?")
14
+
15
+
16
+ def read_lockfile(path: Path) -> list[tuple[str, str, str | None]]:
17
+ """(ecosystem, name, version) for every package the file pins, sorted; a file it can't read is a clear error."""
18
+ path = Path(path)
19
+ return parse_lockfile(path.name, path.read_text(encoding="utf-8"))
20
+
21
+
22
+ def is_lockfile(filename: str) -> bool:
23
+ name = filename.rsplit("/", 1)[-1].lower()
24
+ return name in ("package-lock.json", "npm-shrinkwrap.json", "uv.lock", "poetry.lock", "pipfile.lock") or (
25
+ name.startswith("requirements") and name.endswith(".txt"))
26
+
27
+
28
+ def parse_lockfile(filename: str, text: str) -> list[tuple[str, str, str | None]]:
29
+ name = filename.rsplit("/", 1)[-1].lower()
30
+ if name in ("package-lock.json", "npm-shrinkwrap.json"):
31
+ found = _npm(json.loads(text))
32
+ elif name.endswith(".txt"):
33
+ found = _requirements(text)
34
+ elif name in ("uv.lock", "poetry.lock"):
35
+ found = {("pypi", _norm("pypi", p["name"]), p.get("version")) for p in tomllib.loads(text).get("package") or []
36
+ if p.get("name") and not _local((p.get("source") or {}))}
37
+ elif name == "pipfile.lock":
38
+ doc = json.loads(text)
39
+ found = {("pypi", _norm("pypi", n), (v.get("version") or "").lstrip("=") or None)
40
+ for section in ("default", "develop") for n, v in (doc.get(section) or {}).items()}
41
+ else:
42
+ raise ValueError(f"{filename} isn't a lockfile this reads yet; it reads {SUPPORTED}.")
43
+ return sorted(found, key=lambda t: (t[0], t[1], t[2] or ""))
44
+
45
+
46
+ def _local(source: dict) -> bool:
47
+ # uv lists the project itself and path dependencies; they aren't registry packages.
48
+ return any(k in source for k in ("editable", "virtual", "directory", "path"))
49
+
50
+
51
+ def _npm(doc: dict) -> set:
52
+ if isinstance(doc.get("packages"), dict):
53
+ # v2/v3: "node_modules/a/node_modules/b" is package b, nested under a; an alias ("react": "npm:other@1") names the real one.
54
+ return {("npm", _npm_name(key, v), v.get("version")) for key, v in doc["packages"].items()
55
+ if key and "node_modules/" in key and isinstance(v, dict) and not v.get("link")}
56
+ found = set()
57
+
58
+ def walk(deps: dict) -> None:
59
+ for n, v in (deps or {}).items():
60
+ found.add(("npm", *_v1_alias(n, (v or {}).get("version"))))
61
+ walk((v or {}).get("dependencies"))
62
+ walk(doc.get("dependencies"))
63
+ return found
64
+
65
+
66
+ def _v1_alias(name: str, version) -> tuple[str, str | None]:
67
+ # v1 writes an alias as "version": "npm:real-name@1.2.3".
68
+ if isinstance(version, str) and version.startswith("npm:") and "@" in version[5:]:
69
+ real, _, ver = version[4:].rpartition("@")
70
+ return real, ver
71
+ return name, version
72
+
73
+
74
+ def _npm_name(key: str, entry: dict) -> str:
75
+ name = entry.get("name")
76
+ return name if isinstance(name, str) and name else key.rsplit("node_modules/", 1)[1]
77
+
78
+
79
+ def _host(url: str) -> str:
80
+ parsed = urlparse(url.removeprefix("git+"))
81
+ return parsed.hostname or (url.split(":", 1)[0] if ":" in url else "a local path")
82
+
83
+
84
+ def foreign_sources(filename: str, text: str) -> dict[tuple[str, str], str]:
85
+ """Packages installed from somewhere other than the public registry (a git repo, a tarball URL, another index), with
86
+ where from: evidence about the registry package of that name doesn't describe them."""
87
+ name, out = filename.rsplit("/", 1)[-1].lower(), {}
88
+ if name in ("package-lock.json", "npm-shrinkwrap.json"):
89
+ doc = json.loads(text)
90
+ if isinstance(doc.get("packages"), dict):
91
+ entries = [(_npm_name(k, v), v) for k, v in doc["packages"].items()
92
+ if k and "node_modules/" in k and isinstance(v, dict) and not v.get("link")]
93
+ else:
94
+ entries = []
95
+
96
+ def walk(deps):
97
+ for n, v in (deps or {}).items():
98
+ entries.append((_v1_alias(n, (v or {}).get("version"))[0], v or {}))
99
+ walk((v or {}).get("dependencies"))
100
+ walk(doc.get("dependencies"))
101
+ for pkg, v in entries:
102
+ url = v.get("resolved")
103
+ if isinstance(url, str) and url and _host(url) not in REGISTRY_HOSTS["npm"]:
104
+ out[("npm", pkg)] = _host(url)
105
+ elif name in ("uv.lock", "poetry.lock"):
106
+ for p in tomllib.loads(text).get("package") or []:
107
+ src = p.get("source") or {}
108
+ if not p.get("name") or _local(src):
109
+ continue
110
+ url = src.get("git") or src.get("url") or src.get("registry") or ""
111
+ if isinstance(url, str) and url and (src.get("type") or "git") != "directory" and _host(url) not in REGISTRY_HOSTS["pypi"]:
112
+ out[("pypi", _norm("pypi", p["name"]))] = _host(url)
113
+ elif name == "pipfile.lock":
114
+ doc = json.loads(text)
115
+ for section in ("default", "develop"):
116
+ for n, v in (doc.get(section) or {}).items():
117
+ if isinstance(v, dict) and isinstance(v.get("git") or v.get("file"), str):
118
+ out[("pypi", _norm("pypi", n))] = _host(v.get("git") or v.get("file"))
119
+ return out
120
+
121
+
122
+ def _requirements(text: str) -> set:
123
+ found = set()
124
+ for line in text.splitlines():
125
+ line = line.split("#", 1)[0].strip()
126
+ # Options (-e, -r, --hash) and URLs aren't registry packages.
127
+ if not line or line.startswith("-") or "://" in line:
128
+ continue
129
+ m = REQUIREMENT.match(line)
130
+ if m:
131
+ found.add(("pypi", _norm("pypi", m.group(1)), m.group(2)))
132
+ return found
133
+
134
+
135
+ def dependency_paths(filename: str, text: str, targets: set[str]) -> dict[str, list[str]]:
136
+ """The shortest chain from a direct dependency to each target package (["express", "qs", "side-channel"]), for
137
+ lockfiles that record the graph and which packages are direct (npm v2/v3, uv); none for the rest (poetry.lock
138
+ doesn't say which are direct, so a guessed chain could name the wrong one) or a file that can't be read."""
139
+ name = filename.rsplit("/", 1)[-1].lower()
140
+ npm = name in ("package-lock.json", "npm-shrinkwrap.json")
141
+ try:
142
+ if npm:
143
+ roots, children = _npm_graph(json.loads(text))
144
+ elif name == "uv.lock":
145
+ roots, children = _uv_graph(tomllib.loads(text))
146
+ else:
147
+ return {}
148
+ except (ValueError, TypeError, AttributeError):
149
+ return {}
150
+ want = set(targets) if npm else {_norm("pypi", t) for t in targets}
151
+ found, queue, seen = {}, deque((r, [label]) for r, label in roots), set()
152
+ while queue and len(found) < len(want):
153
+ node, path = queue.popleft()
154
+ if node in seen:
155
+ continue
156
+ seen.add(node)
157
+ if path[-1] in want and path[-1] not in found:
158
+ found[path[-1]] = path
159
+ queue.extend((child, path + [label]) for child, label in children.get(node, ()))
160
+ return found
161
+
162
+
163
+ def _npm_graph(doc: dict) -> tuple[list, dict]:
164
+ pkgs = doc.get("packages")
165
+ if not isinstance(pkgs, dict):
166
+ return [], {}
167
+
168
+ def deps(entry: dict) -> list[str]:
169
+ return [n for key in ("dependencies", "optionalDependencies", "devDependencies", "peerDependencies")
170
+ for n in (entry.get(key) or {})]
171
+
172
+ def resolve(parent: str, child: str) -> str | None:
173
+ # Node looks in the parent's own node_modules, then each folder above it, then the top.
174
+ here = parent
175
+ while True:
176
+ key = f"{here}/node_modules/{child}" if here else f"node_modules/{child}"
177
+ if key in pkgs:
178
+ return key
179
+ if not here:
180
+ return None
181
+ here = here.rsplit("/node_modules/", 1)[0] if "/node_modules/" in here else ""
182
+ children = {key: [(k, _npm_name(k, pkgs[k])) for c in deps(v) if (k := resolve(key, c))]
183
+ for key, v in pkgs.items() if isinstance(v, dict)}
184
+ return children.get("", []), children
185
+
186
+
187
+ def _uv_graph(doc: dict) -> tuple[list, dict]:
188
+ packages = [p for p in doc.get("package") or [] if p.get("name")]
189
+ edges = {_norm("pypi", p["name"]): [_norm("pypi", d["name"]) for key in ("dependencies",)
190
+ for d in p.get(key) or [] if d.get("name")]
191
+ + [_norm("pypi", d["name"]) for group in ("optional-dependencies", "dev-dependencies")
192
+ for ds in (p.get(group) or {}).values() for d in ds if d.get("name")]
193
+ for p in packages}
194
+ # The project's own entries (a local source) name its direct dependencies.
195
+ local = [_norm("pypi", p["name"]) for p in packages if _local(p.get("source") or {})]
196
+ roots = [d for proj in local for d in edges.get(proj, [])]
197
+ children = {n: [(d, d) for d in ds if d in edges] for n, ds in edges.items()}
198
+ return [(r, r) for r in roots if r in edges], children
@@ -0,0 +1,80 @@
1
+ """Names that imitate a popular package (typosquats): one edit away, or the same letters with other separators. The
2
+ popular names are the ones the dependency evidence already covers (the crawl's most-used and most-upgraded packages)."""
3
+ import re
4
+
5
+ MIN_LENGTH = 5 # shorter popular names have too many real neighbours ("chalk", "chai")
6
+
7
+
8
+ def _norm(eco: str, name: str) -> str:
9
+ name = name.lower()
10
+ return re.sub(r"[-_.]+", "-", name) if eco == "pypi" else name
11
+
12
+
13
+ def _one_edit(a: str, b: str) -> bool:
14
+ """True when b is a with one letter added, dropped, changed, or two neighbours swapped."""
15
+ if a == b or abs(len(a) - len(b)) > 1:
16
+ return False
17
+ if len(a) == len(b):
18
+ diff = [i for i in range(len(a)) if a[i] != b[i]]
19
+ return len(diff) == 1 or (len(diff) == 2 and diff[1] == diff[0] + 1 and a[diff[0]] == b[diff[1]] and a[diff[1]] == b[diff[0]])
20
+ short, long_ = (a, b) if len(a) < len(b) else (b, a)
21
+ i = 0
22
+ while i < len(short) and short[i] == long_[i]:
23
+ i += 1
24
+ return short[i:] == long_[i + 1:]
25
+
26
+
27
+ def lookalike(eco: str, name: str, popular: dict[str, set[str]]) -> str | None:
28
+ """The popular package `name` imitates, or None (a popular name itself, a scoped one, or nothing close)."""
29
+ names = {_norm(eco, p): p for p in popular.get(eco) or ()}
30
+ mine = _norm(eco, name)
31
+ if not names or mine in names or mine.startswith("@"):
32
+ return None
33
+ bare = re.sub(r"[-_.]", "", mine)
34
+ for norm, original in sorted(names.items()):
35
+ if len(norm) < MIN_LENGTH or norm.startswith("@"):
36
+ continue
37
+ if _one_edit(norm, mine) or (bare == re.sub(r"[-_.]", "", norm) and bare != mine):
38
+ return original
39
+ return None
40
+
41
+
42
+ # On OpenSSF's malicious look-alikes the weekly downloads were 1 to 75; legitimate look-alikes of popular names had
43
+ # 823,598 to 192 million (bench/accuracy/deps). Under this, a look-alike is flagged.
44
+ LOW_DOWNLOADS = 1000
45
+
46
+
47
+ NPM_STATS, PYPI_STATS = "https://api.npmjs.org", "https://pypistats.org"
48
+
49
+
50
+ def weekly_downloads(eco: str, name: str, client) -> int | None:
51
+ """Last week's downloads from the registry's free stats (npm's API, pypistats for PyPI); None when unknown."""
52
+ import httpx
53
+ url = (f"{NPM_STATS}/downloads/point/last-week/{name}" if eco == "npm"
54
+ else f"{PYPI_STATS}/api/packages/{_norm('pypi', name)}/recent" if eco == "pypi" else None)
55
+ if not url or client is None:
56
+ return None
57
+ try:
58
+ resp = client.get(url)
59
+ body = resp.json() if resp.status_code == 200 else {}
60
+ except (httpx.HTTPError, ValueError):
61
+ return None
62
+ value = body.get("downloads") if eco == "npm" else (body.get("data") or {}).get("last_week")
63
+ return value if isinstance(value, int) else None
64
+
65
+
66
+ def judge(eco: str, name: str, popular: dict[str, set[str]], client) -> dict | None:
67
+ """A verdict for a package a PR adds whose name imitates a popular one and that few people use, else None."""
68
+ target = lookalike(eco, name, popular)
69
+ if not target:
70
+ return None
71
+ downloads = weekly_downloads(eco, name, client)
72
+ if downloads is not None and downloads >= LOW_DOWNLOADS:
73
+ return None
74
+ if downloads is None:
75
+ # Without the download count the name alone false-alarms on real neighbours (preact and react): a note, not an alarm.
76
+ return {"verdict": "monitor", "reasons": [f"Its name is one edit from the popular {target}, and its downloads "
77
+ f"couldn't be checked. Check you meant {target}."]}
78
+ return {"verdict": "investigate", "reasons": [f"Its name is one edit from the popular {target} and it has "
79
+ f"{downloads:,} downloads last week: a common way to slip in a malicious "
80
+ f"package. Check you meant {target}."]}
@@ -0,0 +1,59 @@
1
+ """Reachability from govulncheck (Go's own free scanner, run in the team's workflow): for each advisory, whether the code
2
+ only requires the module, imports the vulnerable package, or calls the vulnerable function, with the call path."""
3
+ import json
4
+
5
+ LEVELS = ("required", "imported", "called")
6
+
7
+
8
+ def _short(frame: dict) -> str:
9
+ # "example.com/app/web" + "render" reads as "web.render"; the program's entry stays "main".
10
+ fn = frame.get("function") or ""
11
+ pkg = (frame.get("package") or "").rsplit("/", 1)[-1]
12
+ if frame.get("receiver"):
13
+ fn = f"{frame['receiver'].lstrip('*')}.{fn}"
14
+ return fn if fn == "main" or not pkg else f"{pkg}.{fn}"
15
+
16
+
17
+ def read_govulncheck(text: str) -> dict:
18
+ """govulncheck -format json output: each advisory's deepest reach, its fixing version, aliases and call path."""
19
+ osv, found, scanner = {}, {}, None
20
+ for line in _objects(text):
21
+ if isinstance(line.get("config"), dict):
22
+ c = line["config"]
23
+ scanner = f"{c.get('scanner_name', 'govulncheck')} {c.get('scanner_version', '')}".strip()
24
+ elif isinstance(line.get("osv"), dict) and line["osv"].get("id"):
25
+ osv[line["osv"]["id"]] = line["osv"]
26
+ elif isinstance(line.get("finding"), dict) and line["finding"].get("osv"):
27
+ f = line["finding"]
28
+ trace = [t for t in f.get("trace") or [] if isinstance(t, dict)]
29
+ top = trace[0] if trace else {}
30
+ reach = "called" if top.get("function") else "imported" if top.get("package") else "required"
31
+ mine = found.setdefault(f["osv"], {"id": f["osv"], "reach": reach, "fixed": f.get("fixed_version"),
32
+ "module": top.get("module"), "version": top.get("version"), "path": []})
33
+ if LEVELS.index(reach) >= LEVELS.index(mine["reach"]):
34
+ mine["reach"] = reach
35
+ if reach == "called":
36
+ mine["path"] = [_short(t) for t in reversed(trace) if t.get("function")]
37
+ findings = []
38
+ for i, f in sorted(found.items()):
39
+ o = osv.get(i) or {}
40
+ findings.append({**f, "aliases": list(o.get("aliases") or []), "summary": str(o.get("summary") or "")[:200]})
41
+ return {"scanner": scanner, "findings": findings,
42
+ "counts": {level: sum(1 for f in findings if f["reach"] == level) for level in LEVELS}}
43
+
44
+
45
+ def _objects(text: str):
46
+ # govulncheck writes indented JSON objects one after another; read them whole, skipping anything else.
47
+ decoder, at = json.JSONDecoder(), 0
48
+ while at < len(text):
49
+ start = text.find("{", at)
50
+ if start < 0:
51
+ return
52
+ try:
53
+ obj, end = decoder.raw_decode(text, start)
54
+ except ValueError:
55
+ at = start + 1
56
+ continue
57
+ at = end
58
+ if isinstance(obj, dict):
59
+ yield obj
@@ -0,0 +1,110 @@
1
+ """Signs a new release may be compromised, before any advisory exists: it is only hours old, it adds an install script,
2
+ it pulls in new dependencies, or the signed record of where it was built is gone. Compared with the version it
3
+ replaces, from the registries' own metadata. A sign is a reason to look first, never a block."""
4
+ from datetime import datetime, timedelta
5
+ from urllib.parse import quote
6
+
7
+ NPM = "https://registry.npmjs.org"
8
+ PYPI = "https://pypi.org"
9
+ DEPS_DEV = "https://api.deps.dev/v3/systems"
10
+ HOOKS = ("preinstall", "install", "postinstall")
11
+ # 2025-26 compromised releases stayed up from 40 minutes to a few hours; a release younger than this gets a sign.
12
+ COOLDOWN = timedelta(hours=48)
13
+ MAX_PACKAGES = 30
14
+
15
+
16
+ def _key(version: str) -> list:
17
+ import re
18
+ return [(0, int(p)) if p.isdigit() else (1, p) for p in re.split(r"[.\-+_]", str(version)) if p]
19
+
20
+
21
+ def _get(client, url: str) -> dict | None:
22
+ try:
23
+ r = client.get(url)
24
+ except Exception: # noqa: BLE001 - a registry hiccup means no sign, never a failed check
25
+ return None
26
+ return r.json() if r.status_code == 200 else None
27
+
28
+
29
+ def _age(published: str | None, now: datetime) -> str | None:
30
+ if not published:
31
+ return None
32
+ when = datetime.fromisoformat(published.replace("Z", "+00:00"))
33
+ hours = (now - when).total_seconds() / 3600
34
+ if 0 <= hours < COOLDOWN.total_seconds() / 3600:
35
+ n = max(1, round(hours))
36
+ return (f"Released {n} hour{'' if n == 1 else 's'} ago: compromised releases are usually found and pulled within hours, "
37
+ "so a few days' wait is safer.")
38
+ return None
39
+
40
+
41
+ def npm_signs(client, name: str, old: str | None, new: str, now: datetime) -> list[str]:
42
+ path = quote(name, safe="@")
43
+ after = _get(client, f"{NPM}/{path}/{quote(new)}")
44
+ if not after:
45
+ return []
46
+ signs = []
47
+ meta = _get(client, f"{DEPS_DEV}/npm/packages/{quote(name, safe='')}/versions/{quote(new, safe='')}") or {}
48
+ if age := _age(meta.get("publishedAt"), now):
49
+ signs.append(age)
50
+ before = _get(client, f"{NPM}/{path}/{quote(old)}") if old else None
51
+ hooks = [h for h in HOOKS if (after.get("scripts") or {}).get(h)]
52
+ if before is not None:
53
+ added = [h for h in hooks if not (before.get("scripts") or {}).get(h)]
54
+ if added:
55
+ signs.append(f"Adds an install script ({', '.join(added)}) that {old} didn't have: it runs when the package installs.")
56
+ new_deps = sorted(set(after.get("dependencies") or {}) - set(before.get("dependencies") or {}))
57
+ if new_deps:
58
+ signs.append(f"New dependencies since {old}: {', '.join(new_deps[:5])}{' and more' if len(new_deps) > 5 else ''}.")
59
+ if (before.get("dist") or {}).get("attestations") and not (after.get("dist") or {}).get("attestations"):
60
+ signs.append(f"{old} carried build provenance (a signed record of where it was built); {new} doesn't.")
61
+ return signs
62
+
63
+
64
+ def _pypi_release(client, name: str, version: str) -> dict | None:
65
+ return _get(client, f"{PYPI}/pypi/{quote(name)}/{quote(version)}/json")
66
+
67
+
68
+ def _pypi_provenance(client, name: str, version: str, doc: dict) -> bool | None:
69
+ files = doc.get("urls") or []
70
+ if not files:
71
+ return None
72
+ try:
73
+ r = client.get(f"{PYPI}/integrity/{quote(name)}/{quote(version)}/{quote(files[0]['filename'])}/provenance")
74
+ except Exception: # noqa: BLE001
75
+ return None
76
+ return r.status_code == 200
77
+
78
+
79
+ def pypi_signs(client, name: str, old: str | None, new: str, now: datetime) -> list[str]:
80
+ after = _pypi_release(client, name, new)
81
+ if not after:
82
+ return []
83
+ signs = []
84
+ times = sorted(u.get("upload_time_iso_8601") for u in after.get("urls") or [] if u.get("upload_time_iso_8601"))
85
+ if age := _age(times[0] if times else None, now):
86
+ signs.append(age)
87
+ before = _pypi_release(client, name, old) if old else None
88
+ if before is not None:
89
+ def names(doc):
90
+ import re
91
+ return {re.split(r"[\s<>=!~;\[(]", r, 1)[0].lower() for r in (doc.get("info") or {}).get("requires_dist") or []
92
+ if "extra ==" not in r}
93
+ new_deps = sorted(names(after) - names(before))
94
+ if new_deps:
95
+ signs.append(f"New dependencies since {old}: {', '.join(new_deps[:5])}{' and more' if len(new_deps) > 5 else ''}.")
96
+ if _pypi_provenance(client, name, old, before) and _pypi_provenance(client, name, new, after) is False:
97
+ signs.append(f"{old} carried build provenance (a signed record of where it was built); {new} doesn't.")
98
+ return signs
99
+
100
+
101
+ def release_signs(client, ecosystem: str, name: str, old_versions: list[str], new_versions: list[str],
102
+ now: datetime) -> list[str]:
103
+ """Signs for the newest version a change installs, against the newest version it replaces."""
104
+ if not new_versions or ecosystem not in ("npm", "pypi"):
105
+ return []
106
+ new = max(new_versions, key=_key)
107
+ old = max(old_versions, key=_key) if old_versions else None
108
+ if old and _key(old) >= _key(new):
109
+ return [] # a downgrade or the same version: nothing new arrived
110
+ return (npm_signs if ecosystem == "npm" else pypi_signs)(client, name, old, new, now)
@@ -0,0 +1,161 @@
1
+ """The language runtimes a repo builds and runs on (Node.js, Python, PHP, Ruby, Go), read from its version files,
2
+ Dockerfiles and workflow setup steps, checked against endoflife.date: a release line out of support gets no security
3
+ fixes, and a pinned patch older than the newest one misses the fixes since. Runtime flaws (PHP's in October 2026) are
4
+ not package advisories, so lockfile scans never see them."""
5
+ import json
6
+ import re
7
+
8
+ EOL_API = "https://endoflife.date/api"
9
+ PRODUCTS = {"node": "nodejs", "python": "python", "php": "php", "ruby": "ruby", "go": "go", "debian": "debian", "ubuntu": "ubuntu",
10
+ "alpine": "alpine-linux"}
11
+ FILES = re.compile(r"(^|/)(\.nvmrc|\.node-version|\.python-version|\.php-version|\.ruby-version|\.tool-versions|runtime\.txt"
12
+ r"|go\.mod|package\.json|composer\.json|Dockerfile[^/]*|\.github/workflows/[^/]+\.ya?ml)$")
13
+ SKIP = re.compile(r"(^|/)(node_modules|vendor|third_party|testdata|fixtures?|examples?)/")
14
+ MAX_FILES = 40
15
+ VERSION = r"v?(\d+(?:\.\d+){0,2})"
16
+ SETUP = {"node": re.compile(rf"node-version:\s*['\"]?{VERSION}"), "python": re.compile(rf"python-version:\s*['\"]?{VERSION}"),
17
+ "php": re.compile(rf"php-version:\s*['\"]?{VERSION}"), "ruby": re.compile(rf"ruby-version:\s*['\"]?{VERSION}"),
18
+ "go": re.compile(rf"go-version:\s*['\"]?{VERSION}")}
19
+ MATRIX_KEYS = {"php": "php", "php-version": "php", "php-versions": "php", "python": "python", "python-version": "python",
20
+ "python-versions": "python", "node": "node", "node-version": "node", "node-versions": "node", "ruby": "ruby",
21
+ "ruby-version": "ruby", "go": "go", "go-version": "go"}
22
+ MATRIX = re.compile(r"(?mi)^\s*(" + "|".join(re.escape(k) for k in MATRIX_KEYS) + r")\s*:\s*(?:\[([^\]\n]*)\]|\n((?:[ \t]*-[ \t]*['\"]?[\d.]+['\"]?[ \t]*\n)+))")
23
+ IMAGE = re.compile(rf"(?mi)^\s*FROM\s+(?:[\w./-]+/)?(node|python|php|ruby|golang)[:@]{VERSION}")
24
+ # Base operating system images, by version or codename (endoflife.date's debian, ubuntu and alpine).
25
+ DEBIAN = {"stretch": "9", "buster": "10", "bullseye": "11", "bookworm": "12", "trixie": "13"}
26
+ UBUNTU = {"bionic": "18.04", "focal": "20.04", "jammy": "22.04", "noble": "24.04", "oracular": "24.10", "plucky": "25.04"}
27
+ CODENAMES = {**DEBIAN, **UBUNTU}
28
+ OS_IMAGE = re.compile(r"(?mi)^\s*FROM\s+(?:--platform=\S+\s+)?(?:[\w./-]+/)?(debian|ubuntu|alpine)[:]([\w.]+)")
29
+ SUFFIX = re.compile(r"(?mi)^\s*FROM\s+(?:--platform=\S+\s+)?(?:[\w./-]+/)?(?:node|python|php|ruby|golang)[:][\w.]*?-(?:slim-)?("
30
+ + "|".join(CODENAMES) + r")\b")
31
+
32
+
33
+ def wanted(path: str) -> bool:
34
+ return bool(FILES.search(path)) and not SKIP.search(path)
35
+
36
+
37
+ def detect(files: dict[str, str]) -> list[dict]:
38
+ """Every runtime version a repo names, with where: [{"runtime", "version", "file"}]; versions that aren't a number
39
+ ("lts/*", ">=18") are left out rather than guessed."""
40
+ found = []
41
+
42
+ def add(runtime, version, file, use="runs"):
43
+ # "runs": what the project runs on; "tests": a test matrix, where old lines are often kept on purpose.
44
+ if version and re.fullmatch(r"\d+(\.\d+){0,2}", version):
45
+ found.append({"runtime": runtime, "version": version, "file": file, "use": use})
46
+ for path, text in files.items():
47
+ name = path.rsplit("/", 1)[-1]
48
+ first = text.strip().splitlines()[0].strip() if text.strip() else ""
49
+ if name in (".nvmrc", ".node-version"):
50
+ add("node", first.lstrip("v"), path)
51
+ elif name == ".python-version":
52
+ add("python", first, path)
53
+ elif name == ".php-version":
54
+ add("php", first, path)
55
+ elif name == ".ruby-version":
56
+ add("ruby", first.removeprefix("ruby-"), path)
57
+ elif name == "runtime.txt":
58
+ m = re.match(rf"(python|php|ruby)-{VERSION}", first)
59
+ if m:
60
+ add(m.group(1), m.group(2), path)
61
+ elif name == ".tool-versions":
62
+ for line in text.splitlines():
63
+ parts = line.split()
64
+ if len(parts) >= 2 and parts[0] in ("nodejs", "python", "php", "ruby", "golang"):
65
+ add({"nodejs": "node", "golang": "go"}.get(parts[0], parts[0]), parts[1], path)
66
+ elif name == "go.mod":
67
+ m = re.search(r"(?m)^go\s+(\d+\.\d+(?:\.\d+)?)", text)
68
+ if m:
69
+ add("go", m.group(1), path)
70
+ elif name.startswith("Dockerfile"):
71
+ for runtime, version in IMAGE.findall(text):
72
+ add({"golang": "go"}.get(runtime.lower(), runtime.lower()), version, path)
73
+ # The operating system under the image: debian:bookworm, ubuntu:22.04, alpine:3.18, or a language image's
74
+ # suffix (python:3.11-slim-bookworm). An end-of-life base gets no security fixes for its packages.
75
+ for os_name, tag in OS_IMAGE.findall(text):
76
+ version = CODENAMES.get(tag.split("-")[0].lower(), tag.split("-")[0])
77
+ add(os_name.lower(), version, path)
78
+ for codename in SUFFIX.findall(text):
79
+ add("debian" if codename.lower() in DEBIAN else "ubuntu", CODENAMES[codename.lower()], path)
80
+ elif path.startswith(".github/workflows/"):
81
+ for runtime, pattern in SETUP.items():
82
+ for version in pattern.findall(text):
83
+ add(runtime, version, path)
84
+ # A matrix names the versions: "php: [8.2, 8.3]" or a "- '3.12'" list under "python:".
85
+ for key, inline, block in MATRIX.findall(text):
86
+ runtime = MATRIX_KEYS[key.lower()]
87
+ for version in re.findall(r"['\"]?(\d+(?:\.\d+){0,2})['\"]?", inline or block):
88
+ add(runtime, version, path, "tests")
89
+ elif name == "package.json":
90
+ try:
91
+ engine = str(((json.loads(text) or {}).get("engines") or {}).get("node") or "")
92
+ except ValueError:
93
+ engine = ""
94
+ m = re.fullmatch(rf"\s*[\^~]?{VERSION}(\.x)?\s*", engine)
95
+ if m:
96
+ add("node", m.group(1), path)
97
+ seen, out = set(), []
98
+ for f in found:
99
+ k = (f["runtime"], f["version"], f["file"], f["use"])
100
+ if k not in seen:
101
+ seen.add(k)
102
+ out.append(f)
103
+ return out
104
+
105
+
106
+ def _cycle(product: str, version: str, cycles: list[dict]) -> dict | None:
107
+ # The release line a version belongs to: "8.3" for PHP 8.3.4, "20" for Node 20.11, "3.11" for Python 3.11.2.
108
+ parts = version.split(".")
109
+ for n in (2, 1):
110
+ name = ".".join(parts[:n])
111
+ hit = next((c for c in cycles if str(c.get("cycle")) == name), None)
112
+ if hit:
113
+ return hit
114
+ return None
115
+
116
+
117
+ def judge(found: list[dict], cycles: dict[str, list[dict]], today: str) -> list[dict]:
118
+ """For each runtime version found: its release line, whether that line is out of support (and since when), and
119
+ the newest patch when the repo pins an older one."""
120
+ out = []
121
+ for f in found:
122
+ product = PRODUCTS[f["runtime"]]
123
+ c = _cycle(product, f["version"], cycles.get(product) or [])
124
+ if not c:
125
+ out.append({**f, "status": "unknown"})
126
+ continue
127
+ eol = c.get("eol")
128
+ ended = eol is True or (isinstance(eol, str) and eol <= today)
129
+ latest = str(c.get("latest") or "")
130
+ pinned = f["version"].count(".") >= 2 # a full version; "8.3" or "20" installs the newest patch
131
+ behind = pinned and latest and _key(f["version"]) < _key(latest)
132
+ out.append({**f, "cycle": str(c.get("cycle")), "eol": eol if isinstance(eol, str) else None, "latest": latest or None,
133
+ "latest_date": c.get("latestReleaseDate"),
134
+ "status": "end of life" if ended else "behind the newest patch" if behind else "supported"})
135
+ return out
136
+
137
+
138
+ def _key(version: str) -> list:
139
+ return [int(p) if p.isdigit() else 0 for p in version.split(".")]
140
+
141
+
142
+ def runtimes(api, ref: str, client) -> list[dict]:
143
+ """Read the files that name runtimes at `ref` (one tree read, at most 40 files) and judge each version."""
144
+ tree = api._get(f"/repos/{api.repo}/git/trees/{ref}", {"recursive": "1"})
145
+ paths = [t["path"] for t in tree.get("tree") or [] if t.get("type") == "blob" and wanted(t["path"])][:MAX_FILES]
146
+ files = {}
147
+ for p in paths:
148
+ try:
149
+ files[p] = api.file(p, ref).decode("utf-8", "replace")
150
+ except Exception: # noqa: BLE001 - one unreadable file is skipped
151
+ continue
152
+ found = detect(files)
153
+ cycles = {}
154
+ for product in sorted({PRODUCTS[f["runtime"]] for f in found}):
155
+ try:
156
+ r = client.get(f"{EOL_API}/{product}.json")
157
+ cycles[product] = r.json() if r.status_code == 200 else []
158
+ except Exception: # noqa: BLE001
159
+ cycles[product] = []
160
+ from datetime import date
161
+ return judge(found, cycles, date.today().isoformat())