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.
Files changed (60) hide show
  1. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.gitignore +3 -0
  2. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/CHANGELOG.md +95 -1
  3. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/PKG-INFO +18 -11
  4. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/README.en.md +17 -10
  5. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/README.md +15 -8
  6. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/__init__.py +1 -1
  7. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/cli.py +11 -1
  8. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/jslocks.py +40 -13
  9. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/manifests.py +74 -8
  10. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/registries.py +30 -7
  11. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/scanner.py +20 -2
  12. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/pyproject.toml +1 -1
  13. ghostpkg-0.22.0/scripts/fieldtest.py +151 -0
  14. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_field_findings.py +81 -1
  15. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_jslocks.py +51 -3
  16. ghostpkg-0.22.0/tests/test_not_a_package.py +124 -0
  17. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  18. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  20. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  21. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/ghostpkg-ignore.json +0 -0
  22. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/workflows/ci.yml +0 -0
  23. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.github/workflows/publish.yml +0 -0
  24. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/.pre-commit-hooks.yaml +0 -0
  25. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/2.0 +0 -0
  26. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/CONTRIBUTING.md +0 -0
  27. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/LICENSE +0 -0
  28. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/SECURITY.md +0 -0
  29. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/action.yml +0 -0
  30. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/banner.html +0 -0
  31. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/banner.png +0 -0
  32. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/demo.gif +0 -0
  33. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/assets/make_demo.py +0 -0
  34. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/__main__.py +0 -0
  35. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/assess.py +0 -0
  36. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/cache.py +0 -0
  37. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/data.py +0 -0
  38. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/discover.py +0 -0
  39. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/inspection.py +0 -0
  40. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/policy.py +0 -0
  41. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/prose.py +0 -0
  42. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/report.py +0 -0
  43. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/ghostpkg/rules.py +0 -0
  44. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_action.py +0 -0
  45. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_assess.py +0 -0
  46. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_cache.py +0 -0
  47. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_discover.py +0 -0
  48. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_hostile_input.py +0 -0
  49. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_inspection.py +0 -0
  50. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_lockfiles.py +0 -0
  51. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_manifests.py +0 -0
  52. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_policy.py +0 -0
  53. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_prose.py +0 -0
  54. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_registries.py +0 -0
  55. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_regressions.py +0 -0
  56. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_report.py +0 -0
  57. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_review_findings.py +0 -0
  58. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_stated_sources.py +0 -0
  59. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_versions.py +0 -0
  60. {ghostpkg-0.20.0 → ghostpkg-0.22.0}/tests/test_withdrawn.py +0 -0
@@ -67,3 +67,6 @@ PLAN.md
67
67
 
68
68
  # Banner art-direction options (working files, not shipped)
69
69
  banner-options/
70
+
71
+ # Agent scratch worktrees
72
+ .claude/
@@ -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.20.0...HEAD
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.20.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 thirteen real READMEs, it finds the genuine
187
- names with a **0% false-positive rate**; `pip install -r requirements.txt`,
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 176 days ago
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.19.0
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`, `python-version`,
455
- `fail-on-error`. Pin `version` if you want the run to be reproducible.
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.19.0
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 thirteen real READMEs, it finds the genuine
139
- names with a **0% false-positive rate**; `pip install -r requirements.txt`,
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 176 days ago
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.19.0
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`, `python-version`,
407
- `fail-on-error`. Pin `version` if you want the run to be reproducible.
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.19.0
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`, будь-який `.md`.
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.19.0
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`, `python-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.19.0
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
  ---
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.20.0"
3
+ __version__ = "0.22.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -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
- match = PNPM_VERSION_AT.search(key)
175
- if match:
176
- name = key[: match.start()]
177
- elif "/" in key.lstrip("@"):
178
- # v5 keys the version with a slash: @babel/code-frame/7.12.11
179
- name = key.rsplit("/", 1)[0]
180
- else:
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, which is how `npm:lodash` is
71
- #: told apart from `npm:^4.17.19`.
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
- return None if rest[0] in RANGE_START else rest
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
- found.extend(_read_include(include, base, seen, _depth))
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, base=path.parent, source=str(path), _seen=seen, _depth=depth + 1
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 parse_requirements(text, base=path.parent, source=source), "pypi"
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 in the form PyPI itself stores.
97
-
98
- Deliberately narrow: it folds case, a leading `v`, the `-`/`_` separators
99
- and leading zeros in numeric segments, and the pre-release spellings. It is
100
- not a full PEP 440 implementation and does not order versions -- it only
101
- has to decide whether two spellings name the same release.
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
- return ".".join(p for p in parts if p)
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: