ghostpkg 0.20.0__tar.gz → 0.22.0__tar.gz
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.
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.gitignore +3 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/CHANGELOG.md +95 -1
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/PKG-INFO +18 -11
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/README.en.md +17 -10
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/README.md +15 -8
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/cli.py +11 -1
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/jslocks.py +40 -13
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/manifests.py +74 -8
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/registries.py +30 -7
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/scanner.py +20 -2
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/pyproject.toml +1 -1
- ghostpkg-0.22.0/scripts/fieldtest.py +151 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_field_findings.py +81 -1
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_jslocks.py +51 -3
- ghostpkg-0.22.0/tests/test_not_a_package.py +124 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ghostpkg-ignore.json +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/workflows/publish.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.pre-commit-hooks.yaml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/2.0 +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/LICENSE +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/SECURITY.md +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/action.yml +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/banner.html +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/banner.png +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/demo.gif +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/assess.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/cache.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/discover.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/inspection.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/policy.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/prose.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/report.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/rules.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_action.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_assess.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_cache.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_discover.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_hostile_input.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_inspection.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_lockfiles.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_manifests.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_policy.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_prose.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_registries.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_regressions.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_report.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_review_findings.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_stated_sources.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_versions.py +0 -0
- {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_withdrawn.py +0 -0
|
@@ -6,6 +6,98 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.22.0] - 2026-09-04
|
|
10
|
+
|
|
11
|
+
Four agents scanned **21 repositories and 78,000 package names**. They found
|
|
12
|
+
**51 false blocks** and not one true positive among them, plus twelve places
|
|
13
|
+
where the documentation described a different tool.
|
|
14
|
+
|
|
15
|
+
### Fixed -- false blocks
|
|
16
|
+
- **pnpm v5 keys with a peer suffix (17 blocks).** `/react-dom/18.2.0_react@18.2.0`
|
|
17
|
+
was read as a package called `react-dom/18.2.0_react`. The peer suffix carries
|
|
18
|
+
an `@digit` of its own, so hunting for the first one found the wrong `@`. The
|
|
19
|
+
version is now located by structure -- in a v5 key the last slash-separated
|
|
20
|
+
segment starts with a digit -- which also fixed `eslint-plugin-react`,
|
|
21
|
+
`tsutils` and `@babel/helper-compilation-targets`.
|
|
22
|
+
- **pnpm `name@file:path` keys (8 blocks).** The protocol is only a prefix in
|
|
23
|
+
v5; from v6 it follows `name@`. A protocol is now looked for anywhere in the
|
|
24
|
+
key, since a `:` cannot occur in a published npm name.
|
|
25
|
+
- **A yarn `npm:v1.1.0` range (2 blocks).** The leading `v` made a version range
|
|
26
|
+
look like an alias to a package called `v1.1.0`.
|
|
27
|
+
- **Checksum files read as requirements (5 blocks).** Airflow keeps 127 files
|
|
28
|
+
whose entire content is one MD5; five carry `constraints` in the name, and a
|
|
29
|
+
32-character hex string is a legal PEP 508 name.
|
|
30
|
+
- **Materialised symlinks (4 blocks).** Git writes a symlink as a plain file
|
|
31
|
+
holding its target where the filesystem has no symlink support, so
|
|
32
|
+
`requirements_compiled_py3.10.txt` contained the line
|
|
33
|
+
`requirements_compiled.txt` -- read as a package.
|
|
34
|
+
- **PEP 440 local versions (17 blocks).** PyPI refuses an upload carrying one,
|
|
35
|
+
so `torch==2.9.0+cu128` can never match a release list. Ray pins every CUDA
|
|
36
|
+
build that way.
|
|
37
|
+
|
|
38
|
+
The first attempt at the last two rejected any name ending in `.yaml`, `.cfg`
|
|
39
|
+
or `.ini` as well. `ruamel.yaml`, `ruamel.yaml.clib` and `pytest.ini` are all
|
|
40
|
+
real packages, so that would have traded a false block for a silent miss on
|
|
41
|
+
three of them. The guard is now two extensions and only on a line with no
|
|
42
|
+
version.
|
|
43
|
+
|
|
44
|
+
### Fixed -- flags
|
|
45
|
+
- **`--timeout` now reaches `--deep`**, which downloaded archives on a fixed
|
|
46
|
+
budget of its own, and **`--timeout 0` is refused** rather than silently
|
|
47
|
+
ignored.
|
|
48
|
+
|
|
49
|
+
### Documentation
|
|
50
|
+
Both READMEs stated that **"does not exist" is cached for an hour** and then, 300
|
|
51
|
+
lines later, that negatives are never cached. The second is true; the first
|
|
52
|
+
described the precise failure this tool exists to avoid. Also corrected: `-e`
|
|
53
|
+
does not apply to `scan`; `--format` and `--config` were in no options table;
|
|
54
|
+
"only one release" and "no repository link" are age-gated; the action's
|
|
55
|
+
`install` and `config` inputs were undocumented; the prose measurement was
|
|
56
|
+
stale in English; `data.py` has been per-ecosystem for some time; and the CI and
|
|
57
|
+
pre-commit snippets pinned a version whose own changelog records a false
|
|
58
|
+
all-clear.
|
|
59
|
+
|
|
60
|
+
656 tests. Field gate: 4,461 packages, zero blocks.
|
|
61
|
+
|
|
62
|
+
## [0.21.0] - 2026-09-04
|
|
63
|
+
|
|
64
|
+
Three more false blocks, all found by a new release gate that scans large real
|
|
65
|
+
repositories instead of synthetic manifests. On `home-assistant/core` alone the
|
|
66
|
+
count went **39 -> 0**.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
- **`scripts/fieldtest.py`, a release gate.** It clones five repositories chosen
|
|
70
|
+
for the dependency shapes they contain, scans each, and fails on any block
|
|
71
|
+
not listed with a reason. Every name in those projects is something thousands
|
|
72
|
+
of people install daily, so **every block is false until shown otherwise**.
|
|
73
|
+
Current state: **4,461 packages checked, zero blocks.**
|
|
74
|
+
|
|
75
|
+
This exists because 605 unit tests and a 35-check acceptance pass had missed
|
|
76
|
+
eleven defects that one pass over real repositories found immediately.
|
|
77
|
+
|
|
78
|
+
### Fixed
|
|
79
|
+
- **A constraints file forbids; it does not install.** `pip` never installs
|
|
80
|
+
from one -- it only bounds a version if the package arrives some other way --
|
|
81
|
+
so the standard way to forbid a package outright is to pin it to a version
|
|
82
|
+
that cannot exist. Home Assistant does this for eight of them
|
|
83
|
+
(`pycrypto==1000000000.0.0`), and checking those pins turned deliberate
|
|
84
|
+
exclusions into **35 reported blocks**. The flag now survives nested `-c` and
|
|
85
|
+
`-r` includes. The name in a constraints file is still checked.
|
|
86
|
+
- **PEP 440 pads the release segment with zeros.** `0.8` and `0.8.0` are one
|
|
87
|
+
version, as are `1.6.6` and `1.6.6.0`. Both spellings sit in Home Assistant
|
|
88
|
+
requirements against packages that store the other form, and comparing the
|
|
89
|
+
raw text blocked `libsoundtouch` and `baidu-aip`. Worse, the test suite had
|
|
90
|
+
asserted the *opposite* -- that `1.0` and `1.0.0` differ -- so the wrong
|
|
91
|
+
behaviour was locked in by a test. Corrected, and the padding is now
|
|
92
|
+
asserted.
|
|
93
|
+
- **A block no longer rests on a cached version list.** An established package
|
|
94
|
+
is cached for a day, so a release published inside that window is invisible;
|
|
95
|
+
`opower==0.21.0` exists and was blocked from a stale list. The version list
|
|
96
|
+
is now re-fetched before any version block, which is the same rule that
|
|
97
|
+
already governs negative answers: the answer that blocks has to be fresh.
|
|
98
|
+
|
|
99
|
+
614 tests.
|
|
100
|
+
|
|
9
101
|
## [0.20.0] - 2026-09-03
|
|
10
102
|
|
|
11
103
|
Four defects found by scanning **10,109 packages** across `vercel/next.js`,
|
|
@@ -810,7 +902,9 @@ First release.
|
|
|
810
902
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
811
903
|
the USENIX'25 authors not to publish theirs.
|
|
812
904
|
|
|
813
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.
|
|
905
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.22.0...HEAD
|
|
906
|
+
[0.22.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.22.0
|
|
907
|
+
[0.21.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.21.0
|
|
814
908
|
[0.20.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.20.0
|
|
815
909
|
[0.19.3]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.3
|
|
816
910
|
[0.19.2]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.22.0
|
|
4
4
|
Summary: Catch package names that do not exist before you install them
|
|
5
5
|
Project-URL: Homepage, https://github.com/m1rwana12/ghostpkg
|
|
6
6
|
Project-URL: Issues, https://github.com/m1rwana12/ghostpkg/issues
|
|
@@ -183,8 +183,8 @@ somebody copies the line and runs it — the install has already happened by the
|
|
|
183
183
|
time that name reaches `requirements.txt`.
|
|
184
184
|
|
|
185
185
|
Extraction is deliberately narrow, because a README is full of words that look
|
|
186
|
-
like package names. Measured across
|
|
187
|
-
names
|
|
186
|
+
like package names. Measured across twenty-two real READMEs it extracted 18
|
|
187
|
+
names, **none of which fail to exist**; `pip install -r requirements.txt`,
|
|
188
188
|
`npm run build`, `pip is a package manager` and `npx create-react-app my-app`
|
|
189
189
|
(where `my-app` is an argument) all yield nothing.
|
|
190
190
|
|
|
@@ -251,7 +251,9 @@ guessed at. Known public mirrors (`registry.yarnpkg.com`,
|
|
|
251
251
|
|
|
252
252
|
| Flag | Purpose |
|
|
253
253
|
|---|---|
|
|
254
|
-
| `-e`, `--ecosystem` | `pypi` (default) or `npm` |
|
|
254
|
+
| `-e`, `--ecosystem` | `pypi` (default) or `npm`. `check` only -- `scan` takes the ecosystem from the file |
|
|
255
|
+
| `--format` | `text` (default), `json`, or `github` for diff annotations |
|
|
256
|
+
| `--config PATH` | Ignore file. Never read from the directory being scanned |
|
|
255
257
|
| `--strict` | Promote warnings to blocks |
|
|
256
258
|
| `--json` | Machine-readable output for scripts and CI |
|
|
257
259
|
| `-q`, `--quiet` | Hide packages that passed |
|
|
@@ -379,8 +381,8 @@ commands instead, and `--format text` is the default.
|
|
|
379
381
|
| A pinned version that does not exist | 🔴 **Blocked** | `requests==99.99.99`. A model invents versions as readily as names, and the registry lists every real one, so this is a lookup rather than a heuristic. Only exact pins are checked — a range like `>=2.31` or `^4.18.0` may be satisfied by some other version. |
|
|
380
382
|
| First published < 90 days ago | 🟡 Warning | Attackers register fast. So do honest authors — hence a warning, not a block. |
|
|
381
383
|
| First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
|
|
382
|
-
| Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
|
|
383
|
-
| No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
|
|
384
|
+
| Only one release, **and under a year old** | 🟡 Warning | Squats are usually published once and abandoned. An established package with one release is simply finished, so the age gate is part of the signal rather than a separate row. |
|
|
385
|
+
| No repository or homepage link, **and under a year old** | 🟡 Warning | Real projects almost always link to source. Age-gated for the same reason as the row above. |
|
|
384
386
|
| 1–2 edits from a popular name, **and** the package is either recent **or** abandoned (≤2 releases and no repository link) | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age alone was the wrong gate: `expresss` has sat on npm since 2016 with one release and ~2,500 typo-driven downloads a month. |
|
|
385
387
|
|
|
386
388
|
Warnings are advisory by default. Nothing but non-existence blocks unless you pass
|
|
@@ -409,9 +411,11 @@ Everything softer is reported for a human to read.
|
|
|
409
411
|
$ ghostpkg check react-router-dom-utils -e npm
|
|
410
412
|
|
|
411
413
|
WARNING react-router-dom-utils
|
|
412
|
-
- first published
|
|
414
|
+
- first published 179 days ago (under a year)
|
|
413
415
|
- only one release
|
|
414
416
|
- no repository or homepage link
|
|
417
|
+
|
|
418
|
+
1 to review by hand
|
|
415
419
|
```
|
|
416
420
|
|
|
417
421
|
When the name does not exist at all, the block comes with the likely intent
|
|
@@ -437,7 +441,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
437
441
|
### In CI
|
|
438
442
|
|
|
439
443
|
```yaml
|
|
440
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
444
|
+
- uses: M1rwana12/ghostpkg@v0.22.0
|
|
441
445
|
```
|
|
442
446
|
|
|
443
447
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -451,8 +455,11 @@ than leaving the answer in a job log:
|
|
|
451
455
|
A blocking finding is an error and a soft signal is a warning, so the
|
|
452
456
|
annotations and the exit code agree about severity.
|
|
453
457
|
|
|
454
|
-
Optional inputs -- `paths`, `strict`, `deep`, `version`, `
|
|
455
|
-
`fail-on-error`. Pin `version`
|
|
458
|
+
Optional inputs -- `paths`, `strict`, `deep`, `version`, `install`, `config`,
|
|
459
|
+
`python-version`, `fail-on-error`. Pin `version` for a reproducible run, and
|
|
460
|
+
point `config` at an ignore file kept in the repository (this project uses
|
|
461
|
+
`.github/ghostpkg-ignore.json` for its own scan, so suppressions are reviewed
|
|
462
|
+
in a pull request like any other change).
|
|
456
463
|
|
|
457
464
|
Or without the action, if you prefer:
|
|
458
465
|
|
|
@@ -465,7 +472,7 @@ Or without the action, if you prefer:
|
|
|
465
472
|
```yaml
|
|
466
473
|
repos:
|
|
467
474
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
468
|
-
rev: v0.
|
|
475
|
+
rev: v0.22.0
|
|
469
476
|
hooks:
|
|
470
477
|
- id: ghostpkg
|
|
471
478
|
```
|
|
@@ -135,8 +135,8 @@ somebody copies the line and runs it — the install has already happened by the
|
|
|
135
135
|
time that name reaches `requirements.txt`.
|
|
136
136
|
|
|
137
137
|
Extraction is deliberately narrow, because a README is full of words that look
|
|
138
|
-
like package names. Measured across
|
|
139
|
-
names
|
|
138
|
+
like package names. Measured across twenty-two real READMEs it extracted 18
|
|
139
|
+
names, **none of which fail to exist**; `pip install -r requirements.txt`,
|
|
140
140
|
`npm run build`, `pip is a package manager` and `npx create-react-app my-app`
|
|
141
141
|
(where `my-app` is an argument) all yield nothing.
|
|
142
142
|
|
|
@@ -203,7 +203,9 @@ guessed at. Known public mirrors (`registry.yarnpkg.com`,
|
|
|
203
203
|
|
|
204
204
|
| Flag | Purpose |
|
|
205
205
|
|---|---|
|
|
206
|
-
| `-e`, `--ecosystem` | `pypi` (default) or `npm` |
|
|
206
|
+
| `-e`, `--ecosystem` | `pypi` (default) or `npm`. `check` only -- `scan` takes the ecosystem from the file |
|
|
207
|
+
| `--format` | `text` (default), `json`, or `github` for diff annotations |
|
|
208
|
+
| `--config PATH` | Ignore file. Never read from the directory being scanned |
|
|
207
209
|
| `--strict` | Promote warnings to blocks |
|
|
208
210
|
| `--json` | Machine-readable output for scripts and CI |
|
|
209
211
|
| `-q`, `--quiet` | Hide packages that passed |
|
|
@@ -331,8 +333,8 @@ commands instead, and `--format text` is the default.
|
|
|
331
333
|
| A pinned version that does not exist | 🔴 **Blocked** | `requests==99.99.99`. A model invents versions as readily as names, and the registry lists every real one, so this is a lookup rather than a heuristic. Only exact pins are checked — a range like `>=2.31` or `^4.18.0` may be satisfied by some other version. |
|
|
332
334
|
| First published < 90 days ago | 🟡 Warning | Attackers register fast. So do honest authors — hence a warning, not a block. |
|
|
333
335
|
| First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
|
|
334
|
-
| Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
|
|
335
|
-
| No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
|
|
336
|
+
| Only one release, **and under a year old** | 🟡 Warning | Squats are usually published once and abandoned. An established package with one release is simply finished, so the age gate is part of the signal rather than a separate row. |
|
|
337
|
+
| No repository or homepage link, **and under a year old** | 🟡 Warning | Real projects almost always link to source. Age-gated for the same reason as the row above. |
|
|
336
338
|
| 1–2 edits from a popular name, **and** the package is either recent **or** abandoned (≤2 releases and no repository link) | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age alone was the wrong gate: `expresss` has sat on npm since 2016 with one release and ~2,500 typo-driven downloads a month. |
|
|
337
339
|
|
|
338
340
|
Warnings are advisory by default. Nothing but non-existence blocks unless you pass
|
|
@@ -361,9 +363,11 @@ Everything softer is reported for a human to read.
|
|
|
361
363
|
$ ghostpkg check react-router-dom-utils -e npm
|
|
362
364
|
|
|
363
365
|
WARNING react-router-dom-utils
|
|
364
|
-
- first published
|
|
366
|
+
- first published 179 days ago (under a year)
|
|
365
367
|
- only one release
|
|
366
368
|
- no repository or homepage link
|
|
369
|
+
|
|
370
|
+
1 to review by hand
|
|
367
371
|
```
|
|
368
372
|
|
|
369
373
|
When the name does not exist at all, the block comes with the likely intent
|
|
@@ -389,7 +393,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
389
393
|
### In CI
|
|
390
394
|
|
|
391
395
|
```yaml
|
|
392
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
396
|
+
- uses: M1rwana12/ghostpkg@v0.22.0
|
|
393
397
|
```
|
|
394
398
|
|
|
395
399
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -403,8 +407,11 @@ than leaving the answer in a job log:
|
|
|
403
407
|
A blocking finding is an error and a soft signal is a warning, so the
|
|
404
408
|
annotations and the exit code agree about severity.
|
|
405
409
|
|
|
406
|
-
Optional inputs -- `paths`, `strict`, `deep`, `version`, `
|
|
407
|
-
`fail-on-error`. Pin `version`
|
|
410
|
+
Optional inputs -- `paths`, `strict`, `deep`, `version`, `install`, `config`,
|
|
411
|
+
`python-version`, `fail-on-error`. Pin `version` for a reproducible run, and
|
|
412
|
+
point `config` at an ignore file kept in the repository (this project uses
|
|
413
|
+
`.github/ghostpkg-ignore.json` for its own scan, so suppressions are reviewed
|
|
414
|
+
in a pull request like any other change).
|
|
408
415
|
|
|
409
416
|
Or without the action, if you prefer:
|
|
410
417
|
|
|
@@ -417,7 +424,7 @@ Or without the action, if you prefer:
|
|
|
417
424
|
```yaml
|
|
418
425
|
repos:
|
|
419
426
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
420
|
-
rev: v0.
|
|
427
|
+
rev: v0.22.0
|
|
421
428
|
hooks:
|
|
422
429
|
- id: ghostpkg
|
|
423
430
|
```
|
|
@@ -98,7 +98,9 @@ ghostpkg clear-cache
|
|
|
98
98
|
|
|
99
99
|
| Прапорець | Призначення |
|
|
100
100
|
|---|---|
|
|
101
|
-
| `-e`, `--ecosystem` | `pypi` (типово) або `npm` |
|
|
101
|
+
| `-e`, `--ecosystem` | `pypi` (типово) або `npm`. Лише для `check` — `scan` визначає екосистему з файлу |
|
|
102
|
+
| `--format` | `text` (типово), `json`, або `github` для анотацій на діф |
|
|
103
|
+
| `--config PATH` | Ignore-файл. Ніколи не читається з каталогу, який сканують |
|
|
102
104
|
| `--strict` | Підвищує попередження до блокувань |
|
|
103
105
|
| `--json` | Вивід у JSON для скриптів і CI |
|
|
104
106
|
| `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
|
|
@@ -117,7 +119,10 @@ Lock-файли варто перевіряти навіть якщо ви вж
|
|
|
117
119
|
включно з транзитивними, яких у маніфесті немає. І кожен запис має точну версію,
|
|
118
120
|
отже перевіряється повністю.
|
|
119
121
|
|
|
120
|
-
**Читає й прозу** — `README`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`,
|
|
122
|
+
**Читає й прозу** — `README`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`,
|
|
123
|
+
`.windsurfrules`, і будь-який `.md`, названий прямо. При пошуку в каталозі
|
|
124
|
+
беруться лише файли інструкцій для агентів та README **у корені** — інакше
|
|
125
|
+
великий репозиторій завалив би вивід чейнджлогами.
|
|
121
126
|
Це закриває проблему порядку: **галюцинація з'являється раніше за маніфест**. Модель
|
|
122
127
|
пише `pip install foo-bar` у README, людина копіює рядок — і встановлення вже відбулось,
|
|
123
128
|
коли назва тільки потрапляє в `requirements.txt`.
|
|
@@ -185,7 +190,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
185
190
|
### У CI
|
|
186
191
|
|
|
187
192
|
```yaml
|
|
188
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
193
|
+
- uses: M1rwana12/ghostpkg@v0.22.0
|
|
189
194
|
```
|
|
190
195
|
|
|
191
196
|
Це весь крок. Він шукає маніфести в чекауті, обходить `node_modules` і подібні
|
|
@@ -199,15 +204,17 @@ ghostpkg scan ../інший-проєкт
|
|
|
199
204
|
Блокувальна знахідка — помилка, м'який сигнал — попередження, тож анотації й код
|
|
200
205
|
виходу не суперечать одне одному.
|
|
201
206
|
|
|
202
|
-
Необов'язкові входи: `paths`, `strict`, `deep`, `version`, `
|
|
203
|
-
`fail-on-error`.
|
|
207
|
+
Необов'язкові входи: `paths`, `strict`, `deep`, `version`, `install`, `config`,
|
|
208
|
+
`python-version`, `fail-on-error`. `config` вказує на ignore-файл у самому
|
|
209
|
+
репозиторії — цей проєкт тримає свій у `.github/ghostpkg-ignore.json`, тож
|
|
210
|
+
придушення проходить рев'ю як звичайна зміна.
|
|
204
211
|
|
|
205
212
|
### Як хук pre-commit
|
|
206
213
|
|
|
207
214
|
```yaml
|
|
208
215
|
repos:
|
|
209
216
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
210
|
-
rev: v0.
|
|
217
|
+
rev: v0.22.0
|
|
211
218
|
hooks:
|
|
212
219
|
- id: ghostpkg
|
|
213
220
|
```
|
|
@@ -227,8 +234,8 @@ repos:
|
|
|
227
234
|
| Закріплену версію відкликав мейнтейнер | 🟡 Попередження з його ж поясненням. Не блокування: `pip` встановлює відкликану версію, якщо її закріпили явно. |
|
|
228
235
|
| Закріплена версія не існує (`requests==99.99.99`) | 🔴 **Заблоковано.** Вигадана версія — той самий клас помилки, що й вигадана назва. Діапазони (`>=2.31`, `^4.18`) не перевіряються: їх може задовольнити інша версія. |
|
|
229
236
|
| Опубліковано днями тому | 🟡 Попередження. Зловмисники реєструють швидко — але й чесні автори теж. |
|
|
230
|
-
| Лише один
|
|
231
|
-
| Немає посилання на
|
|
237
|
+
| Лише один реліз, **і пакету менше року** | 🟡 Попередження. Усталений пакет з одним релізом — просто завершений, тож віковий фільтр тут частина сигналу. |
|
|
238
|
+
| Немає посилання на репозиторій, **і пакету менше року** | 🟡 Попередження. Віковий фільтр із тієї ж причини, що й рядком вище. |
|
|
232
239
|
| За один-два символи від популярної назви, **і** пакет свіжий **або** занедбаний (≤2 релізи й немає репозиторію) | 🟡 Попередження. Схоже на typosquat. |
|
|
233
240
|
|
|
234
241
|
---
|
|
@@ -13,6 +13,7 @@ from pathlib import Path
|
|
|
13
13
|
|
|
14
14
|
from . import __version__, registries
|
|
15
15
|
from .assess import Finding
|
|
16
|
+
from . import inspection
|
|
16
17
|
from .cache import Cache
|
|
17
18
|
from .discover import discover
|
|
18
19
|
from .manifests import (
|
|
@@ -120,8 +121,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
120
121
|
print(f"ghostpkg: {'removed ' + str(cache.path) if removed else 'nothing to remove'}")
|
|
121
122
|
return EXIT_OK
|
|
122
123
|
|
|
123
|
-
if args.timeout:
|
|
124
|
+
if args.timeout is not None:
|
|
125
|
+
# `if args.timeout:` made `--timeout 0` a silent no-op. Nought is not a
|
|
126
|
+
# sensible budget either, so it is refused rather than ignored.
|
|
127
|
+
if args.timeout <= 0:
|
|
128
|
+
print("ghostpkg: --timeout must be a positive number of seconds", file=sys.stderr)
|
|
129
|
+
return EXIT_ERROR
|
|
124
130
|
registries.TIMEOUT = args.timeout
|
|
131
|
+
# `--deep` downloads an archive through its own client, which had a
|
|
132
|
+
# fixed budget of its own -- so the documented per-request timeout
|
|
133
|
+
# applied to every request except the slowest kind.
|
|
134
|
+
inspection.TIMEOUT = max(args.timeout, inspection.TIMEOUT)
|
|
125
135
|
|
|
126
136
|
# Group by ecosystem so several manifests are looked up together and a
|
|
127
137
|
# dependency repeated across them costs one request, not one per file.
|
|
@@ -49,6 +49,35 @@ PNPM_LOCAL = ("file:", "link:", "http://", "https://", "git+", "git:")
|
|
|
49
49
|
PNPM_VERSION_AT = re.compile(r"(?<=.)@(?=\d)")
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
def _pnpm_name(key: str) -> "str | None":
|
|
53
|
+
"""The package name out of a `packages:` key, whichever version wrote it.
|
|
54
|
+
|
|
55
|
+
/react-dom/18.2.0_react@18.2.0 v5, version after a slash
|
|
56
|
+
/react@18.2.0 v6, version after an @
|
|
57
|
+
'react@18.2.0' v9, the same with quotes
|
|
58
|
+
|
|
59
|
+
The shapes are told apart by structure rather than by hunting for the
|
|
60
|
+
first `@` before a digit. That hunt was wrong for every v5 key carrying a
|
|
61
|
+
peer suffix, because the suffix contains an `@digit` of its own: it read
|
|
62
|
+
`/react-dom/18.2.0_react@18.2.0` as a package called
|
|
63
|
+
`react-dom/18.2.0_react`, and did the same to `eslint-plugin-react` and
|
|
64
|
+
`@babel/helper-compilation-targets` -- 17 false blocks in one repository,
|
|
65
|
+
on some of the most-installed packages there are.
|
|
66
|
+
|
|
67
|
+
In a v5 key the last slash-separated segment is the version, so it starts
|
|
68
|
+
with a digit. In a scoped v6 key the last segment is `name@version` and
|
|
69
|
+
starts with a letter. That single test separates them.
|
|
70
|
+
"""
|
|
71
|
+
tail = key.rsplit("/", 1)
|
|
72
|
+
if len(tail) == 2 and tail[1][:1].isdigit() and not key.startswith("@" + tail[1]):
|
|
73
|
+
return tail[0] or None
|
|
74
|
+
|
|
75
|
+
match = PNPM_VERSION_AT.search(key)
|
|
76
|
+
if match:
|
|
77
|
+
return key[: match.start()] or None
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
52
81
|
def _is_host_path(key: str) -> bool:
|
|
53
82
|
"""`github.com/acme/forked/abc123` -- a v5 git dependency, keyed by host.
|
|
54
83
|
|
|
@@ -164,22 +193,20 @@ def parse_pnpm_lock(text: str, source: str | None = None) -> list[Requirement]:
|
|
|
164
193
|
key = key.split("(", 1)[0] # v9 peer resolutions
|
|
165
194
|
if key.startswith("/"):
|
|
166
195
|
key = key[1:]
|
|
167
|
-
# The protocol is only a prefix in v5. From v6 it follows `name@`, so
|
|
168
|
-
# testing the start of the key never fired and the parser fell through
|
|
169
|
-
# to its slash split -- emitting names like `github.com/acme/forked`,
|
|
170
|
-
# which were then looked up on npmjs and blocked.
|
|
171
|
-
if key.startswith(PNPM_LOCAL) or "://" in key or _is_host_path(key):
|
|
172
|
-
continue
|
|
173
196
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
# A protocol can sit anywhere in the key, not only at the front. v5
|
|
198
|
+
# writes `/file:packages/ui`, v6 and later write
|
|
199
|
+
# `name@file:packages/ui`, and testing only the start left every
|
|
200
|
+
# modern workspace with local test fixtures being looked up on npmjs:
|
|
201
|
+
# `e2e-test-dep-plain@file:...` in SvelteKit, `@test/...-fake-adapter`
|
|
202
|
+
# in Astro. A `:` cannot appear in a published npm name, so finding
|
|
203
|
+
# one of these anywhere is decisive.
|
|
204
|
+
if any(marker in key for marker in PNPM_LOCAL) or "://" in key:
|
|
205
|
+
continue
|
|
206
|
+
if _is_host_path(key):
|
|
181
207
|
continue
|
|
182
208
|
|
|
209
|
+
name = _pnpm_name(key)
|
|
183
210
|
if name and name not in seen:
|
|
184
211
|
seen.add(name)
|
|
185
212
|
found.append(Requirement(name=name, source=source))
|
|
@@ -50,6 +50,46 @@ class Requirement:
|
|
|
50
50
|
#: README can hold `pip install x` and `npm i y` in adjacent lines, so the
|
|
51
51
|
#: ecosystem cannot always come from the file.
|
|
52
52
|
ecosystem: str | None = None
|
|
53
|
+
#: From a constraints file, reached by `-c` or named `constraints`. Pip
|
|
54
|
+
#: never installs from one -- it only bounds a version if the package
|
|
55
|
+
#: arrives some other way -- so a pin there is a rule, not a request. The
|
|
56
|
+
#: standard way to forbid a package outright is to pin it to a version that
|
|
57
|
+
#: cannot exist, and Home Assistant does exactly that for eight of them:
|
|
58
|
+
#: `pycrypto==1000000000.0.0`. Checking those pins reported a deliberate
|
|
59
|
+
#: exclusion as a version that does not exist.
|
|
60
|
+
constraint: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
#: A bare digest -- MD5, SHA-1, SHA-256. Airflow keeps 127 files under
|
|
64
|
+
#: `dev/breeze/doc/images/` whose entire content is one checksum, and five of
|
|
65
|
+
#: them carry `constraints` or `requirements` in the name. A 32-character hex
|
|
66
|
+
#: string is a perfectly legal PEP 508 name, so it was read as a package and
|
|
67
|
+
#: blocked for not existing.
|
|
68
|
+
DIGEST = re.compile(r"^[0-9a-f]{32}$|^[0-9a-f]{40}$|^[0-9a-f]{64}$", re.I)
|
|
69
|
+
|
|
70
|
+
#: A name that is really a file name. Git materialises a symlink as a plain
|
|
71
|
+
#: file containing its target on a Windows checkout without symlink support, so
|
|
72
|
+
#: Ray's `requirements_compiled_py3.10.txt` held the single line
|
|
73
|
+
#: `requirements_compiled.txt` -- read as a package, and blocked.
|
|
74
|
+
#:
|
|
75
|
+
#: Only the two extensions that requirements files themselves use. A wider list
|
|
76
|
+
#: looked tidier and was wrong: `ruamel.yaml`, `ruamel.yaml.clib` and
|
|
77
|
+
#: `pytest.ini` are all real packages, and rejecting by extension would have
|
|
78
|
+
#: turned a false block into a silent miss on three of them.
|
|
79
|
+
FILE_SUFFIXES = (".txt", ".in")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_not_a_package(name: str, specifier: "str | None") -> bool:
|
|
83
|
+
"""Both shapes are a whole line on its own, with no version after it.
|
|
84
|
+
|
|
85
|
+
That matters: a package really named like a checksum, pinned to a version,
|
|
86
|
+
is still a requirement. The guard is for a line that carries nothing but
|
|
87
|
+
the token -- which is what a checksum file and a materialised symlink both
|
|
88
|
+
look like.
|
|
89
|
+
"""
|
|
90
|
+
if specifier:
|
|
91
|
+
return False
|
|
92
|
+
return bool(DIGEST.match(name)) or name.lower().endswith(FILE_SUFFIXES)
|
|
53
93
|
|
|
54
94
|
|
|
55
95
|
# `name @ https://...` is a direct reference: the source is stated explicitly
|
|
@@ -67,9 +107,12 @@ NPM_LOCAL_PREFIXES = (
|
|
|
67
107
|
"github:", "gitlab:", "bitbucket:", "gist:",
|
|
68
108
|
)
|
|
69
109
|
|
|
70
|
-
#: A version range never starts with a letter
|
|
71
|
-
#:
|
|
110
|
+
#: A version range never starts with a letter -- except for the `v` some
|
|
111
|
+
#: people write before a number. That exception cost two false blocks in
|
|
112
|
+
#: Storybook's lockfile, where `pino-abstract-transport@npm:v1.1.0` was read as
|
|
113
|
+
#: an alias to a package called `v1.1.0`.
|
|
72
114
|
RANGE_START = "^~><=*0123456789 "
|
|
115
|
+
_V_RANGE = re.compile(r"^v\d")
|
|
73
116
|
|
|
74
117
|
#: `"dep": "owner/repo"` / `"owner/repo#semver:^1"` is GitHub shorthand. A
|
|
75
118
|
#: version range never contains a slash, so this does not catch one.
|
|
@@ -142,7 +185,9 @@ def npm_alias_target(spec: str) -> "str | None":
|
|
|
142
185
|
at = rest.rfind("@")
|
|
143
186
|
if at > 0:
|
|
144
187
|
return rest[:at]
|
|
145
|
-
|
|
188
|
+
if rest[0] in RANGE_START or _V_RANGE.match(rest):
|
|
189
|
+
return None
|
|
190
|
+
return rest
|
|
146
191
|
|
|
147
192
|
|
|
148
193
|
def npm_target(name: str, spec: "str | None") -> "str | None":
|
|
@@ -170,6 +215,7 @@ def parse_requirements(
|
|
|
170
215
|
*,
|
|
171
216
|
base: Path | None = None,
|
|
172
217
|
source: str | None = None,
|
|
218
|
+
constraint: bool = False,
|
|
173
219
|
_seen: set[Path] | None = None,
|
|
174
220
|
_depth: int = 0,
|
|
175
221
|
) -> list[Requirement]:
|
|
@@ -196,7 +242,11 @@ def parse_requirements(
|
|
|
196
242
|
if line.startswith("-"):
|
|
197
243
|
include = _include_target(line)
|
|
198
244
|
if include and base is not None and _depth < MAX_INCLUDE_DEPTH:
|
|
199
|
-
|
|
245
|
+
# `-c` names a constraints file; `-r` another requirements one.
|
|
246
|
+
# The distinction survives the include, because everything a
|
|
247
|
+
# constraints file lists is a bound rather than an install.
|
|
248
|
+
nested = constraint or line.lstrip("-").startswith(("c", "-constraint"))
|
|
249
|
+
found.extend(_read_include(include, base, seen, _depth, nested))
|
|
200
250
|
continue
|
|
201
251
|
|
|
202
252
|
# A bare URL or local path, not a name we can look up.
|
|
@@ -207,13 +257,16 @@ def parse_requirements(
|
|
|
207
257
|
continue
|
|
208
258
|
|
|
209
259
|
match = NAME.match(line)
|
|
210
|
-
if match
|
|
260
|
+
if match and not _is_not_a_package(
|
|
261
|
+
match.group(1), _specifier(line[match.end() :])
|
|
262
|
+
):
|
|
211
263
|
found.append(
|
|
212
264
|
Requirement(
|
|
213
265
|
name=match.group(1),
|
|
214
266
|
specifier=_specifier(line[match.end() :]),
|
|
215
267
|
line=number,
|
|
216
268
|
source=source,
|
|
269
|
+
constraint=constraint,
|
|
217
270
|
)
|
|
218
271
|
)
|
|
219
272
|
|
|
@@ -240,7 +293,7 @@ def _include_target(line: str) -> str | None:
|
|
|
240
293
|
|
|
241
294
|
|
|
242
295
|
def _read_include(
|
|
243
|
-
target: str, base: Path, seen: set[Path], depth: int
|
|
296
|
+
target: str, base: Path, seen: set[Path], depth: int, constraint: bool = False
|
|
244
297
|
) -> list[Requirement]:
|
|
245
298
|
if _is_url(target):
|
|
246
299
|
return []
|
|
@@ -256,7 +309,12 @@ def _read_include(
|
|
|
256
309
|
except (OSError, UnicodeDecodeError):
|
|
257
310
|
return []
|
|
258
311
|
return parse_requirements(
|
|
259
|
-
text,
|
|
312
|
+
text,
|
|
313
|
+
base=path.parent,
|
|
314
|
+
source=str(path),
|
|
315
|
+
constraint=constraint or "constraint" in path.name.lower(),
|
|
316
|
+
_seen=seen,
|
|
317
|
+
_depth=depth + 1,
|
|
260
318
|
)
|
|
261
319
|
|
|
262
320
|
|
|
@@ -759,7 +817,15 @@ def load_manifest(path: Path) -> tuple[list[Requirement], str]:
|
|
|
759
817
|
return parse_pyproject(read_source(path), source), "pypi"
|
|
760
818
|
if _looks_like_requirements(name):
|
|
761
819
|
text = read_source(path)
|
|
762
|
-
return
|
|
820
|
+
return (
|
|
821
|
+
parse_requirements(
|
|
822
|
+
text,
|
|
823
|
+
base=path.parent,
|
|
824
|
+
source=source,
|
|
825
|
+
constraint="constraint" in name,
|
|
826
|
+
),
|
|
827
|
+
"pypi",
|
|
828
|
+
)
|
|
763
829
|
|
|
764
830
|
raise UnsupportedManifest(
|
|
765
831
|
f"don't know how to read {path.name!r}. Supported: {SUPPORTED}"
|
|
@@ -93,16 +93,28 @@ _SEGMENT = re.compile(r"(\d+|[a-z]+)")
|
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
def normalise_version(version: str) -> str:
|
|
96
|
-
"""A PyPI version
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
and
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
"""A PyPI version reduced to the release it names.
|
|
97
|
+
|
|
98
|
+
PEP 440 compares release segments by padding the shorter one with zeros, so
|
|
99
|
+
`0.8` and `0.8.0` are one version and `1.6.6` and `1.6.6.0` are another.
|
|
100
|
+
Both spellings sit in unmodified Home Assistant requirements against
|
|
101
|
+
packages that store the other form, and comparing the raw text blocked
|
|
102
|
+
them as versions that do not exist.
|
|
103
|
+
|
|
104
|
+
Deliberately narrow: it folds case, a leading `v`, the `-`/`_` separators,
|
|
105
|
+
leading zeros, trailing zero components of the release, and the
|
|
106
|
+
pre-release spellings. It is not a full PEP 440 implementation and does not
|
|
107
|
+
order versions -- it only decides whether two spellings name one release.
|
|
102
108
|
"""
|
|
103
109
|
text = version.strip().lower().lstrip("v")
|
|
110
|
+
# A local version identifier -- `2.9.0+cu128` -- names a build made
|
|
111
|
+
# somewhere else. PyPI refuses uploads that carry one, so it can never
|
|
112
|
+
# appear in a release list, and comparing with it attached blocked every
|
|
113
|
+
# CUDA-pinned torch line in Ray.
|
|
114
|
+
text = text.split("+", 1)[0]
|
|
104
115
|
for separator in ("-", "_"):
|
|
105
116
|
text = text.replace(separator, ".")
|
|
117
|
+
|
|
106
118
|
parts = []
|
|
107
119
|
for segment in text.split("."):
|
|
108
120
|
for token in _SEGMENT.findall(segment) or [segment]:
|
|
@@ -110,7 +122,18 @@ def normalise_version(version: str) -> str:
|
|
|
110
122
|
parts.append(str(int(token)))
|
|
111
123
|
else:
|
|
112
124
|
parts.append(_PRE_SPELLINGS.get(token, token))
|
|
113
|
-
|
|
125
|
+
parts = [p for p in parts if p]
|
|
126
|
+
|
|
127
|
+
# Trailing zeros are dropped from the release segment only. Everything
|
|
128
|
+
# from the first non-numeric token onwards is a pre/post/dev marker, where
|
|
129
|
+
# a zero is meaningful -- `1.0rc0` is not `1.0rc`.
|
|
130
|
+
release = 0
|
|
131
|
+
while release < len(parts) and parts[release].isdigit():
|
|
132
|
+
release += 1
|
|
133
|
+
head, tail = parts[:release], parts[release:]
|
|
134
|
+
while len(head) > 1 and head[-1] == "0":
|
|
135
|
+
head.pop()
|
|
136
|
+
return ".".join(head + tail)
|
|
114
137
|
|
|
115
138
|
|
|
116
139
|
def _get_json(url: str, timeout: int | None = None) -> dict | None:
|