ghostpkg 0.1.0__tar.gz → 0.3.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 (29) hide show
  1. ghostpkg-0.3.0/CHANGELOG.md +93 -0
  2. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/PKG-INFO +18 -11
  3. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/README.en.md +17 -10
  4. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/README.md +10 -6
  5. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/__init__.py +1 -1
  6. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/assess.py +53 -12
  7. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/cli.py +12 -34
  8. ghostpkg-0.3.0/ghostpkg/data.py +820 -0
  9. ghostpkg-0.3.0/ghostpkg/manifests.py +193 -0
  10. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/pyproject.toml +1 -1
  11. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/tests/test_assess.py +63 -0
  12. ghostpkg-0.3.0/tests/test_manifests.py +187 -0
  13. ghostpkg-0.1.0/CHANGELOG.md +0 -39
  14. ghostpkg-0.1.0/ghostpkg/data.py +0 -399
  15. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  16. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  18. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  19. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.github/workflows/ci.yml +0 -0
  20. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/.gitignore +0 -0
  21. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/CONTRIBUTING.md +0 -0
  22. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/LICENSE +0 -0
  23. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/SECURITY.md +0 -0
  24. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/banner.html +0 -0
  25. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/banner.png +0 -0
  26. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/demo.gif +0 -0
  27. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/assets/make_demo.py +0 -0
  28. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/__main__.py +0 -0
  29. {ghostpkg-0.1.0 → ghostpkg-0.3.0}/ghostpkg/registries.py +0 -0
