ghostpkg 0.22.0__tar.gz → 0.23.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.22.0 → ghostpkg-0.23.0}/CHANGELOG.md +40 -1
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/PKG-INFO +3 -3
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/README.en.md +2 -2
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/README.md +2 -2
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/__init__.py +1 -1
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/prose.py +41 -13
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/pyproject.toml +1 -1
- ghostpkg-0.23.0/tests/test_mutation_gaps.py +266 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_prose.py +56 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ghostpkg-ignore.json +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/workflows/ci.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/workflows/publish.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.gitignore +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.pre-commit-hooks.yaml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/2.0 +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/CONTRIBUTING.md +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/LICENSE +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/SECURITY.md +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/action.yml +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/banner.html +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/banner.png +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/demo.gif +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/make_demo.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/__main__.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/assess.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/cache.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/cli.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/data.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/discover.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/inspection.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/jslocks.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/manifests.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/policy.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/registries.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/report.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/rules.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/scanner.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/scripts/fieldtest.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_action.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_assess.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_cache.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_discover.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_field_findings.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_hostile_input.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_inspection.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_jslocks.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_lockfiles.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_manifests.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_not_a_package.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_policy.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_registries.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_regressions.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_report.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_review_findings.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_stated_sources.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_versions.py +0 -0
- {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_withdrawn.py +0 -0
|
@@ -6,6 +6,44 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.23.0] - 2026-09-04
|
|
10
|
+
|
|
11
|
+
Mutation testing: 186 single-line changes to the source, each run against the
|
|
12
|
+
whole suite. **53 left it green.** The code was right in every case -- what was
|
|
13
|
+
missing was anything that would notice if it stopped being. Writing the tests to
|
|
14
|
+
kill the survivors turned up a real bug the suite had never had a chance to see.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **A version in an install command made prose extraction skip the whole
|
|
18
|
+
command.** `npm install react@18` and `pip install flask==3.0` both yielded
|
|
19
|
+
nothing: the `@` was read as a direct reference and the `==` stopped the token
|
|
20
|
+
matching the name pattern. A pinned install command is the commonest form
|
|
21
|
+
there is, so the feature built to read install commands was silently skipping
|
|
22
|
+
most of them.
|
|
23
|
+
|
|
24
|
+
The version is now split off and carried through, so a hallucinated *version*
|
|
25
|
+
in a README is checked as well as a hallucinated name. Re-measured on 22 real
|
|
26
|
+
READMEs, because widening this parser has cost false positives before: 19
|
|
27
|
+
names, **none that fail to exist**.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
Tests for the survivors that carry a real consequence:
|
|
31
|
+
- **Each finding keeps its own file and line.** Reversing the thread pool's
|
|
32
|
+
result list left all 656 tests passing -- the printed names still looked
|
|
33
|
+
right, because a finding carries its own name, but the origin stamped on it
|
|
34
|
+
came from a different package. A CI annotation would have pointed at the
|
|
35
|
+
wrong line and marked the wrong one clean.
|
|
36
|
+
- **`--deep` and `--strict` reach the scan.** Both could be forced to `False`
|
|
37
|
+
in the call without a single failure: the flag would silently do nothing.
|
|
38
|
+
- **A name from prose keeps its own ecosystem**, so `npm install left-pad` in a
|
|
39
|
+
README is not looked up on PyPI.
|
|
40
|
+
- Chained commands (`cd app && npm install x`), unknown installers
|
|
41
|
+
(`brew install jq`), age measured from the first upload rather than the
|
|
42
|
+
latest, `cache.put` refusing a negative, `-r base.txt # comment` still
|
|
43
|
+
following the include, and the error counts in both output formats.
|
|
44
|
+
|
|
45
|
+
693 tests. Field gate: 4,461 packages, zero blocks.
|
|
46
|
+
|
|
9
47
|
## [0.22.0] - 2026-09-04
|
|
10
48
|
|
|
11
49
|
Four agents scanned **21 repositories and 78,000 package names**. They found
|
|
@@ -902,7 +940,8 @@ First release.
|
|
|
902
940
|
- No corpus of hallucinated package names is shipped, following the decision of
|
|
903
941
|
the USENIX'25 authors not to publish theirs.
|
|
904
942
|
|
|
905
|
-
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.
|
|
943
|
+
[Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.23.0...HEAD
|
|
944
|
+
[0.23.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.23.0
|
|
906
945
|
[0.22.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.22.0
|
|
907
946
|
[0.21.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.21.0
|
|
908
947
|
[0.20.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.20.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostpkg
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.23.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
|
|
@@ -441,7 +441,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
441
441
|
### In CI
|
|
442
442
|
|
|
443
443
|
```yaml
|
|
444
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
444
|
+
- uses: M1rwana12/ghostpkg@v0.23.0
|
|
445
445
|
```
|
|
446
446
|
|
|
447
447
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -472,7 +472,7 @@ Or without the action, if you prefer:
|
|
|
472
472
|
```yaml
|
|
473
473
|
repos:
|
|
474
474
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
475
|
-
rev: v0.
|
|
475
|
+
rev: v0.23.0
|
|
476
476
|
hooks:
|
|
477
477
|
- id: ghostpkg
|
|
478
478
|
```
|
|
@@ -393,7 +393,7 @@ length, and only applies to packages young enough to plausibly be a squat.
|
|
|
393
393
|
### In CI
|
|
394
394
|
|
|
395
395
|
```yaml
|
|
396
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
396
|
+
- uses: M1rwana12/ghostpkg@v0.23.0
|
|
397
397
|
```
|
|
398
398
|
|
|
399
399
|
That is the whole step. It searches the checkout, skips `node_modules` and
|
|
@@ -424,7 +424,7 @@ Or without the action, if you prefer:
|
|
|
424
424
|
```yaml
|
|
425
425
|
repos:
|
|
426
426
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
427
|
-
rev: v0.
|
|
427
|
+
rev: v0.23.0
|
|
428
428
|
hooks:
|
|
429
429
|
- id: ghostpkg
|
|
430
430
|
```
|
|
@@ -190,7 +190,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
190
190
|
### У CI
|
|
191
191
|
|
|
192
192
|
```yaml
|
|
193
|
-
- uses: M1rwana12/ghostpkg@v0.
|
|
193
|
+
- uses: M1rwana12/ghostpkg@v0.23.0
|
|
194
194
|
```
|
|
195
195
|
|
|
196
196
|
Це весь крок. Він шукає маніфести в чекауті, обходить `node_modules` і подібні
|
|
@@ -214,7 +214,7 @@ ghostpkg scan ../інший-проєкт
|
|
|
214
214
|
```yaml
|
|
215
215
|
repos:
|
|
216
216
|
- repo: https://github.com/M1rwana12/ghostpkg
|
|
217
|
-
rev: v0.
|
|
217
|
+
rev: v0.23.0
|
|
218
218
|
hooks:
|
|
219
219
|
- id: ghostpkg
|
|
220
220
|
```
|
|
@@ -73,11 +73,19 @@ def looks_like_prose(filename: str) -> bool:
|
|
|
73
73
|
SENTENCE_END = ".,;:!?)"
|
|
74
74
|
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
#: Where a version starts in an install argument. npm writes `react@18`, pip
|
|
77
|
+
#: writes `flask==3.0`, and both were dropped entirely: the `@` was read as a
|
|
78
|
+
#: direct reference and the `==` made the token stop matching the name pattern.
|
|
79
|
+
#: A pinned install command is the commonest form there is, so prose extraction
|
|
80
|
+
#: was silently skipping most of what it existed to read.
|
|
81
|
+
PIN = re.compile(r"(===|==|>=|<=|~=|!=|[<>@])")
|
|
78
82
|
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
|
|
84
|
+
def _clean(token: str) -> "tuple[str | None, str | None, bool]":
|
|
85
|
+
"""(package name, version, whether the command ended here).
|
|
86
|
+
|
|
87
|
+
The third value matters more than it looks. Measured against ten real
|
|
88
|
+
READMEs, letting a command run past the end of its sentence produced a
|
|
81
89
|
**25% false-positive rate** -- `The`, `command`, `line`, `client`, `is` all
|
|
82
90
|
came back as package names from one httpx line.
|
|
83
91
|
"""
|
|
@@ -87,22 +95,41 @@ def _clean(token: str) -> "tuple[str | None, bool]":
|
|
|
87
95
|
token = token[:-1]
|
|
88
96
|
stop = True
|
|
89
97
|
if not token or token.startswith("-"):
|
|
90
|
-
return None, stop
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return None, stop
|
|
98
|
+
return None, None, stop
|
|
99
|
+
if "://" in token or token.startswith((".", "/", "~")):
|
|
100
|
+
return None, None, stop
|
|
94
101
|
if token.endswith((".whl", ".tar.gz", ".tgz", ".zip", ".txt", ".toml", ".json")):
|
|
95
|
-
return None, stop
|
|
102
|
+
return None, None, stop
|
|
103
|
+
|
|
104
|
+
# Split the version off. A scoped npm name starts with `@`, so the search
|
|
105
|
+
# begins at the second character.
|
|
106
|
+
specifier = None
|
|
107
|
+
match = PIN.search(token, 1)
|
|
108
|
+
if match:
|
|
109
|
+
rest = token[match.start() :]
|
|
110
|
+
token, specifier = token[: match.start()], token[match.end() :].strip() or None
|
|
111
|
+
# `pkg@https://...`, `pkg@file:../x`, `pkg@owner/repo` state their own
|
|
112
|
+
# source, and the registry has nothing to say about them.
|
|
113
|
+
if rest.startswith("@") and (
|
|
114
|
+
specifier is None
|
|
115
|
+
or "://" in specifier
|
|
116
|
+
or ":" in specifier
|
|
117
|
+
or "/" in specifier
|
|
118
|
+
):
|
|
119
|
+
return None, None, stop
|
|
120
|
+
|
|
96
121
|
# `fastapi[standard]` names fastapi; the extras are not part of it.
|
|
97
122
|
token = re.sub(r"\[[^\]]*\]$", "", token)
|
|
98
123
|
if not token:
|
|
99
|
-
return None, stop
|
|
124
|
+
return None, None, stop
|
|
100
125
|
match = NAME.match(token)
|
|
101
126
|
if not match or match.group(1) != token:
|
|
102
|
-
return None, stop
|
|
127
|
+
return None, None, stop
|
|
103
128
|
name = match.group(1)
|
|
104
129
|
# `npm install` with no argument, or a lone dot, installs the local project.
|
|
105
|
-
|
|
130
|
+
if name in (".", ".."):
|
|
131
|
+
return None, None, stop
|
|
132
|
+
return name, specifier, stop
|
|
106
133
|
|
|
107
134
|
|
|
108
135
|
def _split_commands(line: str) -> list[str]:
|
|
@@ -191,7 +218,7 @@ def extract(text: str, source: str | None = None) -> list[Requirement]:
|
|
|
191
218
|
# An ordinary flag like --upgrade. Not a name, but not the
|
|
192
219
|
# end of the command either.
|
|
193
220
|
continue
|
|
194
|
-
name, ends_here = _clean(token)
|
|
221
|
+
name, specifier, ends_here = _clean(token)
|
|
195
222
|
if name is None:
|
|
196
223
|
# Prose has begun. Anything further on this line is English.
|
|
197
224
|
break
|
|
@@ -200,6 +227,7 @@ def extract(text: str, source: str | None = None) -> list[Requirement]:
|
|
|
200
227
|
found.append(
|
|
201
228
|
Requirement(
|
|
202
229
|
name=name,
|
|
230
|
+
specifier=specifier,
|
|
203
231
|
line=number,
|
|
204
232
|
source=source,
|
|
205
233
|
ecosystem=ecosystem,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostpkg"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.23.0"
|
|
8
8
|
description = "Catch package names that do not exist before you install them"
|
|
9
9
|
readme = "README.en.md" # English page for PyPI; the repo main page is Ukrainian
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Behaviour the suite was not defending.
|
|
2
|
+
|
|
3
|
+
Found by mutation testing: 186 single-line changes to the source, each run
|
|
4
|
+
against the whole suite. 53 left it green. These tests kill the survivors that
|
|
5
|
+
carry a real consequence — a wrong verdict, a dropped package, a flag that
|
|
6
|
+
silently does nothing.
|
|
7
|
+
|
|
8
|
+
The code was correct in every case. What was missing was anything that would
|
|
9
|
+
notice if it stopped being.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from ghostpkg.assess import Verdict
|
|
19
|
+
from ghostpkg.cli import main
|
|
20
|
+
from ghostpkg.manifests import parse_requirements
|
|
21
|
+
from ghostpkg.prose import extract
|
|
22
|
+
from ghostpkg.registries import PackageFacts
|
|
23
|
+
from ghostpkg.scanner import evaluate
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def facts(name, ecosystem="pypi", **kwargs):
|
|
27
|
+
base = dict(
|
|
28
|
+
name=name, ecosystem=ecosystem, exists=True,
|
|
29
|
+
age_days=4000, release_count=30, has_repo_url=True,
|
|
30
|
+
)
|
|
31
|
+
base.update(kwargs)
|
|
32
|
+
return PackageFacts(**base)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestEachFindingKeepsItsOwnOrigin:
|
|
36
|
+
"""The thread pool returns results in submission order and they are zipped
|
|
37
|
+
back onto the names. Reversing that list left all 656 tests passing: the
|
|
38
|
+
printed names still looked right, because a finding carries its own name —
|
|
39
|
+
but the file and line stamped onto it came from a different package. A CI
|
|
40
|
+
annotation would point at the wrong line and mark the wrong one clean."""
|
|
41
|
+
|
|
42
|
+
def test_the_finding_named_x_carries_the_line_x_was_written_on(self, monkeypatch):
|
|
43
|
+
from ghostpkg.manifests import Requirement
|
|
44
|
+
|
|
45
|
+
monkeypatch.setattr(
|
|
46
|
+
"ghostpkg.scanner.fetch",
|
|
47
|
+
lambda name, ecosystem: facts(name, ecosystem, exists=name != "ghost"),
|
|
48
|
+
)
|
|
49
|
+
findings = evaluate(
|
|
50
|
+
[
|
|
51
|
+
Requirement(name="flask", source="requirements.txt", line=1),
|
|
52
|
+
Requirement(name="ghost", source="requirements.txt", line=2),
|
|
53
|
+
Requirement(name="requests", source="other.txt", line=7),
|
|
54
|
+
],
|
|
55
|
+
"pypi",
|
|
56
|
+
cache=None,
|
|
57
|
+
)
|
|
58
|
+
placed = {f.name: (f.source, f.line) for f in findings}
|
|
59
|
+
assert placed["flask"] == ("requirements.txt", 1)
|
|
60
|
+
assert placed["ghost"] == ("requirements.txt", 2)
|
|
61
|
+
assert placed["requests"] == ("other.txt", 7)
|
|
62
|
+
|
|
63
|
+
def test_the_verdict_belongs_to_the_name_it_is_printed_against(self, monkeypatch):
|
|
64
|
+
monkeypatch.setattr(
|
|
65
|
+
"ghostpkg.scanner.fetch",
|
|
66
|
+
lambda name, ecosystem: facts(name, ecosystem, exists="ghost" not in name),
|
|
67
|
+
)
|
|
68
|
+
findings = evaluate(["a-real-one", "ghost-one", "b-real-one", "ghost-two"], "pypi", cache=None)
|
|
69
|
+
for finding in findings:
|
|
70
|
+
expected = Verdict.BLOCK if "ghost" in finding.name else Verdict.OK
|
|
71
|
+
assert finding.verdict is expected, finding.name
|
|
72
|
+
|
|
73
|
+
def test_order_is_preserved(self, monkeypatch):
|
|
74
|
+
monkeypatch.setattr("ghostpkg.scanner.fetch", lambda name, ecosystem: facts(name, ecosystem))
|
|
75
|
+
wanted = ["one", "two", "three", "four", "five"]
|
|
76
|
+
assert [f.name for f in evaluate(wanted, "pypi", cache=None)] == wanted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestTheFlagsReachTheScan:
|
|
80
|
+
"""Every flag was tested at the function it configures and none end to end
|
|
81
|
+
from `main()`. Setting `args.deep` or `args.strict` to False in the call
|
|
82
|
+
left the suite green: the flag would silently do nothing."""
|
|
83
|
+
|
|
84
|
+
def test_deep_reaches_the_scanner(self, tmp_path, monkeypatch):
|
|
85
|
+
seen = {}
|
|
86
|
+
|
|
87
|
+
def fake_evaluate(requirements, ecosystem, strict=False, cache=None, deep=False, workers=8):
|
|
88
|
+
seen["deep"] = deep
|
|
89
|
+
seen["strict"] = strict
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr("ghostpkg.cli.evaluate", fake_evaluate)
|
|
93
|
+
path = tmp_path / "requirements.txt"
|
|
94
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
95
|
+
main(["scan", str(path), "--deep", "--no-cache"])
|
|
96
|
+
assert seen["deep"] is True
|
|
97
|
+
|
|
98
|
+
def test_strict_reaches_the_scanner(self, tmp_path, monkeypatch):
|
|
99
|
+
seen = {}
|
|
100
|
+
|
|
101
|
+
def fake_evaluate(requirements, ecosystem, strict=False, cache=None, deep=False, workers=8):
|
|
102
|
+
seen["strict"] = strict
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
monkeypatch.setattr("ghostpkg.cli.evaluate", fake_evaluate)
|
|
106
|
+
path = tmp_path / "requirements.txt"
|
|
107
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
108
|
+
main(["scan", str(path), "--strict", "--no-cache"])
|
|
109
|
+
assert seen["strict"] is True
|
|
110
|
+
|
|
111
|
+
def test_strict_changes_the_exit_code(self, tmp_path, monkeypatch):
|
|
112
|
+
"""The consequence, not just the wiring: a warning becomes a failure."""
|
|
113
|
+
monkeypatch.setattr(
|
|
114
|
+
"ghostpkg.scanner.fetch",
|
|
115
|
+
lambda name, ecosystem: facts(name, ecosystem, age_days=10, release_count=1, has_repo_url=False),
|
|
116
|
+
)
|
|
117
|
+
path = tmp_path / "requirements.txt"
|
|
118
|
+
path.write_text("something-new\n", encoding="utf-8")
|
|
119
|
+
assert main(["scan", str(path), "--no-cache"]) == 0
|
|
120
|
+
assert main(["scan", str(path), "--strict", "--no-cache"]) == 1
|
|
121
|
+
|
|
122
|
+
def test_an_error_exit_beats_a_clean_one(self, tmp_path, monkeypatch):
|
|
123
|
+
"""Swapping the BLOCK/ERROR precedence survived. An unchecked name must
|
|
124
|
+
never come out as a pass."""
|
|
125
|
+
from ghostpkg.registries import RegistryError
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(
|
|
128
|
+
"ghostpkg.scanner.fetch",
|
|
129
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
130
|
+
)
|
|
131
|
+
path = tmp_path / "requirements.txt"
|
|
132
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
133
|
+
assert main(["scan", str(path), "--no-cache"]) == 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestProseKeepsItsOwnEcosystem:
|
|
137
|
+
"""A README can hold `pip install x` and `npm install y` in adjacent lines,
|
|
138
|
+
so each name carries the registry it belongs to. Dropping the per-item
|
|
139
|
+
override made every npm name from prose be looked up on PyPI -- a false
|
|
140
|
+
block on all of them, and the feature silently dead."""
|
|
141
|
+
|
|
142
|
+
def test_an_npm_name_from_prose_is_looked_up_on_npm(self, tmp_path, monkeypatch):
|
|
143
|
+
looked_up = []
|
|
144
|
+
|
|
145
|
+
def fake_fetch(name, ecosystem):
|
|
146
|
+
looked_up.append((name, ecosystem))
|
|
147
|
+
return facts(name, ecosystem)
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr("ghostpkg.scanner.fetch", fake_fetch)
|
|
150
|
+
path = tmp_path / "AGENTS.md"
|
|
151
|
+
path.write_text("Run `npm install left-pad` then `pip install flask`.\n", encoding="utf-8")
|
|
152
|
+
main(["scan", str(path), "--no-cache"])
|
|
153
|
+
assert ("left-pad", "npm") in looked_up
|
|
154
|
+
assert ("flask", "pypi") in looked_up
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestProseExtraction:
|
|
158
|
+
"""Three survivors in one module, each verified to produce a wrong answer
|
|
159
|
+
on an ordinary README line."""
|
|
160
|
+
|
|
161
|
+
def test_a_chained_command_is_still_read(self):
|
|
162
|
+
"""`_split_commands` returning the whole line dropped this entirely and
|
|
163
|
+
the run reported all clean."""
|
|
164
|
+
assert [r.name for r in extract("cd app && npm install left-pad")] == ["left-pad"]
|
|
165
|
+
|
|
166
|
+
@pytest.mark.parametrize(
|
|
167
|
+
"line",
|
|
168
|
+
["brew install jq", "apt-get install curl nginx", "dnf install make", "cargo install ripgrep"],
|
|
169
|
+
)
|
|
170
|
+
def test_an_unknown_installer_yields_nothing(self, line):
|
|
171
|
+
"""Falling through to the pip branch turned `brew install jq` into a
|
|
172
|
+
PyPI lookup -- a false block on an ordinary README."""
|
|
173
|
+
assert extract(line) == []
|
|
174
|
+
|
|
175
|
+
def test_a_pinned_npm_name_keeps_only_the_name(self):
|
|
176
|
+
"""`npm install react@18` is the commonest install line there is.
|
|
177
|
+
Without the guard it was checked as the literal name `react@18`."""
|
|
178
|
+
assert [r.name for r in extract("npm install react@18")] == ["react"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class TestAgeComesFromTheFirstUpload:
|
|
182
|
+
"""Measuring from the newest upload made every actively maintained package
|
|
183
|
+
read as days old: mass false warnings, and false blocks under `--strict`."""
|
|
184
|
+
|
|
185
|
+
def test_a_long_lived_package_is_not_young(self):
|
|
186
|
+
from ghostpkg.registries import parse_npm
|
|
187
|
+
|
|
188
|
+
payload = {
|
|
189
|
+
"name": "thing",
|
|
190
|
+
"time": {"created": "2015-01-01T00:00:00.000Z", "modified": "2026-09-01T00:00:00.000Z"},
|
|
191
|
+
"versions": {"1.0.0": {}, "2.0.0": {}},
|
|
192
|
+
"dist-tags": {"latest": "2.0.0"},
|
|
193
|
+
}
|
|
194
|
+
assert parse_npm("thing", payload).age_days > 4000
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class TestTheCacheNeverStoresAMiss:
|
|
198
|
+
"""`ttl_for` is tested, the guard inside `put` was not -- so the project's
|
|
199
|
+
stated rule was only enforced second-hand."""
|
|
200
|
+
|
|
201
|
+
def test_put_refuses_a_negative(self, tmp_path):
|
|
202
|
+
from ghostpkg.cache import Cache
|
|
203
|
+
|
|
204
|
+
cache = Cache(directory=tmp_path)
|
|
205
|
+
cache.put(PackageFacts(name="ghost", ecosystem="pypi", exists=False))
|
|
206
|
+
assert cache.get("pypi", "ghost") is None
|
|
207
|
+
|
|
208
|
+
def test_put_stores_a_positive(self, tmp_path):
|
|
209
|
+
from ghostpkg.cache import Cache
|
|
210
|
+
|
|
211
|
+
cache = Cache(directory=tmp_path)
|
|
212
|
+
cache.put(facts("flask"))
|
|
213
|
+
assert cache.get("pypi", "flask") is not None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TestAnIncludeWithATrailingComment:
|
|
217
|
+
"""Dropping the inline-comment strip stopped `-r base.txt # shared deps`
|
|
218
|
+
following the include -- every package in the included file lost in
|
|
219
|
+
silence, with the run still reporting success."""
|
|
220
|
+
|
|
221
|
+
def test_the_include_is_followed(self, tmp_path):
|
|
222
|
+
from ghostpkg.manifests import load_manifest
|
|
223
|
+
|
|
224
|
+
(tmp_path / "base.txt").write_text("flask\njinja2\n", encoding="utf-8")
|
|
225
|
+
(tmp_path / "requirements.txt").write_text(
|
|
226
|
+
"-r base.txt # shared deps\nrequests\n", encoding="utf-8"
|
|
227
|
+
)
|
|
228
|
+
found, _ = load_manifest(tmp_path / "requirements.txt")
|
|
229
|
+
assert set(r.name for r in found) == {"flask", "jinja2", "requests"}
|
|
230
|
+
|
|
231
|
+
def test_a_comment_after_a_name_is_stripped(self):
|
|
232
|
+
assert [r.name for r in parse_requirements("flask # web\n")] == ["flask"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestTheErrorSummaryIsReported:
|
|
236
|
+
"""The error count in `--json` and the "could not be checked" line both
|
|
237
|
+
survived mutation. A run that could not reach the registry must not read as
|
|
238
|
+
a clean one."""
|
|
239
|
+
|
|
240
|
+
def test_json_counts_the_errors(self, tmp_path, monkeypatch, capsys):
|
|
241
|
+
from ghostpkg.registries import RegistryError
|
|
242
|
+
|
|
243
|
+
monkeypatch.setattr(
|
|
244
|
+
"ghostpkg.scanner.fetch",
|
|
245
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
246
|
+
)
|
|
247
|
+
path = tmp_path / "requirements.txt"
|
|
248
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
249
|
+
main(["scan", str(path), "--no-cache", "--json"])
|
|
250
|
+
payload = json.loads(capsys.readouterr().out)
|
|
251
|
+
assert payload["summary"]["errored"] == 1
|
|
252
|
+
assert payload["summary"]["blocked"] == 0
|
|
253
|
+
|
|
254
|
+
def test_the_text_summary_says_so(self, tmp_path, monkeypatch, capsys):
|
|
255
|
+
from ghostpkg.registries import RegistryError
|
|
256
|
+
|
|
257
|
+
monkeypatch.setattr(
|
|
258
|
+
"ghostpkg.scanner.fetch",
|
|
259
|
+
lambda name, ecosystem: (_ for _ in ()).throw(RegistryError("offline")),
|
|
260
|
+
)
|
|
261
|
+
path = tmp_path / "requirements.txt"
|
|
262
|
+
path.write_text("flask\n", encoding="utf-8")
|
|
263
|
+
main(["scan", str(path), "--no-cache"])
|
|
264
|
+
out = capsys.readouterr().out
|
|
265
|
+
assert "could not be checked" in out
|
|
266
|
+
assert "look fine" not in out
|
|
@@ -198,3 +198,59 @@ class TestACommandInsideASentence:
|
|
|
198
198
|
|
|
199
199
|
def test_a_span_with_a_backtick_pair_but_no_installer(self):
|
|
200
200
|
assert self.names("The `requirements.txt` file lists them.") == []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestAVersionInAnInstallCommand:
|
|
204
|
+
"""`npm install react@18` and `pip install flask==3.0` both extracted
|
|
205
|
+
nothing at all. The `@` was read as a direct reference and the `==` made the
|
|
206
|
+
token stop matching the name pattern, so a pinned install command -- the
|
|
207
|
+
commonest form there is -- was silently skipped by the feature built to
|
|
208
|
+
read install commands.
|
|
209
|
+
|
|
210
|
+
Re-measured after the change, because widening this parser has cost false
|
|
211
|
+
positives before: 19 names across 22 real READMEs, none that fail to exist.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
@pytest.mark.parametrize(
|
|
215
|
+
"line, name, specifier",
|
|
216
|
+
[
|
|
217
|
+
("npm install react@18", "react", "18"),
|
|
218
|
+
("npm i lodash@^4.17.21", "lodash", "^4.17.21"),
|
|
219
|
+
("pnpm add vue@3", "vue", "3"),
|
|
220
|
+
("yarn add @babel/core@7", "@babel/core", "7"),
|
|
221
|
+
("bun add hono@4.0.0", "hono", "4.0.0"),
|
|
222
|
+
("pip install flask==3.0", "flask", "3.0"),
|
|
223
|
+
("pip install httpx>=0.27", "httpx", "0.27"),
|
|
224
|
+
("pip install django~=5.0", "django", "5.0"),
|
|
225
|
+
("uv add fastapi==0.115.0", "fastapi", "0.115.0"),
|
|
226
|
+
("npm install react", "react", None),
|
|
227
|
+
],
|
|
228
|
+
)
|
|
229
|
+
def test_the_name_and_the_version_are_both_read(self, line, name, specifier):
|
|
230
|
+
found = extract(line)
|
|
231
|
+
assert [(r.name, r.specifier) for r in found] == [(name, specifier)]
|
|
232
|
+
|
|
233
|
+
@pytest.mark.parametrize(
|
|
234
|
+
"line",
|
|
235
|
+
[
|
|
236
|
+
"npm install pkg@https://example.com/x.tgz",
|
|
237
|
+
"npm install pkg@file:../local",
|
|
238
|
+
"npm install pkg@acme/repo",
|
|
239
|
+
"npm install pkg@github:acme/repo",
|
|
240
|
+
],
|
|
241
|
+
)
|
|
242
|
+
def test_a_stated_source_after_the_at_is_still_skipped(self, line):
|
|
243
|
+
"""The `@` guard existed to catch these. Splitting the version off must
|
|
244
|
+
not let a direct reference through."""
|
|
245
|
+
assert extract(line) == []
|
|
246
|
+
|
|
247
|
+
def test_a_pinned_name_in_prose_keeps_its_line(self):
|
|
248
|
+
text = "intro\n\nRun `pip install ghost-thing==1.0` to begin.\n"
|
|
249
|
+
found = extract(text)
|
|
250
|
+
assert (found[0].name, found[0].specifier, found[0].line) == ("ghost-thing", "1.0", 3)
|
|
251
|
+
|
|
252
|
+
def test_the_sentence_still_ends_the_command(self):
|
|
253
|
+
"""The 25% false-positive rate this parser once had came from running
|
|
254
|
+
past the full stop. Splitting versions must not reopen that."""
|
|
255
|
+
text = "Run `pip install httpx==0.27` and the command line client is optional."
|
|
256
|
+
assert [r.name for r in extract(text)] == ["httpx"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|