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.
- package/bin/skilldrop.js +160 -33
- package/catalogue.json +7 -3
- package/model-routing.json +16 -0
- package/package.json +1 -1
- package/packs/ai-engineering/skills/mcp-pairing/SKILL.md +93 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/eval_queries.json +13 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/evals.json +76 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/2/mcp.json +32 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/.mcp.json +47 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/docs/pr-loop.md +12 -0
- package/packs/ai-engineering/skills/mcp-pairing/evals/files/5/docs/oncall-triage.md +18 -0
- package/packs/ai-engineering/skills/mcp-pairing/examples/build-loop-github-jira-datadog.md +69 -0
- package/packs/ai-engineering/skills/mcp-pairing/manifest.json +17 -0
- package/packs/ai-engineering/skills/mcp-pairing/reference.md +136 -0
- package/packs/ai-engineering/skills/mcp-pairing/templates/mcp-pairing.md +43 -0
- package/packs/data-analytics/loops/measure/LOOP.md +118 -0
- package/packs/data-analytics/loops/measure/evals/eval_queries.json +9 -0
- package/packs/data-analytics/loops/measure/evals/evals.json +28 -0
- package/packs/data-analytics/loops/measure/loop.json +41 -0
- package/packs/dev-team/loops/build/LOOP.md +4 -2
- package/packs/dev-team/loops/build/loop.json +2 -2
- package/packs/dev-team/skills/codebase-tour/SKILL.md +115 -0
- package/packs/dev-team/skills/codebase-tour/evals/eval_queries.json +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/evals.json +77 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/.github/workflows/ci.yml +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/README.md +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/openapi.yaml +21 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/package-lock.json +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/package.json +29 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/config.ts +20 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/filter.ts +24 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/repository.ts +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/errors.ts +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/generated/api-types.ts +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/handlers/shipments.ts +38 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/routes.ts +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/jobs/export-shipments.ts +22 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/src/server.ts +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/domain/filter.test.ts +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/http/shipments.test.ts +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/tsconfig.json +10 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/1/vitest.config.ts +5 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/.github/workflows/ci.yml +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/README.md +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/noxfile.py +14 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/pyproject.toml +22 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/__init__.py +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/_version.py +4 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/cli.py +32 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/detect.py +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/errors.py +2 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/money.py +14 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/__init__.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/acme.py +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/base.py +24 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/harbor.py +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/fixtures/acme_sample.csv +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_acme.py +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_detect.py +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/.github/workflows/ci.yml +28 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/Makefile +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/README.md +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/cmd/tidepool/main.go +24 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/go.mod +5 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/config/config.go +38 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher.go +52 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher_test.go +42 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/sender.go +32 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/replay/replay.go +31 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/db.go +35 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql +11 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql.go +30 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/schema.sql +6 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/store_integration_test.go +21 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/3/sqlc.yaml +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/.env.example +4 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/.github/workflows/ci.yml +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/Makefile +16 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/README.md +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/data/rates.csv +4 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/pyproject.toml +19 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/scripts/gen_rates.py +29 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/__init__.py +0 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/__init__.py +0 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/preview.py +14 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/routes.py +36 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/config.py +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/discounts.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/__init__.py +0 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/rates_table.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/main.py +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/shipping.py +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/tax.py +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/conftest.py +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_discounts.py +11 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_tax.py +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/.env.example +3 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/.github/workflows/ci.yml +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/README.md +18 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/package.json +13 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/openapi.json +30 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/package.json +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/db.js +19 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/flags.js +8 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/generated/schema.js +32 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/middleware/validate.js +20 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/routes/bookings.js +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/server.js +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/bookings.js +23 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/pricing.js +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/test/pricing.test.js +15 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/package.json +9 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/bookingForm.js +11 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/format.js +7 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/test/format.test.js +12 -0
- package/packs/dev-team/skills/codebase-tour/evals/files/5/scripts/gen-schema.mjs +27 -0
- package/packs/dev-team/skills/codebase-tour/examples/refund-window.md +158 -0
- package/packs/dev-team/skills/codebase-tour/manifest.json +47 -0
- package/packs/dev-team/skills/codebase-tour/scripts/repo_facts.py +403 -0
- package/packs/dev-team/skills/codebase-tour/templates/codebase-tour.md +85 -0
- package/packs/dev-team/skills/dependency-upgrade-review/SKILL.md +101 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/eval_queries.json +11 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/evals.json +77 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/.github/workflows/ci.yml +20 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/pr.diff +87 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/quillmark-CHANGELOG.md +24 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/comment.js +6 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/note.js +8 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/test/note.test.js +12 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/ci.yml +23 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/release.yml +23 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/pr.diff +32 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/release-notes.md +20 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/.github/workflows/ci.yml +18 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/Dockerfile +8 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/PR.md +36 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/pr.diff +47 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/client.py +24 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/reader.py +32 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/tests/test_reader.py +15 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/upstream/quillstream-CHANGELOG.md +37 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/ci.yml +22 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/release.yml +25 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.nvmrc +1 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-412.md +16 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-413.md +15 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/package.json +21 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-412.diff +26 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-413.diff +84 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/scripts/release.mjs +18 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/release-notary-CHANGELOG.md +16 -0
- package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/setup-toolchain-RELEASES.md +22 -0
- package/packs/dev-team/skills/dependency-upgrade-review/examples/inputs/ledgerline.diff +38 -0
- package/packs/dev-team/skills/dependency-upgrade-review/examples/ledgerline-tidyparse.md +91 -0
- package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +50 -0
- package/packs/dev-team/skills/dependency-upgrade-review/reference.md +99 -0
- package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +718 -0
- package/packs/grc/loops/comply/LOOP.md +133 -0
- package/packs/grc/loops/comply/evals/eval_queries.json +9 -0
- package/packs/grc/loops/comply/evals/evals.json +30 -0
- package/packs/grc/loops/comply/loop.json +54 -0
- package/packs/product-manager/loops/discover/LOOP.md +5 -4
- package/packs/product-manager/loops/discover/loop.json +2 -2
- package/packs/product-manager/skills/feedback-synthesis/SKILL.md +182 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/eval_queries.json +46 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/evals.json +76 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/1/interview-notes.md +47 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/1/nps-verbatims.csv +13 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/1/support-tickets.csv +36 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/3/churn-exit-survey.csv +11 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/3/sales-call-notes.md +55 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/4/csat_comments_q3.csv +17 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/4/support_tickets_q3.csv +49 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/5/app_reviews_last_60_days.csv +32 -0
- package/packs/product-manager/skills/feedback-synthesis/evals/files/5/interview_notes_round1.md +48 -0
- package/packs/product-manager/skills/feedback-synthesis/examples/booking-app-feedback.md +139 -0
- package/packs/product-manager/skills/feedback-synthesis/examples/inputs/booking-app-coded.csv +17 -0
- package/packs/product-manager/skills/feedback-synthesis/manifest.json +53 -0
- package/packs/product-manager/skills/feedback-synthesis/reference.md +89 -0
- package/packs/product-manager/skills/feedback-synthesis/scripts/theme_counts.py +355 -0
- package/packs/product-manager/skills/feedback-synthesis/templates/coding-sheet.csv +6 -0
- package/packs/product-manager/skills/feedback-synthesis/templates/synthesis.md +42 -0
- package/packs/research/loops/research/LOOP.md +123 -0
- package/packs/research/loops/research/evals/eval_queries.json +9 -0
- package/packs/research/loops/research/evals/evals.json +39 -0
- 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())
|