@@ -0,0 +1,93 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
5
+ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-09-01
10
+
11
+ ### Fixed
12
+ - **npm typo detection did not work at all.** Lookalike names were compared
13
+ against the 2,000 most-downloaded *PyPI* projects regardless of ecosystem, so
14
+ `expresss` was never flagged as a typo of `express` -- `express` was not in
15
+ the list being compared against.
16
+
17
+ ### Added
18
+ - A list of the 2,000 most-downloaded npm packages, built from the registry's
19
+ own download-count API. `nearest_popular()` now picks the list matching the
20
+ ecosystem.
21
+ - Scoped npm names are compared on the part after the slash, since that is what
22
+ a squat targets: `@evil/expresss` is flagged, `@types/node` and `@babel/core`
23
+ are not.
24
+
25
+ ### Changed
26
+ - Typo distance is now Damerau-Levenshtein: swapping two adjacent characters
27
+ counts as one edit rather than two. Transposition is the commonest typosquat
28
+ shape, and under plain Levenshtein `recat`, `lodahs` and `webpakc` all scored
29
+ two edits, which put them outside the budget for names that short. All three
30
+ are caught now, and the false-positive rate across both 2,000-name lists is
31
+ still zero -- there are tests asserting exactly that.
32
+ - Names shorter than five characters are no longer compared at all. Below that
33
+ the name space is too dense for edit distance to mean anything: `core` sits
34
+ one edit from `cors`.
35
+
36
+ ## [0.2.0] - 2026-09-01
37
+
38
+ ### Fixed
39
+ - **`scan` reported nonsense for `pyproject.toml`.** Every file that was not
40
+ `package.json` was handed to the requirements.txt parser, so TOML keys were
41
+ read as package names: `build-backend` came back "does not exist on pypi",
42
+ and `version` came back "ok" because a package of that name exists. For a
43
+ security tool, confidently reporting nonsense is the worst failure mode.
44
+
45
+ ### Added
46
+ - `pyproject.toml` support in `scan`: PEP 621 `project.dependencies` and
47
+ `project.optional-dependencies`, plus Poetry's `tool.poetry` groups.
48
+ `[build-system] requires` is excluded, and Poetry's `python` constraint is
49
+ not treated as a package.
50
+ - Manifests are now detected explicitly. An unrecognised file is **refused with
51
+ an error rather than guessed at** — guessing was the root cause of the bug
52
+ above.
53
+ - A narrow TOML fallback parser for Python 3.9 and 3.10, which have no
54
+ `tomllib`. Adding a TOML dependency would contradict the zero-dependency rule,
55
+ and the fallback is tested by forcing the import to fail rather than by
56
+ trusting the interpreter the suite runs on.
57
+
58
+ ### Changed
59
+ - `--version` now reads `__version__` instead of a hardcoded string.
60
+
61
+ ## [0.1.0] - 2026-09-01
62
+
63
+ First release.
64
+
65
+ ### Added
66
+ - `ghostpkg check` — check one or more package names against PyPI or npm.
67
+ - `ghostpkg scan` — check every dependency in `requirements.txt` or `package.json`.
68
+ - Verdicts: `BLOCK` for names absent from the registry, `WARN` for weak signals
69
+ (recent publication, single release, missing repository link, small edit
70
+ distance to a popular name), `OK` otherwise.
71
+ - `--strict` to promote warnings to blocks, `--json` for machine-readable output,
72
+ `-q/--quiet` to hide passing packages.
73
+ - Exit codes: `0` nothing blocked, `1` something blocked, `2` usage or registry
74
+ error.
75
+ - Bundled list of the 2,000 most-downloaded PyPI projects, used only for typo
76
+ distance.
77
+
78
+ ### Notes on the design
79
+ - The default profile blocks on **non-existence only**. An earlier version scored
80
+ packages and blocked anything suspicious; measured against the live feed of
81
+ newly published PyPI packages, it flagged 100% of legitimate same-day
82
+ publications. Age cannot separate a malicious registration from an honest new
83
+ release, so softer signals are warnings.
84
+ - The typo-distance budget scales with name length. A flat budget flagged `flask`,
85
+ `click` and `black` as typos of one another.
86
+ - Zero runtime dependencies, by policy.
87
+ - No corpus of hallucinated package names is shipped, following the decision of
88
+ the USENIX'25 authors not to publish theirs.
89
+
90
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.3.0...HEAD
91
+ [0.3.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.3.0
92
+ [0.2.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.2.0
93
+ [0.1.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.1.0
3
+ Version: 0.3.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
@@ -59,8 +59,8 @@ Language models invent library names. Attackers register those names in advance.
59
59
  **English** · [Українська](https://github.com/M1rwana12/ghostpkg/blob/main/README.md)
60
60
 
61
61
  [![CI](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
62
- [![PyPI](https://img.shields.io/pypi/v/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
63
- [![Python](https://img.shields.io/pypi/pyversions/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
62
+ [![PyPI](https://img.shields.io/pypi/v/ghostpkg?color=A78BFA&label=pypi)](https://pypi.org/project/ghostpkg/)
63
+ [![Python](https://img.shields.io/pypi/pyversions/ghostpkg?color=A78BFA&label=python)](https://pypi.org/project/ghostpkg/)
64
64
  [![Dependencies](https://img.shields.io/badge/dependencies-0-3FB950.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
65
65
  [![License](https://img.shields.io/badge/license-MIT-8B949E.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/LICENSE)
66
66
 
@@ -158,12 +158,19 @@ ghostpkg check react-router-dom-utils -e npm
158
158
 
159
159
  ```bash
160
160
  ghostpkg scan requirements.txt
161
+ ghostpkg scan pyproject.toml
161
162
  ghostpkg scan package.json
162
163
  ```
163
164
 
164
- `requirements.txt` parsing skips comments, flags, VCS URLs and direct links, and
165
- takes the bare project name off each requirement line. `package.json` reads
166
- `dependencies`, `devDependencies` and `optionalDependencies`.
165
+ | Manifest | What is read |
166
+ |---|---|
167
+ | `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
168
+ | `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
169
+ | `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`. |
170
+
171
+ Anything else is **refused with an error rather than guessed at**. Guessing is
172
+ what made an earlier version read `pyproject.toml` with the requirements parser
173
+ and report TOML keys as package names.
167
174
 
168
175
  ### Options
169
176
 
@@ -210,7 +217,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
210
217
  | First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
211
218
  | Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
212
219
  | No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
213
- | 1–2 characters from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. Age matters: an old lookalike is just a package with a similar name. |
220
+ | 1–2 edits from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age matters: an old lookalike is just a package with a similar name. |
214
221
 
215
222
  Warnings are advisory by default. Nothing but non-existence blocks unless you pass
216
223
  `--strict`.
@@ -346,10 +353,10 @@ earns its keep. Use both.
346
353
  > that stand between you and it, and they are advisory. Improving this is the main
347
354
  > open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
348
355
 
349
- - Typo detection compares against the 2,000 most-downloaded PyPI projects, so a squat
350
- on a less popular package won't be flagged as a lookalike.
351
- - npm scoped packages (`@scope/name`) are checked, but the popular-name list is
352
- PyPI-derived, so npm typosquat detection is weaker.
356
+ - Typo detection compares against the 2,000 most-downloaded projects in each
357
+ ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
358
+ - Names shorter than five characters are not compared at all: below that the name
359
+ space is too dense for edit distance to mean anything.
353
360
  - Every check is a live registry request. There is no caching yet.
354
361
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
355
362
  but it does mean a flaky network fails your build.
@@ -12,8 +12,8 @@ Language models invent library names. Attackers register those names in advance.
12
12
  **English** · [Українська](https://github.com/M1rwana12/ghostpkg/blob/main/README.md)
13
13
 
14
14
  [![CI](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
15
- [![PyPI](https://img.shields.io/pypi/v/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
16
- [![Python](https://img.shields.io/pypi/pyversions/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
15
+ [![PyPI](https://img.shields.io/pypi/v/ghostpkg?color=A78BFA&label=pypi)](https://pypi.org/project/ghostpkg/)
16
+ [![Python](https://img.shields.io/pypi/pyversions/ghostpkg?color=A78BFA&label=python)](https://pypi.org/project/ghostpkg/)
17
17
  [![Dependencies](https://img.shields.io/badge/dependencies-0-3FB950.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/pyproject.toml)
18
18
  [![License](https://img.shields.io/badge/license-MIT-8B949E.svg)](https://github.com/M1rwana12/ghostpkg/blob/main/LICENSE)
19
19
 
@@ -111,12 +111,19 @@ ghostpkg check react-router-dom-utils -e npm
111
111
 
112
112
  ```bash
113
113
  ghostpkg scan requirements.txt
114
+ ghostpkg scan pyproject.toml
114
115
  ghostpkg scan package.json
115
116
  ```
116
117
 
117
- `requirements.txt` parsing skips comments, flags, VCS URLs and direct links, and
118
- takes the bare project name off each requirement line. `package.json` reads
119
- `dependencies`, `devDependencies` and `optionalDependencies`.
118
+ | Manifest | What is read |
119
+ |---|---|
120
+ | `requirements*.txt` | Every requirement line. Comments, flags, VCS URLs and direct links are skipped. |
121
+ | `pyproject.toml` | PEP 621 `project.dependencies` and `project.optional-dependencies`, plus Poetry's `tool.poetry` groups. `[build-system] requires` is not included, and the `python` constraint is not treated as a package. |
122
+ | `package.json` | `dependencies`, `devDependencies`, `optionalDependencies`. |
123
+
124
+ Anything else is **refused with an error rather than guessed at**. Guessing is
125
+ what made an earlier version read `pyproject.toml` with the requirements parser
126
+ and report TOML keys as package names.
120
127
 
121
128
  ### Options
122
129
 
@@ -163,7 +170,7 @@ $ ghostpkg check somepkgthatisnotreal9911 --json
163
170
  | First published < 1 year ago | 🟡 Warning | Weaker version of the same signal. |
164
171
  | Only one release | 🟡 Warning | Squats are usually published once and abandoned. |
165
172
  | No repository or homepage link | 🟡 Warning | Real projects almost always link to source. |
166
- | 1–2 characters from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. Age matters: an old lookalike is just a package with a similar name. |
173
+ | 1–2 edits from a popular name, **and** recently published | 🟡 Warning | Classic typosquat shape. A swap of adjacent characters counts as one edit, because `recat`/`react` is what squatters actually publish. Age matters: an old lookalike is just a package with a similar name. |
167
174
 
168
175
  Warnings are advisory by default. Nothing but non-existence blocks unless you pass
169
176
  `--strict`.
@@ -299,10 +306,10 @@ earns its keep. Use both.
299
306
  > that stand between you and it, and they are advisory. Improving this is the main
300
307
  > open problem — see [issues](https://github.com/M1rwana12/ghostpkg/issues).
301
308
 
302
- - Typo detection compares against the 2,000 most-downloaded PyPI projects, so a squat
303
- on a less popular package won't be flagged as a lookalike.
304
- - npm scoped packages (`@scope/name`) are checked, but the popular-name list is
305
- PyPI-derived, so npm typosquat detection is weaker.
309
+ - Typo detection compares against the 2,000 most-downloaded projects in each
310
+ ecosystem, so a squat on a less popular package won't be flagged as a lookalike.
311
+ - Names shorter than five characters are not compared at all: below that the name
312
+ space is too dense for edit distance to mean anything.
306
313
  - Every check is a live registry request. There is no caching yet.
307
314
  - Registry outages surface as exit code `2` rather than a silent pass — deliberately,
308
315
  but it does mean a flaky network fails your build.
@@ -12,8 +12,8 @@
12
12
  [English](README.en.md) · **Українська**
13
13
 
14
14
  [![CI](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml/badge.svg)](https://github.com/M1rwana12/ghostpkg/actions/workflows/ci.yml)
15
- [![PyPI](https://img.shields.io/pypi/v/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
16
- [![Python](https://img.shields.io/pypi/pyversions/ghostpkg.svg?color=A78BFA)](https://pypi.org/project/ghostpkg/)
15
+ [![PyPI](https://img.shields.io/pypi/v/ghostpkg?color=A78BFA&label=pypi)](https://pypi.org/project/ghostpkg/)
16
+ [![Python](https://img.shields.io/pypi/pyversions/ghostpkg?color=A78BFA&label=python)](https://pypi.org/project/ghostpkg/)
17
17
  [![Залежності](https://img.shields.io/badge/%D0%B7%D0%B0%D0%BB%D0%B5%D0%B6%D0%BD%D0%BE%D1%81%D1%82%D0%B5%D0%B9-0-3FB950.svg)](pyproject.toml)
18
18
  [![Ліцензія](https://img.shields.io/badge/%D0%BB%D1%96%D1%86%D0%B5%D0%BD%D0%B7%D1%96%D1%8F-MIT-8B949E.svg)](LICENSE)
19
19
 
@@ -81,6 +81,7 @@ ghostpkg check react-router-dom-utils -e npm
81
81
 
82
82
  # перевірити всі залежності з маніфесту
83
83
  ghostpkg scan requirements.txt
84
+ ghostpkg scan pyproject.toml
84
85
  ghostpkg scan package.json
85
86
 
86
87
  # машинозчитуваний вивід
@@ -94,6 +95,9 @@ ghostpkg check somepkg --json
94
95
  | `--json` | Вивід у JSON для скриптів і CI |
95
96
  | `-q`, `--quiet` | Ховає пакети, які пройшли перевірку |
96
97
 
98
+ `scan` розпізнає `requirements*.txt`, `pyproject.toml` (PEP 621 і Poetry)
99
+ та `package.json`. Невідомий формат він **відхиляє з помилкою, а не вгадує**.
100
+
97
101
  ---
98
102
 
99
103
  ## Що саме перевіряється
@@ -159,10 +163,10 @@ $ ghostpkg check react-router-dom-utils -e npm
159
163
  > попередження, і вони дорадчі. Це головна відкрита проблема —
160
164
  > див. [issues](https://github.com/M1rwana12/ghostpkg/issues).
161
165
 
162
- - Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів PyPI, тому підробка
163
- під менш популярний пакет як схожа назва не позначиться.
164
- - Пакети npm з областю (`@scope/name`) перевіряються, але список популярних назв
165
- побудований на PyPI — тож для npm виявлення опечаток слабше.
166
+ - Виявлення опечаток порівнює з 2 000 найпопулярніших проєктів кожної екосистеми,
167
+ тому підробка під менш популярний пакет як схожа назва не позначиться.
168
+ - Імена коротші за 5 символів не порівнюються: там простір назв надто щільний,
169
+ щоб відстань редагування щось означала.
166
170
  - Кожна перевірка — це живий запит до реєстру. Кешування ще немає.
167
171
 
168
172
  ---
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
  from dataclasses import dataclass, field
19
19
  from enum import Enum
20
20
 
21
- from .data import TOP_PYPI
21
+ from .data import TOP_NPM, TOP_PYPI
22
22
  from .registries import PackageFacts
23
23
 
24
24
  YOUNG_DAYS = 90
@@ -45,25 +45,44 @@ class Finding:
45
45
 
46
46
 
47
47
  def edit_distance(left: str, right: str, cutoff: int = 3) -> int:
48
- """Levenshtein distance, abandoning early once it exceeds `cutoff`."""
48
+ """Damerau-Levenshtein distance, abandoning early once it exceeds `cutoff`.
49
+
50
+ Swapping two adjacent characters counts as one edit, not two. That matters
51
+ here more than it looks: transposition is the commonest typosquat shape --
52
+ `recat` for `react`, `lodahs` for `lodash`, `webpakc` for `webpack`. Plain
53
+ Levenshtein scores all three as two edits, which put them outside the budget
54
+ for names of that length and let every one of them through.
55
+ """
49
56
  if left == right:
50
57
  return 0
51
58
  if abs(len(left) - len(right)) > cutoff:
52
59
  return cutoff + 1
53
60
 
61
+ before_previous: list[int] = []
54
62
  previous = list(range(len(right) + 1))
55
63
  for i, a in enumerate(left, 1):
56
64
  current = [i]
57
65
  for j, b in enumerate(right, 1):
58
- current.append(
59
- min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a != b))
60
- )
66
+ cost = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a != b))
67
+ if (
68
+ i > 1
69
+ and j > 1
70
+ and a == right[j - 2]
71
+ and left[i - 2] == b
72
+ ):
73
+ cost = min(cost, before_previous[j - 2] + 1)
74
+ current.append(cost)
61
75
  if min(current) > cutoff:
62
76
  return cutoff + 1
63
- previous = current
77
+ before_previous, previous = previous, current
64
78
  return previous[-1]
65
79
 
66
80
 
81
+ POPULAR: dict[str, frozenset[str]] = {"pypi": TOP_PYPI, "npm": TOP_NPM}
82
+
83
+ MIN_COMPARABLE_LENGTH = 5
84
+
85
+
67
86
  def _typo_budget(name: str) -> int:
68
87
  """How many edits still count as a plausible typo of a popular name.
69
88
 
@@ -74,18 +93,40 @@ def _typo_budget(name: str) -> int:
74
93
  return 2 if len(name) >= 10 else 1
75
94
 
76
95
 
77
- def nearest_popular(name: str, popular: frozenset[str] = TOP_PYPI) -> tuple[str, int] | None:
96
+ def _comparable(name: str, ecosystem: str) -> str:
97
+ """The part of a name worth comparing.
98
+
99
+ An npm squat on a scoped package targets the part after the slash, since
100
+ the scope is usually owned by whoever it names.
101
+ """
102
+ lowered = name.lower()
103
+ if ecosystem == "npm" and lowered.startswith("@") and "/" in lowered:
104
+ return lowered.rsplit("/", 1)[1]
105
+ return lowered
106
+
107
+
108
+ def nearest_popular(name: str, ecosystem: str = "pypi") -> tuple[str, int] | None:
78
109
  """Closest popular package name within the typo budget, if any."""
110
+ popular = POPULAR.get(ecosystem)
111
+ if not popular:
112
+ return None
113
+
79
114
  lowered = name.lower()
80
- if lowered in popular:
115
+ target = _comparable(name, ecosystem)
116
+
117
+ if lowered in popular or target in popular:
118
+ return None
119
+ # Below this length the name space is too dense for edit distance to mean
120
+ # anything: 'core' sits one edit from 'cors'.
121
+ if len(target) < MIN_COMPARABLE_LENGTH:
81
122
  return None
82
123
 
83
- budget = _typo_budget(lowered)
124
+ budget = _typo_budget(target)
84
125
  best: tuple[str, int] | None = None
85
126
  for candidate in popular:
86
- if abs(len(candidate) - len(lowered)) > budget:
127
+ if abs(len(candidate) - len(target)) > budget:
87
128
  continue
88
- distance = edit_distance(lowered, candidate, cutoff=budget)
129
+ distance = edit_distance(target, candidate, cutoff=budget)
89
130
  if 0 < distance <= budget and (best is None or distance < best[1]):
90
131
  best = (candidate, distance)
91
132
  if distance == 1:
@@ -114,7 +155,7 @@ def assess(facts: PackageFacts, strict: bool = False) -> Finding:
114
155
  is_young = facts.age_days is not None and facts.age_days < NEW_DAYS
115
156
 
116
157
  if is_young:
117
- neighbour = nearest_popular(facts.name)
158
+ neighbour = nearest_popular(facts.name, facts.ecosystem)
118
159
  if neighbour is not None:
119
160
  popular_name, distance = neighbour
120
161
  reasons.append(
@@ -6,11 +6,12 @@ import argparse
6
6
  import concurrent.futures
7
7
  import json
8
8
  import os
9
- import re
10
9
  import sys
11
10
  from pathlib import Path
12
11
 
12
+ from . import __version__
13
13
  from .assess import Finding, Verdict, assess
14
+ from .manifests import UnsupportedManifest, load_manifest
14
15
  from .registries import RegistryError, fetch
15
16
 
16
17
  EXIT_OK = 0
@@ -96,42 +97,14 @@ def evaluate(names: list[str], ecosystem: str, strict: bool) -> list[Finding]:
96
97
  return list(pool.map(one, names))
97
98
 
98
99
 
99
- REQUIREMENT_LINE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
100
-
101
-
102
- def parse_requirements(text: str) -> list[str]:
103
- names = []
104
- for raw in text.splitlines():
105
- line = raw.strip()
106
- if not line or line.startswith(("#", "-", "git+", "http")):
107
- continue
108
- match = REQUIREMENT_LINE.match(line)
109
- if match:
110
- names.append(match.group(1))
111
- return names
112
-
113
-
114
- def parse_package_json(text: str) -> list[str]:
115
- data = json.loads(text)
116
- names: list[str] = []
117
- for key in ("dependencies", "devDependencies", "optionalDependencies"):
118
- names.extend((data.get(key) or {}).keys())
119
- return names
120
-
121
-
122
- def load_manifest(path: Path) -> tuple[list[str], str]:
123
- text = path.read_text(encoding="utf-8")
124
- if path.name == "package.json":
125
- return parse_package_json(text), "npm"
126
- return parse_requirements(text), "pypi"
127
-
128
-
129
100
  def build_parser() -> argparse.ArgumentParser:
130
101
  parser = argparse.ArgumentParser(
131
102
  prog="ghostpkg",
132
103
  description="Catch package names that do not exist before you install them.",
133
104
  )
134
- parser.add_argument("--version", action="version", version="ghostpkg 0.1.0")
105
+ parser.add_argument(
106
+ "--version", action="version", version=f"ghostpkg {__version__}"
107
+ )
135
108
  sub = parser.add_subparsers(dest="command", required=True)
136
109
 
137
110
  check = sub.add_parser("check", help="check one or more package names")
@@ -139,7 +112,9 @@ def build_parser() -> argparse.ArgumentParser:
139
112
  check.add_argument("-e", "--ecosystem", choices=("pypi", "npm"), default="pypi")
140
113
 
141
114
  scan = sub.add_parser("scan", help="check every dependency in a manifest")
142
- scan.add_argument("path", type=Path, help="requirements.txt or package.json")
115
+ scan.add_argument(
116
+ "path", type=Path, help="requirements*.txt, pyproject.toml or package.json"
117
+ )
143
118
 
144
119
  for command in (check, scan):
145
120
  command.add_argument(
@@ -162,7 +137,10 @@ def main(argv: list[str] | None = None) -> int:
162
137
  return EXIT_ERROR
163
138
  try:
164
139
  names, ecosystem = load_manifest(args.path)
165
- except (json.JSONDecodeError, UnicodeDecodeError) as exc:
140
+ except UnsupportedManifest as exc:
141
+ print(f"ghostpkg: {exc}", file=sys.stderr)
142
+ return EXIT_ERROR
143
+ except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
166
144
  print(f"ghostpkg: could not parse {args.path}: {exc}", file=sys.stderr)
167
145
  return EXIT_ERROR
168
146
  if not names: