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.
Files changed (61) hide show
  1. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/CHANGELOG.md +40 -1
  2. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/PKG-INFO +3 -3
  3. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/README.en.md +2 -2
  4. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/README.md +2 -2
  5. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/__init__.py +1 -1
  6. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/prose.py +41 -13
  7. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/pyproject.toml +1 -1
  8. ghostpkg-0.23.0/tests/test_mutation_gaps.py +266 -0
  9. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_prose.py +56 -0
  10. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  11. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  13. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  14. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/ghostpkg-ignore.json +0 -0
  15. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/workflows/ci.yml +0 -0
  16. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.github/workflows/publish.yml +0 -0
  17. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.gitignore +0 -0
  18. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/.pre-commit-hooks.yaml +0 -0
  19. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/2.0 +0 -0
  20. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/CONTRIBUTING.md +0 -0
  21. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/LICENSE +0 -0
  22. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/SECURITY.md +0 -0
  23. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/action.yml +0 -0
  24. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/banner.html +0 -0
  25. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/banner.png +0 -0
  26. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/demo.gif +0 -0
  27. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/assets/make_demo.py +0 -0
  28. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/__main__.py +0 -0
  29. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/assess.py +0 -0
  30. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/cache.py +0 -0
  31. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/cli.py +0 -0
  32. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/data.py +0 -0
  33. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/discover.py +0 -0
  34. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/inspection.py +0 -0
  35. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/jslocks.py +0 -0
  36. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/manifests.py +0 -0
  37. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/policy.py +0 -0
  38. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/registries.py +0 -0
  39. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/report.py +0 -0
  40. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/rules.py +0 -0
  41. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/ghostpkg/scanner.py +0 -0
  42. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/scripts/fieldtest.py +0 -0
  43. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_action.py +0 -0
  44. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_assess.py +0 -0
  45. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_cache.py +0 -0
  46. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_discover.py +0 -0
  47. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_field_findings.py +0 -0
  48. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_hostile_input.py +0 -0
  49. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_inspection.py +0 -0
  50. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_jslocks.py +0 -0
  51. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_lockfiles.py +0 -0
  52. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_manifests.py +0 -0
  53. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_not_a_package.py +0 -0
  54. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_policy.py +0 -0
  55. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_registries.py +0 -0
  56. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_regressions.py +0 -0
  57. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_report.py +0 -0
  58. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_review_findings.py +0 -0
  59. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_stated_sources.py +0 -0
  60. {ghostpkg-0.22.0 → ghostpkg-0.23.0}/tests/test_versions.py +0 -0
  61. {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.22.0...HEAD
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.22.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.22.0
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.22.0
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.22.0
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.22.0
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.22.0
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.22.0
217
+ rev: v0.23.0
218
218
  hooks:
219
219
  - id: ghostpkg
220
220
  ```
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.22.0"
3
+ __version__ = "0.23.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -73,11 +73,19 @@ def looks_like_prose(filename: str) -> bool:
73
73
  SENTENCE_END = ".,;:!?)"
74
74
 
75
75
 
76
- def _clean(token: str) -> "tuple[str | None, bool]":
77
- """(package name, whether the command ended here).
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
- Returning the second value matters more than it looks. Measured against ten
80
- real READMEs, letting a command run past the end of its sentence produced a
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
- # A path, a URL, an archive or a direct reference names its own source.
92
- if "://" in token or token.startswith((".", "/", "~")) or "@" in token[1:]:
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
- return (name if name not in (".", "..") else None), stop
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.22.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