skilldrop-cli 0.16.28 → 0.16.30

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 (186) hide show
  1. package/bin/skilldrop.js +160 -33
  2. package/catalogue.json +7 -3
  3. package/model-routing.json +16 -0
  4. package/package.json +1 -1
  5. package/packs/ai-engineering/skills/mcp-pairing/SKILL.md +93 -0
  6. package/packs/ai-engineering/skills/mcp-pairing/evals/eval_queries.json +13 -0
  7. package/packs/ai-engineering/skills/mcp-pairing/evals/evals.json +76 -0
  8. package/packs/ai-engineering/skills/mcp-pairing/evals/files/2/mcp.json +32 -0
  9. package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/.mcp.json +47 -0
  10. package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/docs/pr-loop.md +12 -0
  11. package/packs/ai-engineering/skills/mcp-pairing/evals/files/5/docs/oncall-triage.md +18 -0
  12. package/packs/ai-engineering/skills/mcp-pairing/examples/build-loop-github-jira-datadog.md +69 -0
  13. package/packs/ai-engineering/skills/mcp-pairing/manifest.json +17 -0
  14. package/packs/ai-engineering/skills/mcp-pairing/reference.md +136 -0
  15. package/packs/ai-engineering/skills/mcp-pairing/templates/mcp-pairing.md +43 -0
  16. package/packs/data-analytics/loops/measure/LOOP.md +118 -0
  17. package/packs/data-analytics/loops/measure/evals/eval_queries.json +9 -0
  18. package/packs/data-analytics/loops/measure/evals/evals.json +28 -0
  19. package/packs/data-analytics/loops/measure/loop.json +41 -0
  20. package/packs/dev-team/loops/build/LOOP.md +4 -2
  21. package/packs/dev-team/loops/build/loop.json +2 -2
  22. package/packs/dev-team/skills/codebase-tour/SKILL.md +115 -0
  23. package/packs/dev-team/skills/codebase-tour/evals/eval_queries.json +13 -0
  24. package/packs/dev-team/skills/codebase-tour/evals/evals.json +77 -0
  25. package/packs/dev-team/skills/codebase-tour/evals/files/1/.github/workflows/ci.yml +23 -0
  26. package/packs/dev-team/skills/codebase-tour/evals/files/1/README.md +23 -0
  27. package/packs/dev-team/skills/codebase-tour/evals/files/1/openapi.yaml +21 -0
  28. package/packs/dev-team/skills/codebase-tour/evals/files/1/package-lock.json +7 -0
  29. package/packs/dev-team/skills/codebase-tour/evals/files/1/package.json +29 -0
  30. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/config.ts +20 -0
  31. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/filter.ts +24 -0
  32. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/repository.ts +15 -0
  33. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/errors.ts +9 -0
  34. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/generated/api-types.ts +9 -0
  35. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/handlers/shipments.ts +38 -0
  36. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/routes.ts +7 -0
  37. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/jobs/export-shipments.ts +22 -0
  38. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/server.ts +18 -0
  39. package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/domain/filter.test.ts +16 -0
  40. package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/http/shipments.test.ts +18 -0
  41. package/packs/dev-team/skills/codebase-tour/evals/files/1/tsconfig.json +10 -0
  42. package/packs/dev-team/skills/codebase-tour/evals/files/1/vitest.config.ts +5 -0
  43. package/packs/dev-team/skills/codebase-tour/evals/files/2/.github/workflows/ci.yml +16 -0
  44. package/packs/dev-team/skills/codebase-tour/evals/files/2/README.md +16 -0
  45. package/packs/dev-team/skills/codebase-tour/evals/files/2/noxfile.py +14 -0
  46. package/packs/dev-team/skills/codebase-tour/evals/files/2/pyproject.toml +22 -0
  47. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/__init__.py +3 -0
  48. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/_version.py +4 -0
  49. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/cli.py +32 -0
  50. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/detect.py +15 -0
  51. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/errors.py +2 -0
  52. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/money.py +14 -0
  53. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/__init__.py +8 -0
  54. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/acme.py +18 -0
  55. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/base.py +24 -0
  56. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/harbor.py +13 -0
  57. package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/fixtures/acme_sample.csv +3 -0
  58. package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_acme.py +23 -0
  59. package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_detect.py +13 -0
  60. package/packs/dev-team/skills/codebase-tour/evals/files/3/.github/workflows/ci.yml +28 -0
  61. package/packs/dev-team/skills/codebase-tour/evals/files/3/Makefile +16 -0
  62. package/packs/dev-team/skills/codebase-tour/evals/files/3/README.md +16 -0
  63. package/packs/dev-team/skills/codebase-tour/evals/files/3/cmd/tidepool/main.go +24 -0
  64. package/packs/dev-team/skills/codebase-tour/evals/files/3/go.mod +5 -0
  65. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/config/config.go +38 -0
  66. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher.go +52 -0
  67. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher_test.go +42 -0
  68. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/sender.go +32 -0
  69. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/replay/replay.go +31 -0
  70. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/db.go +35 -0
  71. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql +11 -0
  72. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql.go +30 -0
  73. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/schema.sql +6 -0
  74. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/store_integration_test.go +21 -0
  75. package/packs/dev-team/skills/codebase-tour/evals/files/3/sqlc.yaml +9 -0
  76. package/packs/dev-team/skills/codebase-tour/evals/files/4/.env.example +4 -0
  77. package/packs/dev-team/skills/codebase-tour/evals/files/4/.github/workflows/ci.yml +16 -0
  78. package/packs/dev-team/skills/codebase-tour/evals/files/4/Makefile +16 -0
  79. package/packs/dev-team/skills/codebase-tour/evals/files/4/README.md +23 -0
  80. package/packs/dev-team/skills/codebase-tour/evals/files/4/data/rates.csv +4 -0
  81. package/packs/dev-team/skills/codebase-tour/evals/files/4/pyproject.toml +19 -0
  82. package/packs/dev-team/skills/codebase-tour/evals/files/4/scripts/gen_rates.py +29 -0
  83. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/__init__.py +0 -0
  84. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/__init__.py +0 -0
  85. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/preview.py +14 -0
  86. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/routes.py +36 -0
  87. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/config.py +15 -0
  88. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/discounts.py +8 -0
  89. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/__init__.py +0 -0
  90. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/rates_table.py +8 -0
  91. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/main.py +8 -0
  92. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/shipping.py +18 -0
  93. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/tax.py +7 -0
  94. package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/conftest.py +3 -0
  95. package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_discounts.py +11 -0
  96. package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_tax.py +13 -0
  97. package/packs/dev-team/skills/codebase-tour/evals/files/5/.env.example +3 -0
  98. package/packs/dev-team/skills/codebase-tour/evals/files/5/.github/workflows/ci.yml +13 -0
  99. package/packs/dev-team/skills/codebase-tour/evals/files/5/README.md +18 -0
  100. package/packs/dev-team/skills/codebase-tour/evals/files/5/package.json +13 -0
  101. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/openapi.json +30 -0
  102. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/package.json +15 -0
  103. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/db.js +19 -0
  104. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/flags.js +8 -0
  105. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/generated/schema.js +32 -0
  106. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/middleware/validate.js +20 -0
  107. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/routes/bookings.js +15 -0
  108. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/server.js +15 -0
  109. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/bookings.js +23 -0
  110. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/pricing.js +9 -0
  111. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/test/pricing.test.js +15 -0
  112. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/package.json +9 -0
  113. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/bookingForm.js +11 -0
  114. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/format.js +7 -0
  115. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/test/format.test.js +12 -0
  116. package/packs/dev-team/skills/codebase-tour/evals/files/5/scripts/gen-schema.mjs +27 -0
  117. package/packs/dev-team/skills/codebase-tour/examples/refund-window.md +158 -0
  118. package/packs/dev-team/skills/codebase-tour/manifest.json +47 -0
  119. package/packs/dev-team/skills/codebase-tour/scripts/repo_facts.py +403 -0
  120. package/packs/dev-team/skills/codebase-tour/templates/codebase-tour.md +85 -0
  121. package/packs/dev-team/skills/dependency-upgrade-review/SKILL.md +101 -0
  122. package/packs/dev-team/skills/dependency-upgrade-review/evals/eval_queries.json +11 -0
  123. package/packs/dev-team/skills/dependency-upgrade-review/evals/evals.json +77 -0
  124. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/.github/workflows/ci.yml +20 -0
  125. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/pr.diff +87 -0
  126. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/quillmark-CHANGELOG.md +24 -0
  127. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/comment.js +6 -0
  128. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/note.js +8 -0
  129. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/test/note.test.js +12 -0
  130. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/ci.yml +23 -0
  131. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/release.yml +23 -0
  132. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/pr.diff +32 -0
  133. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/release-notes.md +20 -0
  134. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/.github/workflows/ci.yml +18 -0
  135. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/Dockerfile +8 -0
  136. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/PR.md +36 -0
  137. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/pr.diff +47 -0
  138. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/client.py +24 -0
  139. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/reader.py +32 -0
  140. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/tests/test_reader.py +15 -0
  141. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/upstream/quillstream-CHANGELOG.md +37 -0
  142. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/ci.yml +22 -0
  143. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/release.yml +25 -0
  144. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.nvmrc +1 -0
  145. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-412.md +16 -0
  146. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-413.md +15 -0
  147. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/package.json +21 -0
  148. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-412.diff +26 -0
  149. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-413.diff +84 -0
  150. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/scripts/release.mjs +18 -0
  151. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/release-notary-CHANGELOG.md +16 -0
  152. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/setup-toolchain-RELEASES.md +22 -0
  153. package/packs/dev-team/skills/dependency-upgrade-review/examples/inputs/ledgerline.diff +38 -0
  154. package/packs/dev-team/skills/dependency-upgrade-review/examples/ledgerline-tidyparse.md +91 -0
  155. package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +50 -0
  156. package/packs/dev-team/skills/dependency-upgrade-review/reference.md +99 -0
  157. package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +718 -0
  158. package/packs/grc/loops/comply/LOOP.md +133 -0
  159. package/packs/grc/loops/comply/evals/eval_queries.json +9 -0
  160. package/packs/grc/loops/comply/evals/evals.json +30 -0
  161. package/packs/grc/loops/comply/loop.json +54 -0
  162. package/packs/product-manager/loops/discover/LOOP.md +5 -4
  163. package/packs/product-manager/loops/discover/loop.json +2 -2
  164. package/packs/product-manager/skills/feedback-synthesis/SKILL.md +182 -0
  165. package/packs/product-manager/skills/feedback-synthesis/evals/eval_queries.json +46 -0
  166. package/packs/product-manager/skills/feedback-synthesis/evals/evals.json +76 -0
  167. package/packs/product-manager/skills/feedback-synthesis/evals/files/1/interview-notes.md +47 -0
  168. package/packs/product-manager/skills/feedback-synthesis/evals/files/1/nps-verbatims.csv +13 -0
  169. package/packs/product-manager/skills/feedback-synthesis/evals/files/1/support-tickets.csv +36 -0
  170. package/packs/product-manager/skills/feedback-synthesis/evals/files/3/churn-exit-survey.csv +11 -0
  171. package/packs/product-manager/skills/feedback-synthesis/evals/files/3/sales-call-notes.md +55 -0
  172. package/packs/product-manager/skills/feedback-synthesis/evals/files/4/csat_comments_q3.csv +17 -0
  173. package/packs/product-manager/skills/feedback-synthesis/evals/files/4/support_tickets_q3.csv +49 -0
  174. package/packs/product-manager/skills/feedback-synthesis/evals/files/5/app_reviews_last_60_days.csv +32 -0
  175. package/packs/product-manager/skills/feedback-synthesis/evals/files/5/interview_notes_round1.md +48 -0
  176. package/packs/product-manager/skills/feedback-synthesis/examples/booking-app-feedback.md +139 -0
  177. package/packs/product-manager/skills/feedback-synthesis/examples/inputs/booking-app-coded.csv +17 -0
  178. package/packs/product-manager/skills/feedback-synthesis/manifest.json +53 -0
  179. package/packs/product-manager/skills/feedback-synthesis/reference.md +89 -0
  180. package/packs/product-manager/skills/feedback-synthesis/scripts/theme_counts.py +355 -0
  181. package/packs/product-manager/skills/feedback-synthesis/templates/coding-sheet.csv +6 -0
  182. package/packs/product-manager/skills/feedback-synthesis/templates/synthesis.md +42 -0
  183. package/packs/research/loops/research/LOOP.md +123 -0
  184. package/packs/research/loops/research/evals/eval_queries.json +9 -0
  185. package/packs/research/loops/research/evals/evals.json +39 -0
  186. package/packs/research/loops/research/loop.json +35 -0
