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.
- mergescout/__init__.py +1 -0
- mergescout/archive/__init__.py +0 -0
- mergescout/archive/__main__.py +30 -0
- mergescout/archive/convert.py +83 -0
- mergescout/archive/events.py +55 -0
- mergescout/archive/manifest.py +45 -0
- mergescout/archive/stream.py +83 -0
- mergescout/bench/__init__.py +1 -0
- mergescout/bench/__main__.py +135 -0
- mergescout/bench/ci.py +257 -0
- mergescout/bench/deploys.py +115 -0
- mergescout/bench/matched.py +22 -0
- mergescout/bench/prior.py +42 -0
- mergescout/bench/report.py +230 -0
- mergescout/bench/shared.py +112 -0
- mergescout/bench/stars.py +57 -0
- mergescout/cli.py +89 -0
- mergescout/config.py +35 -0
- mergescout/crawl/__init__.py +0 -0
- mergescout/crawl/__main__.py +97 -0
- mergescout/crawl/chain.py +34 -0
- mergescout/crawl/fetch.py +146 -0
- mergescout/crawl/outcomes.py +69 -0
- mergescout/crawl/queries.py +250 -0
- mergescout/crawl/runner.py +176 -0
- mergescout/crawl/seed.py +77 -0
- mergescout/crawl/state.py +169 -0
- mergescout/crawl/writer.py +87 -0
- mergescout/discover/__init__.py +0 -0
- mergescout/discover/__main__.py +58 -0
- mergescout/discover/sweep.py +286 -0
- mergescout/github/__init__.py +0 -0
- mergescout/github/graphql.py +202 -0
- mergescout/github/identities.py +35 -0
- mergescout/github/releases.py +137 -0
- mergescout/labels.py +20 -0
- mergescout/mcp_server.py +228 -0
- mergescout/measure/__init__.py +0 -0
- mergescout/measure/__main__.py +75 -0
- mergescout/measure/api_cost.py +91 -0
- mergescout/measure/count_repos.py +30 -0
- mergescout/measure/jsonl.py +14 -0
- mergescout/measure/report.py +56 -0
- mergescout/measure/sample.py +37 -0
- mergescout/owners.py +22 -0
- mergescout/setup_check.py +64 -0
- mergescout/signals/__init__.py +0 -0
- mergescout/signals/__main__.py +96 -0
- mergescout/signals/bump_ledger.py +258 -0
- mergescout/signals/bumps.py +104 -0
- mergescout/signals/check.py +141 -0
- mergescout/signals/community_bench.py +149 -0
- mergescout/signals/delivery_bench.py +103 -0
- mergescout/signals/deps_map.py +261 -0
- mergescout/signals/deps_trends.py +117 -0
- mergescout/signals/evidence.py +94 -0
- mergescout/signals/exploits.py +43 -0
- mergescout/signals/flow_bench.py +258 -0
- mergescout/signals/identity.py +73 -0
- mergescout/signals/issues.py +76 -0
- mergescout/signals/labels.py +79 -0
- mergescout/signals/osv.py +232 -0
- mergescout/signals/owners_watch.py +80 -0
- mergescout/signals/repo_stats.py +96 -0
- mergescout/signals/rules.py +70 -0
- mergescout/signals/silver.py +33 -0
- mergescout/signals/top_packages.py +26 -0
- mergescout/signals/triage.py +71 -0
- mergescout/signals/triage_math.py +120 -0
- mergescout/signals/walls.py +91 -0
- mergescout/snapshot/__init__.py +0 -0
- mergescout/snapshot/__main__.py +215 -0
- mergescout/snapshot/build.py +147 -0
- mergescout/snapshot/publish.py +70 -0
- mergescout/snapshot/schema.py +34 -0
- mergescout/snapshot/score.py +30 -0
- mergescout/store/__init__.py +0 -0
- mergescout/store/__main__.py +46 -0
- mergescout/store/sync.py +148 -0
- mergescout/timeutil.py +11 -0
- mergescout/tool/__init__.py +0 -0
- mergescout/tool/action_pack.py +62 -0
- mergescout/tool/live.py +201 -0
- mergescout/tool/queries.py +293 -0
- mergescout/tool/refs.py +31 -0
- mergescout/tool/store.py +129 -0
- mergescout/tool/tracking.py +83 -0
- stackvitals/__init__.py +2 -0
- stackvitals/__main__.py +4 -0
- stackvitals/ci/__init__.py +1 -0
- stackvitals/ci/action.py +574 -0
- stackvitals/ci/analyze.py +577 -0
- stackvitals/ci/api.py +218 -0
- stackvitals/ci/automation.py +212 -0
- stackvitals/ci/bench.py +195 -0
- stackvitals/ci/burnin.py +102 -0
- stackvitals/ci/cli.py +570 -0
- stackvitals/ci/collect.py +239 -0
- stackvitals/ci/cost.py +107 -0
- stackvitals/ci/coverage.py +155 -0
- stackvitals/ci/feedback.py +699 -0
- stackvitals/ci/fingerprint.py +220 -0
- stackvitals/ci/gitlab.py +121 -0
- stackvitals/ci/gitlab_action.py +125 -0
- stackvitals/ci/impact.py +125 -0
- stackvitals/ci/investigate.py +59 -0
- stackvitals/ci/kinds.py +52 -0
- stackvitals/ci/monitors.py +105 -0
- stackvitals/ci/owners.py +116 -0
- stackvitals/ci/quarantine_runtime.py +90 -0
- stackvitals/ci/report.py +435 -0
- stackvitals/ci/retry.py +67 -0
- stackvitals/ci/selfheal.py +102 -0
- stackvitals/ci/shards.py +120 -0
- stackvitals/ci/share.py +59 -0
- stackvitals/ci/store.py +93 -0
- stackvitals/ci/testreports.py +351 -0
- stackvitals/ci/tickets.py +106 -0
- stackvitals/ci/track_record.json +11 -0
- stackvitals/ci/triage.py +130 -0
- stackvitals/ci/triggers.py +147 -0
- stackvitals/ci/upload.py +85 -0
- stackvitals/ci/validate.py +91 -0
- stackvitals/cli.py +111 -0
- stackvitals/codeowners.py +69 -0
- stackvitals/community/__init__.py +0 -0
- stackvitals/community/action.py +140 -0
- stackvitals/community/affiliation.py +51 -0
- stackvitals/community/analyze.py +507 -0
- stackvitals/community/build.py +56 -0
- stackvitals/community/chaoss.py +70 -0
- stackvitals/community/cli.py +79 -0
- stackvitals/community/collect.py +240 -0
- stackvitals/community/context.py +54 -0
- stackvitals/community/report.py +250 -0
- stackvitals/connectors/__init__.py +244 -0
- stackvitals/connectors/ai_spend.py +190 -0
- stackvitals/connectors/base.py +82 -0
- stackvitals/connectors/check.py +686 -0
- stackvitals/connectors/copilot.py +104 -0
- stackvitals/connectors/discord.py +103 -0
- stackvitals/connectors/discourse.py +117 -0
- stackvitals/connectors/incident_io.py +106 -0
- stackvitals/connectors/jira.py +210 -0
- stackvitals/connectors/linear.py +140 -0
- stackvitals/connectors/pagerduty.py +96 -0
- stackvitals/connectors/slack.py +110 -0
- stackvitals/connectors/sonar.py +77 -0
- stackvitals/connectors/store.py +120 -0
- stackvitals/connectors/tracker.py +108 -0
- stackvitals/connectors/writeback.py +105 -0
- stackvitals/contribute/__init__.py +1 -0
- stackvitals/contribute/brief.py +297 -0
- stackvitals/contribute/cli.py +64 -0
- stackvitals/core/__init__.py +1 -0
- stackvitals/core/actors.py +103 -0
- stackvitals/core/agent_ledger.py +120 -0
- stackvitals/core/agreements.py +95 -0
- stackvitals/core/ai_outcomes.py +276 -0
- stackvitals/core/alerts.py +224 -0
- stackvitals/core/appdata.py +112 -0
- stackvitals/core/bucket.py +190 -0
- stackvitals/core/bugs.py +43 -0
- stackvitals/core/calendar.py +34 -0
- stackvitals/core/capex.py +57 -0
- stackvitals/core/change_risk.py +181 -0
- stackvitals/core/changes.py +133 -0
- stackvitals/core/chat.py +143 -0
- stackvitals/core/config.py +373 -0
- stackvitals/core/dataflow.py +168 -0
- stackvitals/core/decisions.py +275 -0
- stackvitals/core/delivery.py +954 -0
- stackvitals/core/delivery_peers.py +98 -0
- stackvitals/core/delivery_report.py +429 -0
- stackvitals/core/deliverycli.py +147 -0
- stackvitals/core/devlake_dora.sql +485 -0
- stackvitals/core/digest.py +333 -0
- stackvitals/core/explaincli.py +107 -0
- stackvitals/core/explore.py +296 -0
- stackvitals/core/export.py +342 -0
- stackvitals/core/exportcli.py +117 -0
- stackvitals/core/findings.py +350 -0
- stackvitals/core/health.py +94 -0
- stackvitals/core/identity.py +82 -0
- stackvitals/core/insights.py +365 -0
- stackvitals/core/insightscli.py +43 -0
- stackvitals/core/installdoc.py +69 -0
- stackvitals/core/investment.py +187 -0
- stackvitals/core/joins.py +43 -0
- stackvitals/core/llmsdoc.py +40 -0
- stackvitals/core/merge_queue.py +70 -0
- stackvitals/core/metrics.py +109 -0
- stackvitals/core/model.py +285 -0
- stackvitals/core/projectcli.py +66 -0
- stackvitals/core/propose.py +86 -0
- stackvitals/core/pulse.py +85 -0
- stackvitals/core/recorded.py +145 -0
- stackvitals/core/releases.py +39 -0
- stackvitals/core/requests.py +528 -0
- stackvitals/core/resync.py +35 -0
- stackvitals/core/schema.py +205 -0
- stackvitals/core/services.py +203 -0
- stackvitals/core/settings.py +96 -0
- stackvitals/core/sqlcli.py +111 -0
- stackvitals/core/stats.py +222 -0
- stackvitals/core/store.py +125 -0
- stackvitals/core/summary.py +104 -0
- stackvitals/core/time.py +29 -0
- stackvitals/core/tomledit.py +149 -0
- stackvitals/core/uicli.py +138 -0
- stackvitals/core/webapp.py +79 -0
- stackvitals/core/what_changed.py +76 -0
- stackvitals/core/workspace.py +194 -0
- stackvitals/deps/__init__.py +1 -0
- stackvitals/deps/action.py +299 -0
- stackvitals/deps/actions_vulns.py +89 -0
- stackvitals/deps/cli.py +77 -0
- stackvitals/deps/dossier.py +341 -0
- stackvitals/deps/evidence.py +44 -0
- stackvitals/deps/fixplan.py +40 -0
- stackvitals/deps/guarddog.py +32 -0
- stackvitals/deps/hygiene.py +47 -0
- stackvitals/deps/imports.py +113 -0
- stackvitals/deps/lockfiles.py +198 -0
- stackvitals/deps/lookalike.py +80 -0
- stackvitals/deps/reach.py +59 -0
- stackvitals/deps/release.py +110 -0
- stackvitals/deps/runtimes.py +161 -0
- stackvitals/deps/supply.py +454 -0
- stackvitals/deps/vulns.py +588 -0
- stackvitals/editors/__init__.py +40 -0
- stackvitals/editors/vscode/extension.js +101 -0
- stackvitals/editors/vscode/marks.js +92 -0
- stackvitals/editors/vscode/package.json +26 -0
- stackvitals/flow/__init__.py +1 -0
- stackvitals/flow/action.py +178 -0
- stackvitals/flow/analyze.py +418 -0
- stackvitals/flow/automate.py +72 -0
- stackvitals/flow/build.py +46 -0
- stackvitals/flow/cli.py +93 -0
- stackvitals/flow/code_hosts.py +146 -0
- stackvitals/flow/collect.py +451 -0
- stackvitals/flow/gitlab_mr.py +75 -0
- stackvitals/flow/knowledge.py +202 -0
- stackvitals/flow/peers.py +90 -0
- stackvitals/flow/preflight.py +210 -0
- stackvitals/flow/report.py +253 -0
- stackvitals/flow/rules.py +135 -0
- stackvitals/flow/store.py +108 -0
- stackvitals/flow/survival.py +84 -0
- stackvitals/flow/triage.py +62 -0
- stackvitals/mcp_server.py +670 -0
- stackvitals/portal/__init__.py +0 -0
- stackvitals/portal/action.py +205 -0
- stackvitals/portal/batch.py +80 -0
- stackvitals/portal/campaigns.py +104 -0
- stackvitals/portal/catalog.py +104 -0
- stackvitals/portal/checks.py +63 -0
- stackvitals/portal/cli.py +172 -0
- stackvitals/portal/descriptor.py +105 -0
- stackvitals/portal/export.py +43 -0
- stackvitals/portal/github.py +48 -0
- stackvitals/portal/golden.py +95 -0
- stackvitals/portal/history.py +30 -0
- stackvitals/portal/inventory.py +264 -0
- stackvitals/portal/owners.py +78 -0
- stackvitals/portal/pages.py +153 -0
- stackvitals/portal/rollup.py +32 -0
- stackvitals/portal/scorecard.py +366 -0
- stackvitals/security/__init__.py +2 -0
- stackvitals/security/probes.py +154 -0
- stackvitals/security/repo_checks.py +132 -0
- stackvitals/security/workflows.py +111 -0
- stackvitals/shell.py +107 -0
- stackvitals/site.py +51 -0
- stackvitals/web/app/LICENSES.md +428 -0
- stackvitals/web/app/boot.js +1 -0
- stackvitals/web/app/chunks/AddRepo-KRPZXWCY.js +1 -0
- stackvitals/web/app/chunks/SetupRunning-XWFQAKRZ.js +1 -0
- stackvitals/web/app/chunks/accuracy-data-OQTDXI7Y.js +1 -0
- stackvitals/web/app/chunks/agents-data-BAL6QAOF.js +11 -0
- stackvitals/web/app/chunks/chunk-24LFKFZ6.js +1 -0
- stackvitals/web/app/chunks/chunk-3G4SQNUC.js +1 -0
- stackvitals/web/app/chunks/chunk-3IXWEOIJ.js +154 -0
- stackvitals/web/app/chunks/chunk-3ROQFHPV.js +1 -0
- stackvitals/web/app/chunks/chunk-45PGAQRO.js +8 -0
- stackvitals/web/app/chunks/chunk-4H53OYLG.js +1 -0
- stackvitals/web/app/chunks/chunk-4LUI7O5P.js +1 -0
- stackvitals/web/app/chunks/chunk-4SQPQ5TB.js +1 -0
- stackvitals/web/app/chunks/chunk-4WKQTWUC.js +2 -0
- stackvitals/web/app/chunks/chunk-576YHDEK.js +8 -0
- stackvitals/web/app/chunks/chunk-6BDBXA6V.js +1 -0
- stackvitals/web/app/chunks/chunk-6RLVD2DT.js +1 -0
- stackvitals/web/app/chunks/chunk-763NH7PP.js +1 -0
- stackvitals/web/app/chunks/chunk-76WAVKUG.js +1 -0
- stackvitals/web/app/chunks/chunk-7II2BD5F.js +1 -0
- stackvitals/web/app/chunks/chunk-7TJCBOAY.js +1 -0
- stackvitals/web/app/chunks/chunk-ASYNLVOP.js +1 -0
- stackvitals/web/app/chunks/chunk-CTKDYZMG.js +1 -0
- stackvitals/web/app/chunks/chunk-CVB26NMC.js +1 -0
- stackvitals/web/app/chunks/chunk-G7NGB5UX.js +1 -0
- stackvitals/web/app/chunks/chunk-GPVLJRZD.js +1 -0
- stackvitals/web/app/chunks/chunk-GQYFN4PE.js +75 -0
- stackvitals/web/app/chunks/chunk-GT4EAKAO.js +2 -0
- stackvitals/web/app/chunks/chunk-IYMBFPCV.js +1 -0
- stackvitals/web/app/chunks/chunk-LAAGCPU3.js +1 -0
- stackvitals/web/app/chunks/chunk-MAPYGK3P.js +1 -0
- stackvitals/web/app/chunks/chunk-P5TXDD45.js +1 -0
- stackvitals/web/app/chunks/chunk-PABICGBL.js +1 -0
- stackvitals/web/app/chunks/chunk-RPXHAG6F.js +1 -0
- stackvitals/web/app/chunks/chunk-RQZPSJ2Y.js +1 -0
- stackvitals/web/app/chunks/chunk-S2ZBMEVA.js +1 -0
- stackvitals/web/app/chunks/chunk-S654BBEP.js +6 -0
- stackvitals/web/app/chunks/chunk-T7QVTNXF.js +1 -0
- stackvitals/web/app/chunks/chunk-THFUOCPC.js +273 -0
- stackvitals/web/app/chunks/chunk-TO4VVRZ7.js +1 -0
- stackvitals/web/app/chunks/chunk-VQ5YXO54.js +1 -0
- stackvitals/web/app/chunks/chunk-VWKSMFWJ.js +1 -0
- stackvitals/web/app/chunks/chunk-Y6LUXDM6.js +1 -0
- stackvitals/web/app/chunks/chunk-YTANGYYH.js +1 -0
- stackvitals/web/app/chunks/connect-KONYWYTT.js +1 -0
- stackvitals/web/app/chunks/connections-data-2DYS4UVW.js +32 -0
- stackvitals/web/app/chunks/logos-N5IINUAM.js +1 -0
- stackvitals/web/app/chunks/more-KMJMJQD3.js +10 -0
- stackvitals/web/app/chunks/more-PC6FVS3P.js +1 -0
- stackvitals/web/app/chunks/more-RCIVCUDE.js +2 -0
- stackvitals/web/app/chunks/more-ZKKRY6NO.css +1 -0
- stackvitals/web/app/chunks/sealbox-35YL4EUH.js +3 -0
- stackvitals/web/app/chunks/session-DYYTOE2K.js +1 -0
- stackvitals/web/app/chunks/settingsApply-2HRJEZYM.js +1 -0
- stackvitals/web/app/chunks/settingsReview-GX2FZQ4M.js +3 -0
- stackvitals/web/app/chunks/uPlot.esm-OIWT3AHJ.js +3 -0
- stackvitals/web/app/demo/ci/impact.json +1 -0
- stackvitals/web/app/demo/ci/quarantine.json +1 -0
- stackvitals/web/app/demo/ci/tickets.json +1 -0
- stackvitals/web/app/demo/mobile/alerts.json +1 -0
- stackvitals/web/app/demo/mobile/app.json +1 -0
- stackvitals/web/app/demo/mobile/community.json +1 -0
- stackvitals/web/app/demo/mobile/connectors.json +1 -0
- stackvitals/web/app/demo/mobile/data.json +1 -0
- stackvitals/web/app/demo/mobile/decisions.json +1 -0
- stackvitals/web/app/demo/mobile/delivery.json +1 -0
- stackvitals/web/app/demo/mobile/deps.json +1 -0
- stackvitals/web/app/demo/mobile/flow.json +1 -0
- stackvitals/web/app/demo/mobile/health.json +1 -0
- stackvitals/web/app/demo/mobile/insights.json +1 -0
- stackvitals/web/app/demo/mobile/pulse.json +1 -0
- stackvitals/web/app/demo/mobile/security.json +1 -0
- stackvitals/web/app/demo/mobile/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/mobile/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/mobile/summary/manifest.json +1 -0
- stackvitals/web/app/demo/mobile/summary/prs.jsonl +19 -0
- stackvitals/web/app/demo/mobile/summary/tests.jsonl +1 -0
- stackvitals/web/app/demo/mobile/supply.json +1 -0
- stackvitals/web/app/demo/mobile/vulns.json +1 -0
- stackvitals/web/app/demo/payments-api/alerts.json +1 -0
- stackvitals/web/app/demo/payments-api/app.json +1 -0
- stackvitals/web/app/demo/payments-api/community.json +1 -0
- stackvitals/web/app/demo/payments-api/connectors.json +1 -0
- stackvitals/web/app/demo/payments-api/data.json +1 -0
- stackvitals/web/app/demo/payments-api/decisions.json +1 -0
- stackvitals/web/app/demo/payments-api/delivery.json +1 -0
- stackvitals/web/app/demo/payments-api/deps.json +1 -0
- stackvitals/web/app/demo/payments-api/flow.json +1 -0
- stackvitals/web/app/demo/payments-api/health.json +1 -0
- stackvitals/web/app/demo/payments-api/insights.json +1 -0
- stackvitals/web/app/demo/payments-api/pulse.json +1 -0
- stackvitals/web/app/demo/payments-api/security.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/payments-api/summary/manifest.json +1 -0
- stackvitals/web/app/demo/payments-api/summary/prs.jsonl +22 -0
- stackvitals/web/app/demo/payments-api/summary/tests.jsonl +3 -0
- stackvitals/web/app/demo/payments-api/supply.json +1 -0
- stackvitals/web/app/demo/payments-api/vulns.json +1 -0
- stackvitals/web/app/demo/portal.json +1 -0
- stackvitals/web/app/demo/settings.json +1 -0
- stackvitals/web/app/demo/web-app/alerts.json +1 -0
- stackvitals/web/app/demo/web-app/app.json +1 -0
- stackvitals/web/app/demo/web-app/community.json +1 -0
- stackvitals/web/app/demo/web-app/connectors.json +1 -0
- stackvitals/web/app/demo/web-app/data.json +1 -0
- stackvitals/web/app/demo/web-app/decisions.json +1 -0
- stackvitals/web/app/demo/web-app/delivery.json +1 -0
- stackvitals/web/app/demo/web-app/deps.json +1 -0
- stackvitals/web/app/demo/web-app/flow.json +1 -0
- stackvitals/web/app/demo/web-app/health.json +1 -0
- stackvitals/web/app/demo/web-app/insights.json +1 -0
- stackvitals/web/app/demo/web-app/pulse.json +1 -0
- stackvitals/web/app/demo/web-app/security.json +1 -0
- stackvitals/web/app/demo/web-app/summary/ci_jobs.jsonl +5 -0
- stackvitals/web/app/demo/web-app/summary/devlake_project_pr_metrics.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/devlake_pull_requests.jsonl +14 -0
- stackvitals/web/app/demo/web-app/summary/manifest.json +1 -0
- stackvitals/web/app/demo/web-app/summary/prs.jsonl +18 -0
- stackvitals/web/app/demo/web-app/summary/tests.jsonl +2 -0
- stackvitals/web/app/demo/web-app/supply.json +1 -0
- stackvitals/web/app/demo/web-app/vulns.json +1 -0
- stackvitals/web/app/fonts/plex-mono-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-400.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-600.woff2 +0 -0
- stackvitals/web/app/fonts/plex-sans-condensed-700.woff2 +0 -0
- stackvitals/web/app/help/accuracy.json +1 -0
- stackvitals/web/app/help/connectors.json +1 -0
- stackvitals/web/app/help/getting-started.json +1 -0
- stackvitals/web/app/help/index.json +1 -0
- stackvitals/web/app/help/modules.json +1 -0
- stackvitals/web/app/help/privacy-and-security.json +1 -0
- stackvitals/web/app/help/reference.json +1 -0
- stackvitals/web/app/main.css +1 -0
- stackvitals/web/app/main.js +1 -0
- stackvitals/web/app/pages/about.css +1 -0
- stackvitals/web/app/pages/about.js +1 -0
- stackvitals/web/app/pages/activity.css +1 -0
- stackvitals/web/app/pages/activity.js +1 -0
- stackvitals/web/app/pages/agents.css +1 -0
- stackvitals/web/app/pages/agents.js +2 -0
- stackvitals/web/app/pages/ci.css +1 -0
- stackvitals/web/app/pages/ci.js +1 -0
- stackvitals/web/app/pages/community.css +1 -0
- stackvitals/web/app/pages/community.js +1 -0
- stackvitals/web/app/pages/connections.css +1 -0
- stackvitals/web/app/pages/connections.js +6 -0
- stackvitals/web/app/pages/delivery.css +1 -0
- stackvitals/web/app/pages/delivery.js +45 -0
- stackvitals/web/app/pages/deps.css +1 -0
- stackvitals/web/app/pages/deps.js +7 -0
- stackvitals/web/app/pages/explore.css +1 -0
- stackvitals/web/app/pages/explore.js +7 -0
- stackvitals/web/app/pages/health.css +1 -0
- stackvitals/web/app/pages/health.js +1 -0
- stackvitals/web/app/pages/help.css +1 -0
- stackvitals/web/app/pages/help.js +6 -0
- stackvitals/web/app/pages/insights.css +1 -0
- stackvitals/web/app/pages/insights.js +1 -0
- stackvitals/web/app/pages/investment.css +1 -0
- stackvitals/web/app/pages/investment.js +3 -0
- stackvitals/web/app/pages/overview.css +1 -0
- stackvitals/web/app/pages/overview.js +1 -0
- stackvitals/web/app/pages/portal.css +1 -0
- stackvitals/web/app/pages/portal.js +51 -0
- stackvitals/web/app/pages/prs.css +1 -0
- stackvitals/web/app/pages/prs.js +4 -0
- stackvitals/web/app/pages/settings.css +1 -0
- stackvitals/web/app/pages/settings.js +1 -0
- stackvitals/web/app/pages/setup.css +1 -0
- stackvitals/web/app/pages/setup.js +2 -0
- stackvitals/web/app/pages/standing.css +1 -0
- stackvitals/web/app/pages/standing.js +1 -0
- stackvitals/web/app/pages/welcome-sheet.js +1 -0
- stackvitals/web/app/pages/welcome.js +1 -0
- stackvitals/web/app/settings-workflow.json +1 -0
- stackvitals/web/index.html +34 -0
- stackvitals/web/site/index.html +108 -0
- stackvitals/web/site/integrations.html +15 -0
- stackvitals/web/site/setup.html +15 -0
- stackvitals/web/site/site.css +68 -0
- stackvitals-0.1.0.dist-info/METADATA +134 -0
- stackvitals-0.1.0.dist-info/RECORD +465 -0
- stackvitals-0.1.0.dist-info/WHEEL +4 -0
- stackvitals-0.1.0.dist-info/entry_points.txt +3 -0
- stackvitals-0.1.0.dist-info/licenses/LICENSE +202 -0
- stackvitals-0.1.0.dist-info/licenses/NOTICE +16 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""One package's dossier: use / monitor / investigate, and the evidence lines behind it, each with its counts, dates and
|
|
2
|
+
source. Never "safe": the evidence can only say what happened, not what will."""
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from mergescout.signals.bumps import _norm
|
|
7
|
+
|
|
8
|
+
# Upgrade rates are shown from 30 PRs in 10 repos; thinner cells only say how much evidence there is.
|
|
9
|
+
MIN_PRS, MIN_REPOS, MIN_CI = 30, 10, 20
|
|
10
|
+
EXCESS_FAIL = 0.10
|
|
11
|
+
# Full-crawl backtest (2026-10-20): flagged at 30% closed saw 1.45x the later closes of others, 40% 1.74x, 50% 1.79x, 60% 2.1x (13).
|
|
12
|
+
CLOSED_SHARE = 0.40
|
|
13
|
+
REVERT_SHARE = 0.03
|
|
14
|
+
STALE_DAYS = 730
|
|
15
|
+
ONE_MERGER = 0.8
|
|
16
|
+
SIZES = {"major": "Major upgrades", "minor": "Minor upgrades", "patch": "Patch upgrades", "nonmajor": "Minor or patch upgrades",
|
|
17
|
+
"range": "Range updates", "unknown": "Upgrades (size unknown)", "combined": "Upgrades (sizes combined)"}
|
|
18
|
+
# A steady merge rate is a good sign only when outside PRs actually get merged.
|
|
19
|
+
GOOD_MERGE_RATE = 0.3
|
|
20
|
+
# Of 440 crawled repos with 20+ outside PRs last quarter, half had 24%+ still open and 1 in 10 had 70%+ (2026-10-18).
|
|
21
|
+
MIN_OPENED = 20
|
|
22
|
+
STILL_OPEN = 0.7
|
|
23
|
+
# A fall under 15 points from the first quarter shown to the last is noise (22 of 102 "falling" repos, 2026-10-18).
|
|
24
|
+
FALL_STEP = 0.15
|
|
25
|
+
# 3+ advisories first published in 90 days: 4% of mapped packages (nltk, vllm, axios, electron), 2026-10-18.
|
|
26
|
+
BURST = 3
|
|
27
|
+
# EPSS at 10% or more: a common action line, a few percent of all scored CVEs (FIRST's own guidance names 10%).
|
|
28
|
+
EPSS_ALARM = 0.1
|
|
29
|
+
BAD_STATUS = {"ARCHIVED", "QUARANTINED", "DEPRECATED"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pct(x: float) -> int:
|
|
33
|
+
return round(100 * x)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _line(signal: str, text: str, source: str) -> dict:
|
|
37
|
+
return {"signal": signal, "text": text, "source": source}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def dossier(ecosystem: str, name: str, ev: dict, now: datetime, versions: list[str] | None = None) -> dict:
|
|
41
|
+
eco = ecosystem.lower()
|
|
42
|
+
pkg = _norm(eco, name)
|
|
43
|
+
key = f"{eco}:{pkg}"
|
|
44
|
+
m, cells, osv = ev["map"].get(key), ev["ledger"].get(key, []), ev["osv"].get(key)
|
|
45
|
+
out = {"ecosystem": eco, "package": pkg, "verdict": None, "reasons": [], "lines": [], "repo": (m or {}).get("repo"),
|
|
46
|
+
"osv": {"open_severity": (osv or {}).get("open_severity")},
|
|
47
|
+
"link": (m or {}).get("link"), "as_of": ev.get("built_at")}
|
|
48
|
+
if not m and not cells:
|
|
49
|
+
out["lines"].append(_line("info", f"{pkg} is not in the evidence set yet: packages join when crawled repos upgrade them.",
|
|
50
|
+
"ledger"))
|
|
51
|
+
return out
|
|
52
|
+
lines = (_osv_lines(osv, ev.get("osv_complete", True), (m or {}).get("version"), versions) + _status_lines(m) + _link_lines(m, now)
|
|
53
|
+
+ _release_lines(m, now))
|
|
54
|
+
upgrade_lines, upgrade_history = _ledger_lines(cells)
|
|
55
|
+
trend_lines, trend_history = _trend_lines(ev["trends"].get(((m or {}).get("repo") or "").lower()), m)
|
|
56
|
+
lines += upgrade_lines + trend_lines + _owner_lines((ev.get("owners") or {}).get(key), now)
|
|
57
|
+
out["lines"] = lines
|
|
58
|
+
alarms = [x["text"] for x in lines if x["signal"] == "alarm"]
|
|
59
|
+
warns = [x["text"] for x in lines if x["signal"] == "warn"]
|
|
60
|
+
if alarms:
|
|
61
|
+
out["verdict"], out["reasons"] = "investigate", alarms
|
|
62
|
+
elif warns:
|
|
63
|
+
out["verdict"], out["reasons"] = "monitor", warns
|
|
64
|
+
elif not (upgrade_history or trend_history):
|
|
65
|
+
# Too little history for any verdict (real data: 925 of 2,266 packages); saying "monitor" would mean nothing.
|
|
66
|
+
out["reasons"] = ["Not enough evidence yet: too few upgrades seen and no maintainer trend to judge by."]
|
|
67
|
+
elif not any(x["signal"] == "good" and x["source"] in ("ledger", "crawl") for x in lines):
|
|
68
|
+
# Real data: gradio's only history was a low upgrade merge rate; "use" with no good evidence would have no reasons.
|
|
69
|
+
out["reasons"] = ["Not enough evidence for a verdict: the history seen shows nothing clearly good or bad."]
|
|
70
|
+
else:
|
|
71
|
+
# History leads: how upgrades and maintainers behaved says more than an empty advisory list.
|
|
72
|
+
good = sorted((x for x in lines if x["signal"] == "good"), key=lambda x: x["source"] not in ("ledger", "crawl"))
|
|
73
|
+
out["verdict"], out["reasons"] = "use", [x["text"] for x in good][:3]
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _vkey(v: str | None) -> tuple:
|
|
78
|
+
from mergescout.signals.osv import version_key
|
|
79
|
+
return version_key(v)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _in_span(v: str, span: list) -> bool:
|
|
83
|
+
"""Whether a version sits in an advisory's [introduced, fixed) span, or [introduced, last_affected] when inclusive."""
|
|
84
|
+
start, end, inclusive = span[0], span[1], len(span) > 2 and span[2]
|
|
85
|
+
if _vkey(v) < _vkey(start):
|
|
86
|
+
return False
|
|
87
|
+
return end is None or (_vkey(v) <= _vkey(end) if inclusive else _vkey(v) < _vkey(end))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def affected_by(osv: dict | None, versions: list[str], disputed: bool = True) -> list[dict]:
|
|
91
|
+
"""The advisories whose spans hold one of these versions (a version that doesn't parse is never judged)."""
|
|
92
|
+
known = [v for v in versions or [] if re.match(r"v?\d", v or "")]
|
|
93
|
+
return [a for a in (osv or {}).get("affects") or [] if (disputed or not a.get("disputed"))
|
|
94
|
+
and any(_in_span(v, s) for v in known for s in a["ranges"])]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _osv_lines(osv: dict | None, complete: bool, version: str | None = None,
|
|
98
|
+
versions: list[str] | None = None) -> list[dict]:
|
|
99
|
+
if osv is None:
|
|
100
|
+
return [_line("info", "Not checked against OSV yet.", "osv")]
|
|
101
|
+
out = []
|
|
102
|
+
ids = osv.get("ids") or []
|
|
103
|
+
if osv.get("malware"):
|
|
104
|
+
mal = ", ".join((osv.get("malware_ids") or [])[:2]) or next((i for i in ids if i.startswith("MAL-")), ids[0] if ids else "a MAL- record")
|
|
105
|
+
bad = osv.get("malware_versions") or []
|
|
106
|
+
if bad == ["all"]:
|
|
107
|
+
what = "every published version is listed as malicious"
|
|
108
|
+
elif bad:
|
|
109
|
+
listed = ", ".join(bad[-5:]) + (f" and {len(bad) - 5} more" if len(bad) > 5 else "")
|
|
110
|
+
what = f"version{'s' if len(bad) > 1 else ''} {listed} {'were' if len(bad) > 1 else 'was'} malicious"
|
|
111
|
+
# In a PR the version being installed matters, not the registry's current one.
|
|
112
|
+
hit = [v for v in versions or [] if v in bad]
|
|
113
|
+
if versions:
|
|
114
|
+
what += f"; the version installed here ({', '.join(hit or versions)}) is {'one' if hit else 'not one'} of them"
|
|
115
|
+
elif version:
|
|
116
|
+
what += f"; the current version {version} is {'one of them' if version in bad else 'not one of them'}"
|
|
117
|
+
else:
|
|
118
|
+
what = "a published version was malicious"
|
|
119
|
+
out.append(_line("alarm", f"OSV has a malware record for it ({mal}): {what}.", "osv"))
|
|
120
|
+
# Exploited in the wild beats any severity label; EPSS says how likely an open one is to be exploited soon.
|
|
121
|
+
e = osv.get("epss_open")
|
|
122
|
+
for k in osv.get("kev") or []:
|
|
123
|
+
if k.get("fixed"):
|
|
124
|
+
out.append(_line("warn", f"{k['id']} was exploited in the wild (CISA KEV); it is fixed in newer releases, so "
|
|
125
|
+
"stay on a fixed version.", "kev"))
|
|
126
|
+
else:
|
|
127
|
+
odds = f"; EPSS puts the chance of exploitation in the next 30 days at {e['score']:.0%}" if e and e["id"] == k["id"] else ""
|
|
128
|
+
out.append(_line("alarm", f"{k['id']} is being exploited in the wild (CISA KEV) and has no fixed version yet{odds}.",
|
|
129
|
+
"kev"))
|
|
130
|
+
if e and e.get("score", 0) >= EPSS_ALARM and not any(k["id"] == e["id"] for k in osv.get("kev") or []):
|
|
131
|
+
out.append(_line("alarm", f"An open advisory ({e['id']}) has a {e['score']:.0%} chance of being exploited in the next "
|
|
132
|
+
"30 days (EPSS).", "epss"))
|
|
133
|
+
# In a PR, the version being installed is checked against each advisory's affected spans.
|
|
134
|
+
for v in versions or []:
|
|
135
|
+
hit = [(a["id"], s) for a in affected_by(osv, [v]) for s in a["ranges"] if _in_span(v, s)]
|
|
136
|
+
if hit:
|
|
137
|
+
named = "; ".join(f"{i}, " + ("affected up to " + s[1] + ", no fix named" if len(s) > 2 and s[2]
|
|
138
|
+
else "fixed in " + s[1] if s[1] else "no fixed version yet") for i, s in hit[:3])
|
|
139
|
+
more = f" and {len(hit) - 3} more" if len(hit) > 3 else ""
|
|
140
|
+
out.append(_line("alarm", f"Version {v}, the one installed here, is affected by {named}{more} (OSV).", "osv"))
|
|
141
|
+
if osv.get("unfixed"):
|
|
142
|
+
n = osv["unfixed"]
|
|
143
|
+
out.append(_line("alarm", f"{n} {'advisory has' if n == 1 else 'advisories have'} no fixed version yet (OSV).", "osv"))
|
|
144
|
+
# A range that ends at a "last affected" version is still open only while the newest release is inside it.
|
|
145
|
+
inside = [b for b in osv.get("last_affected") or [] if version and _vkey(version) <= _vkey(b)]
|
|
146
|
+
if inside:
|
|
147
|
+
out.append(_line("alarm", f"The newest release {version} is still inside an advisory's affected range (up to "
|
|
148
|
+
f"{inside[-1]}, OSV).", "osv"))
|
|
149
|
+
if osv.get("disputed"):
|
|
150
|
+
out.append(_line("info", f"{osv['disputed']} advisory is disputed by the maintainers (OSV)." if osv["disputed"] == 1
|
|
151
|
+
else f"{osv['disputed']} advisories are disputed by the maintainers (OSV).", "osv"))
|
|
152
|
+
recent = osv.get("last_90d") or 0
|
|
153
|
+
if recent >= BURST:
|
|
154
|
+
out.append(_line("warn", f"{recent} advisories in the last 90 days: it is under active security scrutiny, so stay on "
|
|
155
|
+
"the newest version (OSV).", "osv"))
|
|
156
|
+
loading = "" if complete else " History still loading."
|
|
157
|
+
if osv.get("advisories"):
|
|
158
|
+
n = osv["advisories"]
|
|
159
|
+
out.append(_line("info", f"{n} {'advisory' if n == 1 else 'advisories'} in total, {osv.get('last_2y', 0)} in the last 2 years "
|
|
160
|
+
f"(latest {osv.get('latest')}, OSV).{loading}", "osv"))
|
|
161
|
+
else:
|
|
162
|
+
out.append(_line("good", f"No advisories in OSV.{loading}", "osv"))
|
|
163
|
+
return out
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _status_lines(m: dict | None) -> list[dict]:
|
|
167
|
+
if not m:
|
|
168
|
+
return []
|
|
169
|
+
out = []
|
|
170
|
+
if m.get("deprecated"):
|
|
171
|
+
why = f': "{m["deprecated_reason"]}"' if m.get("deprecated_reason") else ""
|
|
172
|
+
end = "" if why and why[-2] in ".!?" else "."
|
|
173
|
+
out.append(_line("alarm", f"Its registry marks the default version deprecated{why}{end}", "deps.dev"))
|
|
174
|
+
if (m.get("status") or "").upper() in BAD_STATUS:
|
|
175
|
+
out.append(_line("alarm", f"PyPI marks the project {m['status'].lower()}.", "deps.dev"))
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _link_lines(m: dict | None, now: datetime) -> list[dict]:
|
|
180
|
+
if not m:
|
|
181
|
+
return [_line("warn", "Its source repo isn't known yet.", "deps.dev")]
|
|
182
|
+
repo, link = m.get("repo"), m.get("link")
|
|
183
|
+
if not repo:
|
|
184
|
+
# Established packages sometimes live off GitHub (beautifulsoup4, docutils); a young one with no source is the risky case.
|
|
185
|
+
young = (m.get("first_release") or "") >= (now - timedelta(days=STALE_DAYS)).strftime("%Y-%m-%d")
|
|
186
|
+
return [_line("alarm" if young else "warn",
|
|
187
|
+
f"No source repo is linked{' and it is under 2 years old' if young else ''}, so its maintainer history "
|
|
188
|
+
"can't be checked.", "deps.dev")]
|
|
189
|
+
if link == "verified":
|
|
190
|
+
return [_line("info", f"Source repo {repo}, proven by a signed build or publish attestation (deps.dev).", "deps.dev")]
|
|
191
|
+
if link == "claimed_checked":
|
|
192
|
+
return [_line("info", f"Source repo {repo}: the package names it and that repo's manifest names the package back.",
|
|
193
|
+
"deps.dev + GitHub")]
|
|
194
|
+
if link == "claimed_unconfirmed":
|
|
195
|
+
# Monorepos keep the manifest in a subfolder (swiper, postcss-lab-function, typescript): a warning only for young packages.
|
|
196
|
+
young = (m.get("first_release") or "") >= (now - timedelta(days=STALE_DAYS)).strftime("%Y-%m-%d")
|
|
197
|
+
if young:
|
|
198
|
+
return [_line("warn", f"It names {repo} as its source, but no manifest we checked there names it back and the "
|
|
199
|
+
"package is under 2 years old: check the real source before trusting this history.",
|
|
200
|
+
"deps.dev + GitHub")]
|
|
201
|
+
return [_line("info", f"It names {repo} as its source; that repo's top-level manifest doesn't name it (monorepos and "
|
|
202
|
+
"generated packages often do this).", "deps.dev + GitHub")]
|
|
203
|
+
return [_line("warn", f"It names {repo} as its source; not checked yet.", "deps.dev")]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _release_lines(m: dict | None, now: datetime) -> list[dict]:
|
|
207
|
+
if not m or not m.get("last_release"):
|
|
208
|
+
return []
|
|
209
|
+
last = m["last_release"]
|
|
210
|
+
if last < (now - timedelta(days=STALE_DAYS)).strftime("%Y-%m-%d"):
|
|
211
|
+
return [_line("warn", f"There has been no release since {last}.", "deps.dev")]
|
|
212
|
+
return [_line("info", f"{m.get('releases_12m', 0)} releases in the last 12 months, latest {last} (version {m.get('version')}).",
|
|
213
|
+
"deps.dev")]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _ledger_lines(cells: list[dict]) -> tuple[list[dict], bool]:
|
|
217
|
+
strong = [c for c in cells if c["prs"] >= MIN_PRS and c["repos"] >= MIN_REPOS]
|
|
218
|
+
if not strong and cells and sum(c["prs"] for c in cells) >= MIN_PRS and max(c["repos"] for c in cells) >= MIN_REPOS:
|
|
219
|
+
# Real data: httpx had 33 upgrade PRs from 23 repos split across sizes; together they are enough evidence.
|
|
220
|
+
keys = ("prs", "merged", "closed_unmerged", "ci_known", "ci_failed", "expected_failed", "extra_commits", "reverted")
|
|
221
|
+
strong = [{"bump": "combined", "repos": max(c["repos"] for c in cells), "eco_inferred": all(c.get("eco_inferred") for c in cells),
|
|
222
|
+
**{k: sum(c.get(k) or 0 for c in cells) for k in keys}}]
|
|
223
|
+
if not strong:
|
|
224
|
+
if not cells:
|
|
225
|
+
return [_line("info", "No Dependabot or Renovate upgrades of it seen in crawled repos yet.", "ledger")], False
|
|
226
|
+
prs, repos = sum(c["prs"] for c in cells), max(c["repos"] for c in cells)
|
|
227
|
+
return [_line("info", f"Not enough upgrade evidence yet ({prs} PRs from {repos} repos; rates are shown from "
|
|
228
|
+
f"{MIN_PRS} PRs in {MIN_REPOS} repos).", "ledger")], False
|
|
229
|
+
out = []
|
|
230
|
+
for c in sorted(strong, key=lambda c: list(SIZES).index(c["bump"]) if c["bump"] in SIZES else 99):
|
|
231
|
+
label = SIZES.get(c["bump"], f"{c['bump'].capitalize()} upgrades")
|
|
232
|
+
fail = c["ci_failed"] / c["ci_known"] if c["ci_known"] >= MIN_CI else None
|
|
233
|
+
usual = (c.get("expected_failed") or 0) / c["ci_known"] if fail is not None else None
|
|
234
|
+
closed, reverted = c["closed_unmerged"] / c["prs"], c["reverted"] / c["prs"]
|
|
235
|
+
trouble = (fail is not None and fail - usual >= EXCESS_FAIL) or closed >= CLOSED_SHARE or reverted >= REVERT_SHARE
|
|
236
|
+
if trouble and fail is not None and fail - usual >= EXCESS_FAIL:
|
|
237
|
+
text = (f"{label} failed CI in {_pct(fail)}% of {c['ci_known']} PRs from {c['repos']} repos; "
|
|
238
|
+
f"usually {_pct(usual)}% in these repos")
|
|
239
|
+
else:
|
|
240
|
+
text = f"{label}: {_pct(c['merged'] / c['prs'])}% of {c['prs']} PRs in {c['repos']} repos merged"
|
|
241
|
+
if fail is not None:
|
|
242
|
+
text += f"; CI failed in {_pct(fail)}% (usually {_pct(usual)}% in these repos)"
|
|
243
|
+
if closed >= CLOSED_SHARE:
|
|
244
|
+
text += f"; {_pct(closed)}% closed without merging"
|
|
245
|
+
if c["reverted"]:
|
|
246
|
+
text += f"; {c['reverted']} reverted within 14 days"
|
|
247
|
+
if c.get("extra_commits"):
|
|
248
|
+
text += f"; {c['extra_commits']} needed extra commits"
|
|
249
|
+
if c.get("eco_inferred"):
|
|
250
|
+
# Renovate's "update dependency X" names no ecosystem; a guess from the repo's language can count another package
|
|
251
|
+
# (real data: Docker "ubuntu" bumps counted as npm's "ubuntu"), so it is shown but never decides.
|
|
252
|
+
out.append(_line("info", text + " (crawl, last 180 days; ecosystem guessed from the repo's language).", "ledger"))
|
|
253
|
+
continue
|
|
254
|
+
merged = c["merged"] / c["prs"]
|
|
255
|
+
out.append(_line("warn" if trouble else "good" if merged >= 0.5 else "info", text + " (crawl, last 180 days).", "ledger"))
|
|
256
|
+
return out, any(not c.get("eco_inferred") for c in strong)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _trend_lines(trend: dict | None, m: dict | None) -> tuple[list[dict], bool]:
|
|
260
|
+
if not m or not m.get("repo"):
|
|
261
|
+
return [], False
|
|
262
|
+
if not trend:
|
|
263
|
+
return [_line("info", "Its source repo isn't crawled yet; maintainer trends arrive once it is.", "crawl")], False
|
|
264
|
+
q, d = trend.get("quarters") or [], trend.get("direction") or {}
|
|
265
|
+
rates = [x["outside_merge_rate"] for x in q if x.get("outside_merge_rate") is not None]
|
|
266
|
+
hours = [x["first_response_hours"] for x in q if x.get("first_response_hours") is not None]
|
|
267
|
+
out, last = [], (q[-1] if q else {})
|
|
268
|
+
opened, decided = last.get("outside_opened") or 0, last.get("outside_decided") or 0
|
|
269
|
+
# Merge rates count decided PRs only; a quarter where most outside PRs got no decision is the real signal (dask: 71 of 89).
|
|
270
|
+
if opened >= MIN_OPENED and (opened - decided) / opened >= STILL_OPEN:
|
|
271
|
+
out.append(_line("warn", f"{opened - decided} of {opened} outside PRs opened last quarter are still open.", "crawl"))
|
|
272
|
+
quiet = 0
|
|
273
|
+
for x in reversed(q):
|
|
274
|
+
if (x.get("outside_opened") or 0) > 0:
|
|
275
|
+
break
|
|
276
|
+
quiet += 1
|
|
277
|
+
known = [x for x in q if x.get("outside_merge_rate") is not None]
|
|
278
|
+
if 2 <= quiet < len(q) and known:
|
|
279
|
+
# Real data (httpx): 23-28 outside PRs a quarter, then none at all; the empty quarters are the news.
|
|
280
|
+
k = known[-1]
|
|
281
|
+
out.append(_line("warn", f"No outside pull requests were opened in the last {quiet} quarters; before that, about "
|
|
282
|
+
f"{_pct(k['outside_merge_rate'])}% of them were merged ({k['quarter']}).", "crawl"))
|
|
283
|
+
elif not rates and not hours and last.get("top_merger_share") is None:
|
|
284
|
+
out.append(_line("info", "Its repo had too few outside PRs in the last year to show a trend.", "crawl"))
|
|
285
|
+
n = f" ({decided} decided)" if decided else ""
|
|
286
|
+
if d.get("outside_merge_rate") == "falling" and rates:
|
|
287
|
+
out.append(_line("warn" if rates[0] - rates[-1] >= FALL_STEP else "info",
|
|
288
|
+
f"Outside PRs merged fell from {_pct(rates[0])}% to {_pct(rates[-1])}% over the last 4 quarters{n}.", "crawl"))
|
|
289
|
+
elif d.get("outside_merge_rate") and rates:
|
|
290
|
+
good = rates[-1] >= GOOD_MERGE_RATE
|
|
291
|
+
out.append(_line("good" if good else "info", f"Outside PRs merged: {d['outside_merge_rate']}, about {_pct(rates[-1])}% "
|
|
292
|
+
f"last quarter{n}.", "crawl"))
|
|
293
|
+
if d.get("first_response_hours") == "slower" and hours:
|
|
294
|
+
out.append(_line("warn", f"First maintainer answers slowed from about {hours[0]:.0f} to {hours[-1]:.0f} hours.", "crawl"))
|
|
295
|
+
elif d.get("first_response_hours") and hours:
|
|
296
|
+
out.append(_line("good", f"First maintainer answers: {d['first_response_hours']}, about {hours[-1]:.0f} hours last quarter.",
|
|
297
|
+
"crawl"))
|
|
298
|
+
top = (q[-1] if q else {}).get("top_merger_share")
|
|
299
|
+
# One lead merging and a place among peers are facts to know, not warnings (real data: one lead is normal, and big
|
|
300
|
+
# libraries merge fewer drive-by PRs); only a worsening trend warns.
|
|
301
|
+
if top is not None and top >= ONE_MERGER:
|
|
302
|
+
out.append(_line("info", f"Merging rests on one person: {_pct(top)}% of last quarter's merges.", "crawl"))
|
|
303
|
+
names = {"outside_merge_rate": "Outside PRs merged", "first_response_hours": "First maintainer answers"}
|
|
304
|
+
for metric, peer in (trend.get("peers") or {}).items():
|
|
305
|
+
out.append(_line("info", f"{names.get(metric, metric)} against {peer['peers']} similar repos: {peer['place']}.", "crawl"))
|
|
306
|
+
return out, any(v for v in d.values())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
OWNER_DAYS = 90
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _owner_lines(watch: dict | None, now: datetime) -> list[dict]:
|
|
313
|
+
"""Recent changes in who publishes it (a takeover usually starts here), and who does now."""
|
|
314
|
+
if not watch:
|
|
315
|
+
return []
|
|
316
|
+
out, recent = [], (now - timedelta(days=OWNER_DAYS)).strftime("%Y-%m-%d")
|
|
317
|
+
for e in watch.get("events") or []:
|
|
318
|
+
if e["date"] < recent:
|
|
319
|
+
continue
|
|
320
|
+
if e["kind"] == "trusted_lost":
|
|
321
|
+
out.append(_line("alarm", f"On {e['date']}, release {e.get('version')} was published without the trusted CI "
|
|
322
|
+
"publishing used before.", "registry watch"))
|
|
323
|
+
elif e["kind"] == "maintainer_added":
|
|
324
|
+
out.append(_line("warn", f"New maintainer{'s' if len(e['who']) > 1 else ''} added on {e['date']}: "
|
|
325
|
+
f"{', '.join(e['who'])}.", "registry watch"))
|
|
326
|
+
elif e["kind"] == "organization_changed":
|
|
327
|
+
out.append(_line("warn", f"The owning organization changed on {e['date']}: {e.get('from')} to {e.get('to')}.",
|
|
328
|
+
"registry watch"))
|
|
329
|
+
elif e["kind"] == "maintainer_removed":
|
|
330
|
+
out.append(_line("info", f"Maintainer{'s' if len(e['who']) > 1 else ''} removed on {e['date']}: {', '.join(e['who'])}.",
|
|
331
|
+
"registry watch"))
|
|
332
|
+
cur = watch.get("current") or {}
|
|
333
|
+
names = ", ".join(cur.get("maintainers") or []) or "none listed"
|
|
334
|
+
if "trusted" in cur:
|
|
335
|
+
how = "came through trusted CI publishing" if cur.get("trusted") else f"was published by {cur.get('publisher')}"
|
|
336
|
+
out.append(_line("info", f"Latest release {cur.get('version')} {how}; maintainers: {names} (watched since "
|
|
337
|
+
f"{watch.get('since')}).", "registry watch"))
|
|
338
|
+
else:
|
|
339
|
+
org = f", organization {cur['organization']}" if cur.get("organization") else ""
|
|
340
|
+
out.append(_line("info", f"Owners on PyPI: {names}{org} (watched since {watch.get('since')}).", "registry watch"))
|
|
341
|
+
return out
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The dependency evidence the daily pipeline publishes: upgrade ledger, package map, trend lines, vulnerability history."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from stackvitals.ci import bench as ci_bench
|
|
5
|
+
|
|
6
|
+
FILES = {"ledger": ("deps-ledger.json", "cells"), "map": ("deps-map.json", "packages"),
|
|
7
|
+
"trends": ("deps-trends.json", "repos"), "osv": ("deps-osv.json", "packages"),
|
|
8
|
+
"owners": ("deps-owners.json", "packages")}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _validator(key: str):
|
|
12
|
+
# A file without its main part is ignored, so a bad download can never break a check.
|
|
13
|
+
return lambda d: d if isinstance(d, dict) and isinstance(d.get(key), (dict, list)) else None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_evidence(token: str | None, cache_dir: Path, client=None) -> dict | None:
|
|
17
|
+
"""All five files (each cached a day); None without the package map, which every dossier starts from."""
|
|
18
|
+
parts = {name: ci_bench.load(token, cache_dir, client=client, name=file, url_env=f"STACKVITALS_DEPS_{name.upper()}_URL",
|
|
19
|
+
validate=_validator(key), default_url=None)
|
|
20
|
+
for name, (file, key) in FILES.items()}
|
|
21
|
+
if not parts["map"]:
|
|
22
|
+
return None
|
|
23
|
+
return index_evidence(parts["ledger"] or {}, parts["map"], parts["trends"] or {}, parts["osv"] or {}, parts["owners"] or {})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def index_evidence(ledger: dict, mapped: dict, trends: dict, osv: dict, owners: dict | None = None) -> dict:
|
|
27
|
+
cells = {}
|
|
28
|
+
for c in ledger.get("cells") or []:
|
|
29
|
+
if isinstance(c, dict) and c.get("package") and c.get("ecosystem"):
|
|
30
|
+
cells.setdefault(f"{c['ecosystem']}:{c['package']}", []).append(c)
|
|
31
|
+
return {"ledger": cells, "map": mapped.get("packages") or {},
|
|
32
|
+
"trends": {k.lower(): v for k, v in (trends.get("repos") or {}).items()},
|
|
33
|
+
"osv": osv.get("packages") or {}, "osv_complete": osv.get("complete", True),
|
|
34
|
+
"owners": (owners or {}).get("packages") or {},
|
|
35
|
+
"built_at": {"ledger": ledger.get("built_at"), "map": mapped.get("built_at"), "trends": trends.get("built_at"),
|
|
36
|
+
"osv": osv.get("built_at"), "owners": (owners or {}).get("built_at")}}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_ledger(token: str | None, cache_dir: Path, client=None) -> dict | None:
|
|
40
|
+
"""Only the upgrade ledger, indexed by "ecosystem:package" (cached a day); None when it can't be read."""
|
|
41
|
+
file, key = FILES["ledger"]
|
|
42
|
+
data = ci_bench.load(token, cache_dir, client=client, name=file, url_env="STACKVITALS_DEPS_LEDGER_URL",
|
|
43
|
+
validate=_validator(key), default_url=None)
|
|
44
|
+
return index_evidence(data, {}, {}, {})["ledger"] if data else None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Guided fixes (OSV-Scanner's guided remediation, without its resolver): for each finding, the smallest upgrade that
|
|
2
|
+
fixes it, how big a jump that is (a major one may break things), and the command that makes it in that ecosystem."""
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
COMMANDS = {
|
|
6
|
+
"npm": "npm install {name}@{to}",
|
|
7
|
+
"PyPI": "pip install \"{name}>={to}\" (uv: uv add \"{name}>={to}\")",
|
|
8
|
+
"Go": "go get {name}@v{to}",
|
|
9
|
+
"crates.io": "cargo update -p {name} --precise {to}",
|
|
10
|
+
"RubyGems": "bundle update {name} --conservative",
|
|
11
|
+
"Packagist": "composer require {name}:^{to}",
|
|
12
|
+
"NuGet": "dotnet add package {name} --version {to}",
|
|
13
|
+
"Maven": "set {name} to {to} in the pom or Gradle file",
|
|
14
|
+
"Pub": "dart pub upgrade {name}",
|
|
15
|
+
"Hex": "mix deps.update {name}",
|
|
16
|
+
"GitHub Actions": "use {name}@v{to} (or pin its commit) in the workflow",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parts(version: str) -> list[int]:
|
|
21
|
+
nums = [int(p) for p in re.findall(r"\d+", str(version).lstrip("v"))[:3]]
|
|
22
|
+
return nums + [0] * (3 - len(nums))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bump(current: str, to: str) -> str:
|
|
26
|
+
""""major", "minor" or "patch": how far the upgrade moves (0.x minors count as major, as semver treats them)."""
|
|
27
|
+
a, b = _parts(current), _parts(to)
|
|
28
|
+
if b[0] != a[0] or (a[0] == 0 and b[1] != a[1]):
|
|
29
|
+
return "major"
|
|
30
|
+
return "minor" if b[1] != a[1] else "patch"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def plan(finding: dict) -> dict | None:
|
|
34
|
+
"""{"to", "bump", "command"} for a finding with a fixed version; None when no fix exists yet."""
|
|
35
|
+
to = finding.get("fixed")
|
|
36
|
+
if not to or finding.get("malware"):
|
|
37
|
+
return None
|
|
38
|
+
eco, name = finding.get("ecosystem") or "", finding.get("package") or ""
|
|
39
|
+
template = COMMANDS.get(eco, "upgrade {name} to {to}")
|
|
40
|
+
return {"to": to, "bump": bump(finding.get("version") or "0", to), "command": template.format(name=name, to=str(to).lstrip("v"))}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Opt-in: DataDog's GuardDog on each npm or PyPI package version a pull request adds or upgrades. It downloads the
|
|
2
|
+
package and checks its code for what malware does (install hooks that fetch and run code, obfuscation, data sent out);
|
|
3
|
+
on a 2026 npm benchmark it was right on 97% of what it flagged and caught 90% of the malware. Pinned, run through uv."""
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
VERSION = "3.2.0"
|
|
9
|
+
MAX_PACKAGES = 10
|
|
10
|
+
ECOSYSTEMS = {"npm": "npm", "pypi": "pypi"}
|
|
11
|
+
# A plain npm or PyPI name (npm may have a scope): never an option, a path or a URL, which GuardDog would also take.
|
|
12
|
+
NAME = re.compile(r"(@[A-Za-z0-9][\w.-]*/)?[A-Za-z0-9][\w.-]*")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan(ecosystem: str, name: str, version: str, timeout: int = 180) -> list[str] | None:
|
|
16
|
+
"""The rules GuardDog's scan of one package version matched, or None when it couldn't run."""
|
|
17
|
+
# Names come from a pull request's manifest: options go first and `--` ends them, so a name can't act as one.
|
|
18
|
+
if ecosystem not in ECOSYSTEMS or not NAME.fullmatch(name or "") or not version or version.startswith("-"):
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
run = subprocess.run(["uvx", "--quiet", "--from", f"guarddog=={VERSION}", "guarddog", ECOSYSTEMS[ecosystem], "scan",
|
|
22
|
+
f"--version={version}", "--output-format=json", "--", name],
|
|
23
|
+
capture_output=True, timeout=timeout)
|
|
24
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
doc = json.loads(run.stdout.decode("utf-8", "replace") or "null")
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
if not isinstance(doc, dict):
|
|
31
|
+
return None
|
|
32
|
+
return sorted(rule for rule, hits in (doc.get("results") or {}).items() if hits)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Dependency-update hygiene from the pull requests Team Flow already keeps, the view Dependabot doesn't give: open update
|
|
2
|
+
PRs by age, security updates waiting more than 48 hours (the UK MoJ's rule) unless the team accepted the risk until a
|
|
3
|
+
date, how fast updates merge, and whether the bot went quiet (Dependabot pauses itself after 90 days without
|
|
4
|
+
interaction)."""
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from statistics import median
|
|
7
|
+
|
|
8
|
+
from mergescout.signals.bumps import parse_bump
|
|
9
|
+
from stackvitals.core.time import iso
|
|
10
|
+
|
|
11
|
+
SECURITY_HOURS = 48
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _age(stamp: str, now: datetime) -> float:
|
|
15
|
+
return (now - datetime.fromisoformat(stamp.replace("Z", "+00:00"))).total_seconds() / 3600
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hygiene(flow: dict, decisions: dict, now: datetime) -> dict | None:
|
|
19
|
+
"""None when no update bot has opened a pull request here."""
|
|
20
|
+
def updates(rows):
|
|
21
|
+
for p in rows or []:
|
|
22
|
+
b = parse_bump(p.get("ti"), p.get("head"), p.get("au"))
|
|
23
|
+
if b:
|
|
24
|
+
yield p, b
|
|
25
|
+
opened = list(updates(flow.get("open")))
|
|
26
|
+
closed = list(updates(flow.get("closed")))
|
|
27
|
+
if not opened and not closed:
|
|
28
|
+
return None
|
|
29
|
+
rows = []
|
|
30
|
+
for p, b in opened:
|
|
31
|
+
security = b["security"] or any("security" in (label or "").lower() for label in p.get("lb") or [])
|
|
32
|
+
decided = decisions.get(f"package:{b['package']}") if b.get("package") else None
|
|
33
|
+
row = {"pr": p["n"], "package": b["package"], "ecosystem": b["ecosystem"], "from": b["from_version"],
|
|
34
|
+
"to": b["to_version"], "bump": b["bump"], "bot": b["bot"], "security": security,
|
|
35
|
+
"age_hours": round(_age(p["cr"], now), 1)}
|
|
36
|
+
if decided and decided.get("verb") == "accept":
|
|
37
|
+
row["decision"] = {k: decided.get(k) for k in ("verb", "by", "until", "reason")}
|
|
38
|
+
rows.append(row)
|
|
39
|
+
rows.sort(key=lambda r: -r["age_hours"])
|
|
40
|
+
since = iso(now - timedelta(days=90))
|
|
41
|
+
merged = [_age(p["cr"], now) - _age(p["mg"], now) for p, _b in closed if p.get("mg") and p["mg"] >= since]
|
|
42
|
+
last = max(p["cr"] for p, _b in opened + closed)
|
|
43
|
+
return {"open": rows,
|
|
44
|
+
"security_overdue": [r for r in rows if r["security"] and r["age_hours"] > SECURITY_HOURS and not r.get("decision")],
|
|
45
|
+
"merged_90d": len(merged), "merge_median_hours": round(median(merged), 1) if merged else None,
|
|
46
|
+
"bots": sorted({b["bot"] for _p, b in opened + closed}), "last_opened": last,
|
|
47
|
+
"quiet_days": int(_age(last, now) // 24)}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Which packages a repo's own code imports, for Python, JavaScript/TypeScript and Rust, read in the team's checkout:
|
|
2
|
+
|
|
3
|
+
stackvitals deps imports [path] --out imports.json # in a workflow step before StackVitals (imports-json input)
|
|
4
|
+
|
|
5
|
+
It answers "does our code import the package an advisory names?", not "does our code call the vulnerable function":
|
|
6
|
+
an import is a cheap, honest signal for which advisories to look at first, and a package that isn't imported directly
|
|
7
|
+
may still run through another package. Tests, vendored code and build output are left out.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SKIP = re.compile(r"(^|/)(node_modules|vendor|third_party|dist|build|target|\.git|\.venv|venv|site-packages|__pycache__|"
|
|
15
|
+
r"tests?|__tests__|fixtures?)(/|$)")
|
|
16
|
+
# `from x import y`, or everything after `import` on the line (`import a as b, c`).
|
|
17
|
+
PY = re.compile(r"^\s*(?:from\s+([A-Za-z_][\w.]*)\s+import|import\s+([^\n#;]+))", re.M)
|
|
18
|
+
# Rust: `use x::`, `use x;`, `extern crate x` and paths like `tokio::spawn` (2018 edition needs no `use`).
|
|
19
|
+
RS = re.compile(r"(?:\bextern\s+crate\s+([a-z_][a-z0-9_]*)|\buse\s+([a-z_][a-z0-9_]*)\s*(?:;|\bas\b)"
|
|
20
|
+
r"|(?<![\w:])([a-z_][a-z0-9_]*)::)")
|
|
21
|
+
RS_OWN = {"std", "core", "alloc", "crate", "self", "super", "proc_macro"}
|
|
22
|
+
JS = re.compile(r"""(?:\bfrom\s*|\brequire\s*\(\s*|\bimport\s*\(\s*|^\s*import\s+)['"]([^'"]+)['"]""", re.M)
|
|
23
|
+
JS_FILES = (".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte", ".astro")
|
|
24
|
+
# Distributions whose import name differs from the package name (the common ones). A dotted name must match itself or
|
|
25
|
+
# what's under it; a package with a hyphen and no entry here can't be told apart, so it reads "can't tell".
|
|
26
|
+
PY_ALIASES = {"pyyaml": ("yaml",), "beautifulsoup4": ("bs4",), "pillow": ("PIL",), "scikit-learn": ("sklearn",),
|
|
27
|
+
"scikit-image": ("skimage",), "python-dateutil": ("dateutil",), "opencv-python": ("cv2",),
|
|
28
|
+
"opencv-python-headless": ("cv2",), "protobuf": ("google.protobuf",), "pyjwt": ("jwt",),
|
|
29
|
+
"python-jose": ("jose",), "msgpack-python": ("msgpack",), "attrs": ("attr", "attrs"),
|
|
30
|
+
"python-multipart": ("multipart",), "pycryptodome": ("Crypto",), "pycryptodomex": ("Cryptodome",),
|
|
31
|
+
"google-cloud-storage": ("google.cloud.storage",), "typing-extensions": ("typing_extensions",),
|
|
32
|
+
"psycopg2-binary": ("psycopg2",), "grpcio": ("grpc",), "grpcio-tools": ("grpc_tools",),
|
|
33
|
+
"pyopenssl": ("OpenSSL",), "gitpython": ("git",), "mysqlclient": ("MySQLdb",), "pyserial": ("serial",),
|
|
34
|
+
"pyzmq": ("zmq",), "python-magic": ("magic",), "websocket-client": ("websocket",),
|
|
35
|
+
"python-slugify": ("slugify",), "pygithub": ("github",), "ruamel-yaml": ("ruamel.yaml",),
|
|
36
|
+
"python-dotenv": ("dotenv",), "pymupdf": ("fitz",), "faiss-cpu": ("faiss",)}
|
|
37
|
+
MAX_FILES = 20000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _norm(name: str) -> str:
|
|
41
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def scan(root: Path) -> dict:
|
|
45
|
+
"""{"pypi": [modules], "npm": [package names], "crates.io": [crate names]} imported by the repo's own source files."""
|
|
46
|
+
py, js, rs, seen = set(), set(), set(), 0
|
|
47
|
+
for dirpath, dirs, files in os.walk(root):
|
|
48
|
+
rel = Path(dirpath).relative_to(root).as_posix()
|
|
49
|
+
if rel != "." and SKIP.search(rel):
|
|
50
|
+
dirs[:] = []
|
|
51
|
+
continue
|
|
52
|
+
for f in files:
|
|
53
|
+
if seen >= MAX_FILES:
|
|
54
|
+
break
|
|
55
|
+
path = Path(dirpath) / f
|
|
56
|
+
if f.endswith(".py"):
|
|
57
|
+
seen += 1
|
|
58
|
+
for a, b in PY.findall(path.read_text(encoding="utf-8", errors="replace")):
|
|
59
|
+
for part in ([a] if a else b.split(",")):
|
|
60
|
+
mod = (part.split() or [""])[0].strip("()")
|
|
61
|
+
if re.fullmatch(r"[A-Za-z_][\w.]*", mod):
|
|
62
|
+
py.add(mod)
|
|
63
|
+
elif f.endswith(".rs"):
|
|
64
|
+
seen += 1
|
|
65
|
+
for found in RS.findall(path.read_text(encoding="utf-8", errors="replace")):
|
|
66
|
+
rs.add(next(x for x in found if x))
|
|
67
|
+
elif f.endswith(JS_FILES) and not f.endswith(".d.ts"):
|
|
68
|
+
seen += 1
|
|
69
|
+
for spec in JS.findall(path.read_text(encoding="utf-8", errors="replace")):
|
|
70
|
+
if spec.startswith((".", "/")) or spec.startswith("node:"):
|
|
71
|
+
continue
|
|
72
|
+
parts = spec.split("/")
|
|
73
|
+
js.add("/".join(parts[:2]) if spec.startswith("@") else parts[0])
|
|
74
|
+
return {"pypi": sorted(py), "npm": sorted(js), "crates.io": sorted(rs - RS_OWN), "files": seen}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def imported(doc: dict | None, ecosystem: str | None, package: str) -> bool | None:
|
|
78
|
+
"""True when the code imports the package, False when it doesn't, None when there's nothing to tell (no file read,
|
|
79
|
+
an ecosystem this doesn't cover, or a Python package whose import name isn't known)."""
|
|
80
|
+
if not isinstance(doc, dict):
|
|
81
|
+
return None
|
|
82
|
+
eco = (ecosystem or "").lower()
|
|
83
|
+
if eco == "npm":
|
|
84
|
+
return package in set(doc.get("npm") or [])
|
|
85
|
+
if eco == "crates.io":
|
|
86
|
+
return package.replace("-", "_") in set(doc.get("crates.io") or [])
|
|
87
|
+
if eco == "pypi":
|
|
88
|
+
mods = set(doc.get("pypi") or [])
|
|
89
|
+
names = PY_ALIASES.get(_norm(package))
|
|
90
|
+
if names:
|
|
91
|
+
return any(n in mods or any(m.startswith(n + ".") for m in mods) for n in names)
|
|
92
|
+
tops = {_norm(m.split(".")[0]) for m in mods}
|
|
93
|
+
if _norm(package) in tops:
|
|
94
|
+
return True
|
|
95
|
+
# Only a plain name is safe to call not imported; "foo-bar" may be imported under any name.
|
|
96
|
+
return False if re.fullmatch(r"[A-Za-z0-9_]+", package) else None
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str]) -> int:
|
|
101
|
+
import argparse
|
|
102
|
+
ap = argparse.ArgumentParser(prog="stackvitals deps imports", description=__doc__.split("\n\n")[0])
|
|
103
|
+
ap.add_argument("path", nargs="?", default=".")
|
|
104
|
+
ap.add_argument("--out", help="write the JSON here (default: print it)")
|
|
105
|
+
args = ap.parse_args(argv)
|
|
106
|
+
doc = scan(Path(args.path))
|
|
107
|
+
text = json.dumps(doc, indent=1)
|
|
108
|
+
if args.out:
|
|
109
|
+
Path(args.out).write_text(text + "\n", encoding="utf-8")
|
|
110
|
+
print(f"{len(doc['pypi'])} Python modules, {len(doc['npm'])} npm packages and {len(doc['crates.io'])} crates imported, from {doc['files']} files: {args.out}")
|
|
111
|
+
else:
|
|
112
|
+
print(text)
|
|
113
|
+
return 0
|