@@ -0,0 +1,718 @@
1
+ #!/usr/bin/env python3
2
+ """List every dependency change in a unified diff of manifests, lockfiles and workflow files.
3
+
4
+ The mechanical layer of a dependency-upgrade review: for each changed dependency it prints
5
+ old -> new, the semver class of the jump, whether it is commit-pinned, and which file it came
6
+ from; then the packages added and removed, packages the PR title does not name, new install
7
+ scripts, native-code hints, registry or source changes, licence changes, and GitHub Actions pins
8
+ with the command that verifies each SHA. Stdlib only. It reads only the path it is given (or
9
+ stdin) and makes no network calls, so it cannot read a changelog, verify a SHA or see what CI
10
+ runs; it prints those as open checks.
11
+
12
+ Reads: package.json, package-lock.json, npm-shrinkwrap.json, yarn.lock, pnpm-lock.yaml,
13
+ requirements*.txt, constraints*.txt, pyproject.toml, Pipfile, Pipfile.lock, poetry.lock, uv.lock,
14
+ pdm.lock, go.mod, go.sum, Cargo.toml, Cargo.lock, pom.xml, build.gradle(.kts),
15
+ gradle/libs.versions.toml, .github/workflows/*.yml, action.yml, .npmrc, .yarnrc(.yml), pip.conf.
16
+
17
+ Usage:
18
+ python3 upgrade_diff.py pr.diff [--title "Bump foo from 1.2.0 to 2.0.0"] [--format json]
19
+ git diff origin/main...HEAD | python3 upgrade_diff.py - --title "..."
20
+ gh pr diff 212 | python3 upgrade_diff.py -
21
+
22
+ Exit codes: 0 report printed; 2 bad input (unreadable, not a unified diff, or no dependency
23
+ files in it). The floor verdict is a minimum: the reviewer raises it on what the changelog,
24
+ the usage search and CI show, and never prints MERGE on the script's word alone.
25
+ """
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+
32
+ INSTALL_HOOKS = ("preinstall", "install", "postinstall", "prepare", "prepublish")
33
+ NATIVE_NPM = {"node-gyp", "node-gyp-build", "prebuild-install", "node-pre-gyp", "@mapbox/node-pre-gyp",
34
+ "node-addon-api", "nan", "bindings", "cmake-js", "napi-build-utils", "@napi-rs/cli"}
35
+ NATIVE_CARGO = {"cc", "cmake", "bindgen", "pkg-config", "vcpkg"}
36
+ DEFAULT_HOSTS = {"registry.npmjs.org", "registry.yarnpkg.com", "pypi.org", "files.pythonhosted.org",
37
+ "index.crates.io", "github.com/rust-lang/crates.io-index", "repo.maven.apache.org",
38
+ "repo1.maven.org", "proxy.golang.org"}
39
+ SHA40 = re.compile(r"\b[0-9a-f]{40}\b")
40
+ JSON_SKIP_KEYS = {"", "dependencies", "devDependencies", "optionalDependencies", "peerDependencies",
41
+ "peerDependenciesMeta", "requires", "packages", "engines", "bin", "funding", "scripts",
42
+ "default", "develop", "_meta", "hash", "sources", "pipfile-spec", "overrides",
43
+ "directories", "repository", "exports", "os", "cpu", "workspaces", "bundleDependencies"}
44
+ TOML_SKIP_KEYS = {"name", "version", "edition", "description", "authors", "license", "license-file",
45
+ "readme", "rust-version", "requires-python", "build-backend", "requires", "homepage",
46
+ "repository", "documentation", "keywords", "categories", "publish", "resolver",
47
+ "python_version", "python_full_version", "url", "verify_ssl", "members", "exclude"}
48
+ PKGJSON_SKIP_KEYS = {"name", "version", "description", "main", "module", "types", "license", "author",
49
+ "type", "homepage", "packageManager"}
50
+ VERSIONISH = re.compile(r"^\s*(?:[\^~<>=!*]|v?\d|npm:|workspace:|latest|next|git\+|github:|https?:|file:|link:)")
51
+
52
+
53
+ # ---------------------------------------------------------------- diff parsing
54
+
55
+ def parse_diff(text):
56
+ """Return [{path, hunks: [[(tag, line)]]}] from a unified diff (git or plain)."""
57
+ files, cur, lines, i = [], None, text.splitlines(), 0
58
+ while i < len(lines):
59
+ raw = lines[i]
60
+ if raw.startswith("diff --git "):
61
+ m = re.match(r"diff --git a/(.+?) b/(.+)$", raw)
62
+ cur = {"path": m.group(2) if m else raw[11:].strip(), "hunks": []}
63
+ files.append(cur)
64
+ elif raw.startswith("--- ") and i + 1 < len(lines) and lines[i + 1].startswith("+++ "):
65
+ old_p = raw[4:].split("\t")[0].strip()
66
+ new_p = lines[i + 1][4:].split("\t")[0].strip()
67
+ path = new_p if new_p != "/dev/null" else old_p
68
+ path = re.sub(r"^[ab]/", "", path)
69
+ if cur is None or cur["hunks"]:
70
+ cur = {"path": path, "hunks": []}
71
+ files.append(cur)
72
+ else:
73
+ cur["path"] = path
74
+ i += 1
75
+ elif raw.startswith("@@") and cur is not None:
76
+ m = re.match(r"@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@", raw)
77
+ if not m:
78
+ i += 1
79
+ continue
80
+ old_n = int(m.group(1)) if m.group(1) is not None else 1
81
+ new_n = int(m.group(2)) if m.group(2) is not None else 1
82
+ hunk = []
83
+ i += 1
84
+ while i < len(lines) and (old_n > 0 or new_n > 0):
85
+ ln = lines[i]
86
+ if ln.startswith("\\"):
87
+ i += 1
88
+ continue
89
+ tag, body = (ln[0], ln[1:]) if ln else (" ", "")
90
+ if tag not in " +-":
91
+ break
92
+ if tag in " -":
93
+ old_n -= 1
94
+ if tag in " +":
95
+ new_n -= 1
96
+ hunk.append((tag, body))
97
+ i += 1
98
+ cur["hunks"].append(hunk)
99
+ continue
100
+ i += 1
101
+ return files
102
+
103
+
104
+ def views(hunk):
105
+ """Old view (context + removed) and new view (context + added) of one hunk."""
106
+ return [(t, s) for t, s in hunk if t in " -"], [(t, s) for t, s in hunk if t in " +"]
107
+
108
+
109
+ # ---------------------------------------------------------------- versions
110
+
111
+ def parse_ver(v):
112
+ if v is None:
113
+ return None
114
+ v = str(v).strip().strip("\"'")
115
+ v = v.split("@")[-1] if v.startswith("npm:") else v
116
+ v = re.split(r"[,|\s]", v.strip())[0] if v else v
117
+ v = re.sub(r"^(?:===|==|>=|<=|~=|!=|[\^~=<>v])+", "", v)
118
+ m = re.match(r"^(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?([-+.].*)?$", v)
119
+ if not m:
120
+ return None
121
+ num = lambda g: int(g) if g and g.isdigit() else 0
122
+ return num(m.group(1)), num(m.group(2)), num(m.group(3)), (m.group(4) or "").lstrip(".")
123
+
124
+
125
+ def semver_class(old, new):
126
+ o, n = parse_ver(old), parse_ver(new)
127
+ if not o or not n:
128
+ return "unknown"
129
+ if n[:3] < o[:3]:
130
+ return "downgrade"
131
+ if n[0] != o[0]:
132
+ return "major"
133
+ if n[0] == 0 and n[1] != o[1]:
134
+ return "minor (0.x: breaking)"
135
+ if n[1] != o[1]:
136
+ return "minor"
137
+ if n[0] == 0 and n[1] == 0 and n[2] != o[2]:
138
+ return "patch (0.0.x: breaking)"
139
+ if n[2] != o[2]:
140
+ return "patch"
141
+ if n[3] != o[3]:
142
+ return "prerelease/build"
143
+ return "range only"
144
+
145
+
146
+ def host_of(url):
147
+ if not url:
148
+ return None
149
+ url = re.sub(r"^(registry|sparse|git)\+", "", url)
150
+ m = re.match(r"^[a-z]+://(?:[^@/]+@)?([^/:]+)", url)
151
+ if not m:
152
+ return url if url.startswith(("git", "file:", "link:", "github:")) else None
153
+ host = m.group(1)
154
+ if host == "github.com" and "crates.io-index" in url:
155
+ return "github.com/rust-lang/crates.io-index"
156
+ return host
157
+
158
+
159
+ # ---------------------------------------------------------------- record helpers
160
+
161
+ class Report:
162
+ def __init__(self):
163
+ self.changes, self.flags, self.files, self.unattributed = [], [], [], {}
164
+
165
+ def change(self, path, eco, where, name, old, new, note=None):
166
+ if old == new:
167
+ return
168
+ rec = {"file": path, "ecosystem": eco, "where": where, "name": name,
169
+ "old": old, "new": new, "notes": [note] if note else []}
170
+ if old is None:
171
+ rec["kind"] = "added"
172
+ elif new is None:
173
+ rec["kind"] = "removed"
174
+ else:
175
+ rec["kind"] = "changed"
176
+ rec["class"] = semver_class(old, new)
177
+ if new and SHA40.search(str(new)):
178
+ rec["notes"].append("pinned to a commit")
179
+ p = parse_ver(new)
180
+ if new and p and p[3] and re.search(r"(alpha|beta|rc|pre|dev|canary|next)", p[3], re.I):
181
+ rec["notes"].append("target is a prerelease")
182
+ for r in self.changes:
183
+ if all(r[k] == rec[k] for k in ("file", "name", "old", "new")):
184
+ return
185
+ self.changes.append(rec)
186
+
187
+ def flag(self, kind, path, text):
188
+ f = {"kind": kind, "file": path, "text": text}
189
+ if f not in self.flags:
190
+ self.flags.append(f)
191
+
192
+
193
+ def pair_line_changes(rep, path, eco, where, removed, added, notes=None):
194
+ """removed/added: {name: value}. Pair by name into changed / added / removed."""
195
+ notes = notes or {}
196
+ for name in list(removed) + [n for n in added if n not in removed]:
197
+ old, new = removed.get(name), added.get(name)
198
+ rep.change(path, eco, where, name, old, new, notes.get(name))
199
+
200
+
201
+ def scan_blocks(view, key_fn, field_res, reset_re=None):
202
+ out, cur, loose = {}, None, []
203
+ for tag, text in view:
204
+ if reset_re and reset_re.match(text):
205
+ cur = None
206
+ continue
207
+ k = key_fn(text)
208
+ if k is not None:
209
+ cur = k
210
+ out.setdefault(k, {"tag": tag, "fields": {}})
211
+ continue
212
+ for f, rx in field_res.items():
213
+ m = rx.match(text)
214
+ if not m:
215
+ continue
216
+ if cur is None:
217
+ if tag != " " and f == "version":
218
+ loose.append(text.strip())
219
+ elif f not in out[cur]["fields"]:
220
+ out[cur]["fields"][f] = (m.group(1), tag)
221
+ return out, loose
222
+
223
+
224
+ def merge_blocks(rep, path, eco, hunks, key_fn, field_res, name_fn, reset_re=None, count_loose=True,
225
+ where="lockfile"):
226
+ """Block-structured lockfiles: compare each package block's fields across the two views."""
227
+ for hunk in hunks:
228
+ ov, nv = views(hunk)
229
+ old, loose_o = scan_blocks(ov, key_fn, field_res, reset_re)
230
+ new, loose_n = scan_blocks(nv, key_fn, field_res, reset_re)
231
+ if count_loose and (loose_o or loose_n):
232
+ rep.unattributed[path] = rep.unattributed.get(path, 0) + max(len(loose_o), len(loose_n))
233
+ for key in list(old) + [k for k in new if k not in old]:
234
+ o, n = old.get(key), new.get(key)
235
+ name, note = name_fn(key)
236
+ of = o["fields"] if o else {}
237
+ nf = n["fields"] if n else {}
238
+ ov_, nv_ = of.get("version", (None,))[0], nf.get("version", (None,))[0]
239
+ if o and not n:
240
+ rep.change(path, eco, where, name, ov_ or "?", None, note)
241
+ continue
242
+ if n and not o:
243
+ rep.change(path, eco, where, name, None, nv_ or "?", note)
244
+ elif ov_ and nv_ and ov_ != nv_:
245
+ rep.change(path, eco, where, name, ov_, nv_, note)
246
+ label = f"{name} {nv_ or ''}".strip()
247
+ for f in ("hasInstallScript", "requiresBuild"):
248
+ if nf.get(f, (None,))[0] == "true" and of.get(f, (None,))[0] != "true":
249
+ rep.flag("install-script", path, f"{label}: `{f}: true` is new; the package runs code at install time")
250
+ if nf.get("gypfile", (None,))[0] == "true" and of.get("gypfile", (None,))[0] != "true":
251
+ rep.flag("native", path, f"{label}: `gypfile: true`, compiles native code at install")
252
+ for f in ("resolved", "source", "url", "index", "resolution"):
253
+ oh = host_of(of.get(f, (None,))[0]) if f != "index" else of.get(f, (None,))[0]
254
+ nh = host_of(nf.get(f, (None,))[0]) if f != "index" else nf.get(f, (None,))[0]
255
+ if nh and o and oh and oh != nh:
256
+ rep.flag("source", path, f"{label}: {f} moved from {oh} to {nh}")
257
+ elif nh and not o and f != "index" and nh not in DEFAULT_HOSTS:
258
+ rep.flag("source", path, f"{label}: new package comes from {nh}, not the default registry")
259
+ ol, nl = of.get("license", (None,))[0], nf.get("license", (None,))[0]
260
+ if ol and nl and ol != nl:
261
+ rep.flag("licence", path, f"{label}: licence changed from {ol} to {nl}")
262
+ elif nl and not o:
263
+ rep.flag("licence", path, f"{label}: new package, licence {nl}")
264
+ for f, label_ in (("dev", "dev only"), ("optional", "optional")):
265
+ if n and not o and nf.get(f, (None,))[0] == "true":
266
+ for r in rep.changes:
267
+ if r["file"] == path and r["name"] == name and r["kind"] == "added":
268
+ r["notes"].append(label_)
269
+
270
+
271
+ # ---------------------------------------------------------------- handlers
272
+
273
+ def h_package_json(rep, path, hunks):
274
+ for hunk in hunks:
275
+ removed, added, section = {}, {}, None
276
+ for tag, text in hunk:
277
+ sm = re.match(r'^\s*"(\w+)":\s*\{\s*$', text)
278
+ if sm:
279
+ section = sm.group(1)
280
+ continue
281
+ m = re.match(r'^\s*"([^"]+)":\s*"([^"]*)"', text)
282
+ if not m or tag == " ":
283
+ continue
284
+ key, val = m.groups()
285
+ if key in INSTALL_HOOKS and (section in (None, "scripts")):
286
+ (removed if tag == "-" else added)["script:" + key] = val
287
+ continue
288
+ if key in PKGJSON_SKIP_KEYS or section == "scripts" or not VERSIONISH.match(val):
289
+ continue
290
+ (removed if tag == "-" else added)[key] = val
291
+ if tag == "+" and re.match(r"^(git\+|github:|https?:|file:|link:)", val):
292
+ rep.flag("source", path, f"{key}: installed from `{val}`, not the registry")
293
+ for k in list(added):
294
+ if k.startswith("script:"):
295
+ hook, cmd = k[7:], added.pop(k)
296
+ if removed.pop(k, None) != cmd:
297
+ rep.flag("install-script", path, f"package.json adds or changes a `{hook}` script: `{cmd}`")
298
+ for k in list(removed):
299
+ if k.startswith("script:"):
300
+ removed.pop(k)
301
+ pair_line_changes(rep, path, "npm", "manifest", removed, added)
302
+
303
+
304
+ def npm_lock_key(text):
305
+ m = re.match(r'^\s*"([^"]*)":\s*\{\s*$', text)
306
+ if not m or m.group(1) in JSON_SKIP_KEYS:
307
+ return None
308
+ return m.group(1)
309
+
310
+
311
+ def npm_lock_name(key):
312
+ parts = key.split("node_modules/")
313
+ name = parts[-1].rstrip("/") if len(parts) > 1 else key
314
+ if len(parts) > 2:
315
+ return name, f"nested copy under {parts[-2].rstrip('/')}"
316
+ return name, None
317
+
318
+
319
+ NPM_FIELDS = {f: re.compile(rf'^\s*"{f}":\s*"?([^",]+)"?,?\s*$') for f in
320
+ ("version", "resolved", "license", "hasInstallScript", "dev", "optional", "gypfile")}
321
+
322
+
323
+ def h_package_lock(rep, path, hunks):
324
+ merge_blocks(rep, path, "npm", hunks, npm_lock_key, NPM_FIELDS, npm_lock_name)
325
+
326
+
327
+ def h_yarn_lock(rep, path, hunks):
328
+ def key(text):
329
+ m = re.match(r'^"?((?:@[^@/\s"]+/)?[^@\s",]+)@[^:]*"?:\s*$', text)
330
+ return m.group(1) if m and not text.startswith((" ", "#")) else None
331
+ fields = {"version": re.compile(r'^\s+version:?\s+"?([^"\s]+)"?'),
332
+ "resolved": re.compile(r'^\s+resolved:?\s+"?([^"\s#]+)'),
333
+ }
334
+ merge_blocks(rep, path, "npm", hunks, key, fields, lambda k: (k, None))
335
+
336
+
337
+ def h_pnpm_lock(rep, path, hunks):
338
+ key_re = re.compile(r"^ {2}['\"]?/?((?:@[^/@\s]+/)?[^@\s'\"/(]+)@([^\s'\"():]+)(?:\([^)]*\))*['\"]?:\s*$")
339
+
340
+ def key(text):
341
+ m = key_re.match(text)
342
+ return f"{m.group(1)}\x00{m.group(2)}" if m else None
343
+ fields = {"requiresBuild": re.compile(r"^\s+requiresBuild:\s*(true|false)"),
344
+ "resolved": re.compile(r"^\s+resolution:\s*\{.*tarball:\s*([^\s,}]+)")}
345
+ for hunk in hunks:
346
+ ov, nv = views(hunk)
347
+ old, _ = scan_blocks(ov, key, fields)
348
+ new, _ = scan_blocks(nv, key, fields)
349
+ removed = {k.split("\x00")[0]: k.split("\x00")[1] for k in old if k not in new}
350
+ added = {k.split("\x00")[0]: k.split("\x00")[1] for k in new if k not in old}
351
+ pair_line_changes(rep, path, "npm", "lockfile", removed, added)
352
+ for k, n in new.items():
353
+ if n["fields"].get("requiresBuild", (None,))[0] == "true" and \
354
+ old.get(k, {"fields": {}})["fields"].get("requiresBuild", (None,))[0] != "true":
355
+ rep.flag("install-script", path, f"{k.replace(chr(0), ' ')}: `requiresBuild: true`, runs code at install time")
356
+ t = n["fields"].get("resolved", (None,))[0]
357
+ if t and k not in old and host_of(t) not in DEFAULT_HOSTS:
358
+ rep.flag("source", path, f"{k.replace(chr(0), ' ')}: tarball from {host_of(t)}")
359
+
360
+
361
+ TOML_PKG_FIELDS = {"version": re.compile(r'^version\s*=\s*"([^"]+)"'),
362
+ "source": re.compile(r'^source\s*=\s*"([^"]+)"'),
363
+ "url": re.compile(r'^(?:url|source\s*=\s*\{\s*(?:registry|git|url))\s*=\s*"([^"]+)"')}
364
+
365
+
366
+ def h_toml_lock(eco):
367
+ def handler(rep, path, hunks):
368
+ def key(text):
369
+ m = re.match(r'^name\s*=\s*"([^"]+)"', text)
370
+ return m.group(1) if m else None
371
+ merge_blocks(rep, path, eco, hunks, key, TOML_PKG_FIELDS,
372
+ lambda k: (k, None), reset_re=re.compile(r"^\[\[package\]\]"))
373
+ if eco == "cargo":
374
+ for r in rep.changes:
375
+ if r["file"] == path and r["kind"] == "added" and \
376
+ (r["name"].endswith("-sys") or r["name"] in NATIVE_CARGO):
377
+ rep.flag("native", path, f"{r['name']} {r['new']}: new crate that builds or links native code")
378
+ return handler
379
+
380
+
381
+ def h_pipfile_lock(rep, path, hunks):
382
+ fields = {"version": re.compile(r'^\s*"version":\s*"([^"]+)"'),
383
+ "index": re.compile(r'^\s*"index":\s*"([^"]+)"')}
384
+ merge_blocks(rep, path, "pip", hunks, npm_lock_key, fields, lambda k: (k.lower().replace("_", "-"), None))
385
+
386
+
387
+ def h_requirements(rep, path, hunks):
388
+ for hunk in hunks:
389
+ removed, added = {}, {}
390
+ for tag, text in hunk:
391
+ if tag == " ":
392
+ continue
393
+ s = text.split(" #")[0].strip()
394
+ if re.match(r"^(-i|--index-url|--extra-index-url|--find-links|-f)\b", s):
395
+ if tag == "+":
396
+ rep.flag("source", path, f"package index line added: `{s}`")
397
+ continue
398
+ m = re.match(r"^([A-Za-z0-9][A-Za-z0-9._\-]*)(\[[^\]]*\])?\s*((?:===|==|~=|>=|<=|!=|>|<)[^;\\\s]*)", s)
399
+ if m:
400
+ (removed if tag == "-" else added)[m.group(1).lower().replace("_", "-")] = m.group(3)
401
+ elif re.search(r"(git\+|https?://)", s) and tag == "+":
402
+ rep.flag("source", path, f"requirement installed from a URL: `{s}`")
403
+ pair_line_changes(rep, path, "pip", "manifest", removed, added)
404
+
405
+
406
+ def h_toml_manifest(eco):
407
+ def handler(rep, path, hunks):
408
+ for hunk in hunks:
409
+ removed, added = {}, {}
410
+ for tag, text in hunk:
411
+ if tag == " ":
412
+ continue
413
+ s = text.strip()
414
+ pep = re.match(r"""^["']([A-Za-z0-9][A-Za-z0-9._\-]*)(\[[^\]]*\])?\s*([<>=!~^][^"';]*)""", s)
415
+ if pep:
416
+ (removed if tag == "-" else added)[pep.group(1).lower()] = pep.group(3).strip()
417
+ continue
418
+ m = re.match(r'^([A-Za-z0-9_.\-]+)\s*=\s*(?:"([^"]+)"|\{(.*)\})', s)
419
+ if not m or m.group(1) in TOML_SKIP_KEYS:
420
+ continue
421
+ name, val, table = m.group(1), m.group(2), m.group(3)
422
+ if table is not None:
423
+ vm = re.search(r'\b(?:version|rev|tag|branch)\s*=\s*"([^"]+)"', table)
424
+ val = vm.group(1) if vm else None
425
+ if tag == "+" and re.search(r'\b(git|path|registry|url)\s*=', table):
426
+ rep.flag("source", path, f"{name}: source set in the manifest: `{{{table.strip()}}}`")
427
+ if val and VERSIONISH.match(val) or (val and SHA40.search(val)):
428
+ (removed if tag == "-" else added)[name] = val
429
+ pair_line_changes(rep, path, eco, "manifest", removed, added)
430
+ return handler
431
+
432
+
433
+ def h_go_mod(rep, path, hunks):
434
+ for hunk in hunks:
435
+ removed, added, notes = {}, {}, {}
436
+ for tag, text in hunk:
437
+ if tag == " ":
438
+ continue
439
+ s = text.strip()
440
+ if s.startswith("replace") or "=>" in s:
441
+ if tag == "+":
442
+ rep.flag("source", path, f"go.mod replace directive: `{s}`")
443
+ continue
444
+ tm = re.match(r"^(go|toolchain)\s+(\S+)$", s)
445
+ if tm:
446
+ (removed if tag == "-" else added)[tm.group(1) + " (directive)"] = tm.group(2)
447
+ continue
448
+ m = re.match(r"^(?:require\s+)?([a-zA-Z0-9][^\s]*\.[^\s]+)\s+(v[^\s]+)(\s*//\s*indirect)?", s)
449
+ if m:
450
+ (removed if tag == "-" else added)[m.group(1)] = m.group(2)
451
+ if m.group(3):
452
+ notes[m.group(1)] = "indirect"
453
+ pair_line_changes(rep, path, "go", "manifest", removed, added, notes)
454
+
455
+
456
+ def h_go_sum(rep, path, hunks):
457
+ removed, added = {}, {}
458
+ for hunk in hunks:
459
+ for tag, text in hunk:
460
+ m = re.match(r"^(\S+)\s+(v[^\s/]+)(/go\.mod)?\s+h1:", text)
461
+ if tag == " " or not m or m.group(3):
462
+ continue
463
+ (removed if tag == "-" else added)[m.group(1)] = m.group(2)
464
+ pair_line_changes(rep, path, "go", "lockfile", removed, added)
465
+
466
+
467
+ def h_pom(rep, path, hunks):
468
+ def key(text):
469
+ m = re.match(r"^\s*<artifactId>([^<]+)</artifactId>", text)
470
+ return m.group(1) if m else None
471
+ fields = {"version": re.compile(r"^\s*<version>([^<]+)</version>")}
472
+ merge_blocks(rep, path, "maven", hunks, key, fields, lambda k: (k, None), where="manifest")
473
+ for hunk in hunks:
474
+ removed, added = {}, {}
475
+ for tag, text in hunk:
476
+ if tag == " ":
477
+ continue
478
+ m = re.match(r"^\s*<([\w.\-]+\.version)>([^<]+)</", text)
479
+ if m:
480
+ (removed if tag == "-" else added)[m.group(1)] = m.group(2)
481
+ if tag == "+" and re.match(r"^\s*<url>", text):
482
+ rep.flag("source", path, f"repository url line added: `{text.strip()}`")
483
+ pair_line_changes(rep, path, "maven", "manifest", removed, added)
484
+
485
+
486
+ def h_gradle(rep, path, hunks):
487
+ for hunk in hunks:
488
+ removed, added = {}, {}
489
+ for tag, text in hunk:
490
+ if tag == " ":
491
+ continue
492
+ m = re.search(r"""["']([\w.\-]+:[\w.\-]+):([\w.\-+]+)["']""", text)
493
+ if m:
494
+ (removed if tag == "-" else added)[m.group(1)] = m.group(2)
495
+ pair_line_changes(rep, path, "gradle", "manifest", removed, added)
496
+
497
+
498
+ def h_registry_config(rep, path, hunks):
499
+ for hunk in hunks:
500
+ for tag, text in hunk:
501
+ if tag == "+" and re.search(r"(registry|index-url|extra-index-url|npmRegistryServer|scope)", text, re.I):
502
+ rep.flag("source", path, f"registry configuration changed: `{text.strip()}`")
503
+
504
+
505
+ def h_workflow(rep, path, hunks):
506
+ uses = re.compile(r"""^\s*-?\s*uses:\s*['"]?([^@\s'"]+)@([^\s'"#]+)['"]?\s*(?:#\s*(.*))?$""")
507
+ for hunk in hunks:
508
+ removed, added = {}, {}
509
+ for tag, text in hunk:
510
+ m = uses.match(text)
511
+ if tag == " " or not m or m.group(1).startswith(("./", "docker://")):
512
+ continue
513
+ (removed if tag == "-" else added)[m.group(1)] = (m.group(2), (m.group(3) or "").strip())
514
+ for name in list(removed) + [n for n in added if n not in removed]:
515
+ o, n = removed.get(name), added.get(name)
516
+ if o == n:
517
+ continue
518
+ show = lambda x: (f"{x[0][:12]}… ({x[1]})" if SHA40.fullmatch(x[0]) and x[1]
519
+ else (f"{x[0][:12]}…" if SHA40.fullmatch(x[0]) else x[0])) if x else None
520
+ ver = lambda x: (x[1] or None) if x and SHA40.fullmatch(x[0]) else (x[0] if x else None)
521
+ rec_old, rec_new = show(o), show(n)
522
+ rep.change(path, "github-actions", "workflow", name, rec_old, rec_new)
523
+ r = next(r for r in rep.changes if r["file"] == path and r["name"] == name and r["new"] == rec_new)
524
+ if o and n:
525
+ r["class"] = semver_class(ver(o), ver(n))
526
+ repo = "/".join(name.split("/")[:2])
527
+ if n and SHA40.fullmatch(n[0]):
528
+ tag_ = re.match(r"^(v?[\w.\-]+)", n[1]).group(1) if n[1] else None
529
+ if tag_:
530
+ rep.flag("pin", path, f"{name}@{n[0]} claims {tag_}. UNVERIFIED until the tag resolves to this commit: "
531
+ f"`git ls-remote https://github.com/{repo} refs/tags/{tag_} 'refs/tags/{tag_}^{{}}'` "
532
+ f"(the `^{{}}` line is the commit for an annotated tag; it must equal {n[0]})")
533
+ else:
534
+ rep.flag("pin", path, f"{name}@{n[0]} has no version comment, so which release it is cannot be read "
535
+ f"from the diff. UNVERIFIED: `git ls-remote --tags https://github.com/{repo}` and find {n[0]}")
536
+ if o and not SHA40.fullmatch(o[0]):
537
+ r["notes"].append("pin strengthened from tag to SHA")
538
+ elif n:
539
+ kind = "branch" if n[0] in ("main", "master", "develop", "trunk") or not re.match(r"^v?\d", n[0]) else "tag"
540
+ rep.flag("pin", path, f"{name}@{n[0]} is a {kind}-only pin: the {kind} can be moved to other code after "
541
+ f"review. Pin to the commit: `git ls-remote https://github.com/{repo} refs/tags/{n[0]} "
542
+ f"'refs/tags/{n[0]}^{{}}'`" if kind == "tag" else
543
+ f"{name}@{n[0]} tracks a branch: every run gets whatever was pushed last. Pin to a release commit")
544
+ if o and SHA40.fullmatch(o[0]):
545
+ r["notes"].append("pin WEAKENED from SHA to " + kind)
546
+
547
+
548
+ def handler_for(path):
549
+ base = os.path.basename(path)
550
+ low = path.replace("\\", "/")
551
+ if re.search(r"(^|/)\.github/workflows/[^/]+\.ya?ml$", low) or base in ("action.yml", "action.yaml"):
552
+ return h_workflow
553
+ table = {
554
+ "package.json": h_package_json, "package-lock.json": h_package_lock,
555
+ "npm-shrinkwrap.json": h_package_lock, "yarn.lock": h_yarn_lock, "pnpm-lock.yaml": h_pnpm_lock,
556
+ "pyproject.toml": h_toml_manifest("pip"), "Pipfile": h_toml_manifest("pip"),
557
+ "Pipfile.lock": h_pipfile_lock, "poetry.lock": h_toml_lock("pip"), "uv.lock": h_toml_lock("pip"),
558
+ "pdm.lock": h_toml_lock("pip"), "go.mod": h_go_mod, "go.sum": h_go_sum,
559
+ "Cargo.toml": h_toml_manifest("cargo"), "Cargo.lock": h_toml_lock("cargo"), "pom.xml": h_pom,
560
+ "build.gradle": h_gradle, "build.gradle.kts": h_gradle, "libs.versions.toml": h_toml_manifest("gradle"),
561
+ ".npmrc": h_registry_config, ".yarnrc": h_registry_config, ".yarnrc.yml": h_registry_config,
562
+ "pip.conf": h_registry_config,
563
+ }
564
+ if base in table:
565
+ return table[base]
566
+ if re.match(r"^(requirements|constraints)[\w.\-]*\.(txt|in)$", base):
567
+ return h_requirements
568
+ return None
569
+
570
+
571
+ # ---------------------------------------------------------------- analysis + output
572
+
573
+ def analyse(files, title):
574
+ rep = Report()
575
+ for f in files:
576
+ h = handler_for(f["path"])
577
+ rep.files.append((f["path"], h is not None))
578
+ if h:
579
+ h(rep, f["path"], f["hunks"])
580
+ manifest_recs = {(r["ecosystem"], r["name"], r["old"], r["new"]) for r in rep.changes if r["where"] == "manifest"}
581
+ rep.changes = [r for r in rep.changes if r["where"] != "lockfile" or
582
+ (r["ecosystem"], r["name"], r["old"], r["new"]) not in manifest_recs]
583
+ manifest_names = {r["name"] for r in rep.changes if r["where"] in ("manifest", "workflow")}
584
+ for r in rep.changes:
585
+ if r["where"] == "lockfile":
586
+ r["notes"].insert(0, "direct" if r["name"] in manifest_names else "lockfile only (transitive)")
587
+ if r["kind"] == "added" and r["ecosystem"] == "npm" and r["name"] in NATIVE_NPM:
588
+ rep.flag("native", r["file"], f"{r['name']} {r['new']}: new package used to build or fetch native binaries")
589
+ if title:
590
+ t = title.lower()
591
+ for r in rep.changes:
592
+ short = r["name"].split("/")[-1].lower()
593
+ r["in_title"] = r["name"].lower() in t or (len(short) > 3 and re.search(rf"\b{re.escape(short)}\b", t) is not None)
594
+ reasons_stop, reasons_check = [], []
595
+ for fl in rep.flags:
596
+ if fl["kind"] in ("install-script", "source"):
597
+ reasons_stop.append(f"{fl['kind']}: {fl['text']}")
598
+ elif fl["kind"] == "pin":
599
+ reasons_check.append("pin: " + fl["text"].split(". ")[0])
600
+ elif not (fl["kind"] == "licence" and "new package" in fl["text"]):
601
+ reasons_check.append(f"{fl['kind']}: {fl['text']}")
602
+ locked = {r["name"] for r in rep.changes if r["where"] == "lockfile"}
603
+ for r in rep.changes:
604
+ if r["where"] == "manifest" and r["name"] in locked:
605
+ continue
606
+ cls = r.get("class", "")
607
+ if cls in ("major", "minor (0.x: breaking)", "patch (0.0.x: breaking)", "downgrade", "unknown"):
608
+ reasons_check.append(f"{cls}: {r['name']} {r['old']} -> {r['new']}")
609
+ if r["kind"] == "added":
610
+ reasons_check.append(f"added: {r['name']} {r['new']}")
611
+ if "target is a prerelease" in r["notes"] or any("WEAKENED" in n for n in r["notes"]):
612
+ reasons_check.append(f"{r['name']}: {', '.join(r['notes'])}")
613
+ if rep.unattributed:
614
+ reasons_check.append("version lines with no package name in context: " +
615
+ ", ".join(f"{p} ({n})" for p, n in rep.unattributed.items()))
616
+ reasons_stop = list(dict.fromkeys(reasons_stop))
617
+ reasons_check = list(dict.fromkeys(reasons_check))
618
+ if reasons_stop:
619
+ floor = "DO NOT MERGE"
620
+ elif reasons_check:
621
+ floor = "MERGE AFTER CHECKS"
622
+ else:
623
+ floor = "none from the diff"
624
+ return rep, floor, reasons_stop, reasons_check
625
+
626
+
627
+ def render_md(rep, floor, stop, check, title):
628
+ out = ["# Dependency upgrade diff", ""]
629
+ read = [p for p, ok in rep.files if ok]
630
+ skipped = [p for p, ok in rep.files if not ok]
631
+ out.append("Files read: " + (", ".join(f"`{p}`" for p in read) or "none"))
632
+ if skipped:
633
+ out.append("Other files in the diff (not dependency files, review them separately): " +
634
+ ", ".join(f"`{p}`" for p in skipped))
635
+ if title:
636
+ out.append(f'PR title: "{title}"')
637
+ out += ["", f"**Floor verdict: {floor}** (mechanical; the changelog, usage and CI checks are still to do, "
638
+ "and only they can make it MERGE)"]
639
+ if stop or check:
640
+ out.append("")
641
+ for r in stop:
642
+ out.append(f"- STOP {r}")
643
+ for r in check:
644
+ out.append(f"- CHECK {r}")
645
+ groups = (("Changed", "changed"), ("Added", "added"), ("Removed", "removed"))
646
+ for heading, kind in groups:
647
+ rows = [r for r in rep.changes if r["kind"] == kind]
648
+ out += ["", f"## {heading} ({len(rows)})", ""]
649
+ if not rows:
650
+ out.append("None.")
651
+ continue
652
+ out.append("| Package | Ecosystem | Old → New | Class | File | Notes |")
653
+ out.append("|---|---|---|---|---|---|")
654
+ for r in rows:
655
+ notes = list(r["notes"])
656
+ if title and not r.get("in_title"):
657
+ notes.append("not named in the title")
658
+ out.append(f"| `{r['name']}` | {r['ecosystem']} | {r['old'] or '—'} → {r['new'] or '—'} | "
659
+ f"{r.get('class', '—')} | `{r['file']}` | {'; '.join(notes) or ''} |")
660
+ if title:
661
+ beyond = [r for r in rep.changes if not r.get("in_title")]
662
+ out += ["", f"## Beyond the title ({len(beyond)})", ""]
663
+ out += [f"- `{r['name']}` {r['kind']}: {r['old'] or '—'} → {r['new'] or '—'} ({r['file']})" for r in beyond] or ["None."]
664
+ labels = (("install-script", "Install scripts"), ("native", "Native code"), ("source", "Sources and registries"),
665
+ ("licence", "Licences"), ("pin", "Pins (verify every SHA; the script cannot)"))
666
+ for kind, heading in labels:
667
+ rows = [f for f in rep.flags if f["kind"] == kind]
668
+ out += ["", f"## {heading} ({len(rows)})", ""]
669
+ out += [f"- `{f['file']}`: {f['text']}" for f in rows] or ["None seen in the diff."]
670
+ if rep.unattributed:
671
+ out += ["", "## Out of context", ""]
672
+ out += [f"- `{p}`: {n} changed version line(s) with the package name outside the hunk. "
673
+ f"Re-run on `git diff -U12` to attribute them." for p, n in rep.unattributed.items()]
674
+ out += ["", "## Not visible in a diff", "",
675
+ "- The release notes for every version between old and new, and what they break, deprecate, fix or relicense.",
676
+ "- Whether the repo calls an API those notes change or remove.",
677
+ "- Whether any CI job runs the code paths that use the changed packages.",
678
+ "- Publisher or maintainer changes, unless the lockfile records them.", ""]
679
+ return "\n".join(out)
680
+
681
+
682
+ def main(argv=None):
683
+ ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
684
+ ap.add_argument("diff", help="unified diff file, or - for stdin")
685
+ ap.add_argument("--title", help="the PR title, to list changes it does not name")
686
+ ap.add_argument("--format", choices=("md", "json"), default="md")
687
+ ap.add_argument("-o", "--output", help="write the report here instead of stdout")
688
+ a = ap.parse_args(argv)
689
+ try:
690
+ text = sys.stdin.read() if a.diff == "-" else open(a.diff, encoding="utf-8", errors="replace").read()
691
+ except OSError as e:
692
+ print(f"upgrade_diff: cannot read {a.diff}: {e}", file=sys.stderr)
693
+ return 2
694
+ files = parse_diff(text)
695
+ if not files:
696
+ print("upgrade_diff: no unified diff found in the input (expected `diff --git` or ---/+++ headers)", file=sys.stderr)
697
+ return 2
698
+ if not any(handler_for(f["path"]) for f in files):
699
+ print("upgrade_diff: the diff touches no manifest, lockfile or workflow file this script reads: " +
700
+ ", ".join(f["path"] for f in files), file=sys.stderr)
701
+ return 2
702
+ rep, floor, stop, check = analyse(files, a.title)
703
+ if a.format == "json":
704
+ body = json.dumps({"floor_verdict": floor, "stop": stop, "check": check, "changes": rep.changes,
705
+ "flags": rep.flags, "unattributed": rep.unattributed,
706
+ "files": [{"path": p, "read": ok} for p, ok in rep.files]}, indent=2)
707
+ else:
708
+ body = render_md(rep, floor, stop, check, a.title)
709
+ if a.output:
710
+ with open(a.output, "w", encoding="utf-8") as fh:
711
+ fh.write(body + "\n")
712
+ else:
713
+ print(body)
714
+ return 0
715
+
716
+
717
+ if __name__ == "__main__":
718
+ sys.exit(main())