ghostpkg 0.19.2__tar.gz → 0.21.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 (59) hide show
  1. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.gitignore +3 -0
  2. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/CHANGELOG.md +120 -1
  3. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/PKG-INFO +25 -12
  4. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/README.en.md +24 -11
  5. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/__init__.py +1 -1
  6. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/cli.py +28 -2
  7. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/jslocks.py +187 -156
  8. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/manifests.py +793 -684
  9. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/prose.py +210 -204
  10. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/registries.py +63 -3
  11. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/scanner.py +20 -2
  12. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/pyproject.toml +1 -1
  13. ghostpkg-0.21.0/scripts/fieldtest.py +151 -0
  14. ghostpkg-0.21.0/tests/test_field_findings.py +264 -0
  15. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_policy.py +57 -0
  16. ghostpkg-0.21.0/tests/test_review_findings.py +163 -0
  17. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  18. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  19. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  20. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  21. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/ghostpkg-ignore.json +0 -0
  22. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/workflows/ci.yml +0 -0
  23. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.github/workflows/publish.yml +0 -0
  24. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/.pre-commit-hooks.yaml +0 -0
  25. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/2.0 +0 -0
  26. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/CONTRIBUTING.md +0 -0
  27. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/LICENSE +0 -0
  28. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/README.md +0 -0
  29. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/SECURITY.md +0 -0
  30. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/action.yml +0 -0
  31. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/assets/banner.html +0 -0
  32. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/assets/banner.png +0 -0
  33. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/assets/demo.gif +0 -0
  34. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/assets/make_demo.py +0 -0
  35. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/__main__.py +0 -0
  36. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/assess.py +0 -0
  37. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/cache.py +0 -0
  38. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/data.py +0 -0
  39. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/discover.py +0 -0
  40. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/inspection.py +0 -0
  41. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/policy.py +0 -0
  42. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/report.py +0 -0
  43. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/ghostpkg/rules.py +0 -0
  44. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_action.py +0 -0
  45. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_assess.py +0 -0
  46. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_cache.py +0 -0
  47. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_discover.py +0 -0
  48. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_hostile_input.py +0 -0
  49. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_inspection.py +0 -0
  50. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_jslocks.py +0 -0
  51. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_lockfiles.py +0 -0
  52. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_manifests.py +0 -0
  53. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_prose.py +0 -0
  54. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_registries.py +0 -0
  55. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_regressions.py +0 -0
  56. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_report.py +0 -0
  57. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_stated_sources.py +0 -0
  58. {ghostpkg-0.19.2 → ghostpkg-0.21.0}/tests/test_versions.py +0 -0
  59. {ghostpkg-0.19.2 → ghostpkg-0.21.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,122 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.21.0] - 2026-09-04
10
+
11
+ Three more false blocks, all found by a new release gate that scans large real
12
+ repositories instead of synthetic manifests. On `home-assistant/core` alone the
13
+ count went **39 -> 0**.
14
+
15
+ ### Added
16
+ - **`scripts/fieldtest.py`, a release gate.** It clones five repositories chosen
17
+ for the dependency shapes they contain, scans each, and fails on any block
18
+ not listed with a reason. Every name in those projects is something thousands
19
+ of people install daily, so **every block is false until shown otherwise**.
20
+ Current state: **4,461 packages checked, zero blocks.**
21
+
22
+ This exists because 605 unit tests and a 35-check acceptance pass had missed
23
+ eleven defects that one pass over real repositories found immediately.
24
+
25
+ ### Fixed
26
+ - **A constraints file forbids; it does not install.** `pip` never installs
27
+ from one -- it only bounds a version if the package arrives some other way --
28
+ so the standard way to forbid a package outright is to pin it to a version
29
+ that cannot exist. Home Assistant does this for eight of them
30
+ (`pycrypto==1000000000.0.0`), and checking those pins turned deliberate
31
+ exclusions into **35 reported blocks**. The flag now survives nested `-c` and
32
+ `-r` includes. The name in a constraints file is still checked.
33
+ - **PEP 440 pads the release segment with zeros.** `0.8` and `0.8.0` are one
34
+ version, as are `1.6.6` and `1.6.6.0`. Both spellings sit in Home Assistant
35
+ requirements against packages that store the other form, and comparing the
36
+ raw text blocked `libsoundtouch` and `baidu-aip`. Worse, the test suite had
37
+ asserted the *opposite* -- that `1.0` and `1.0.0` differ -- so the wrong
38
+ behaviour was locked in by a test. Corrected, and the padding is now
39
+ asserted.
40
+ - **A block no longer rests on a cached version list.** An established package
41
+ is cached for a day, so a release published inside that window is invisible;
42
+ `opower==0.21.0` exists and was blocked from a stale list. The version list
43
+ is now re-fetched before any version block, which is the same rule that
44
+ already governs negative answers: the answer that blocks has to be fresh.
45
+
46
+ 614 tests.
47
+
48
+ ## [0.20.0] - 2026-09-03
49
+
50
+ Four defects found by scanning **10,109 packages** across `vercel/next.js`,
51
+ `home-assistant/core` and `getsentry/sentry`. Those three repositories produced
52
+ 53 blocks and **every one of them was false**. None of these shapes occur in a
53
+ small synthetic manifest, which is why 571 tests and a 35-check acceptance pass
54
+ had missed all four.
55
+
56
+ ### Fixed
57
+ - **`ghostpkg check -e npm "@scope/name"` reported success having checked
58
+ nothing.** The names went through the PEP 508 requirements parser, whose
59
+ pattern demands an alphanumeric first character, so every scoped npm name was
60
+ dropped in silence -- the run printed "all 0 packages look fine" and exited
61
+ 0. On the command this tool is named for, and for a quarter of the npm
62
+ namespace. A false all-clear is worse than any false positive, and this is
63
+ the most serious defect the project has shipped.
64
+ - **A pinned version was compared as text rather than as a version.**
65
+ `aiopurpleair==2025.08.1` was blocked as non-existent while `pip download`
66
+ installed it happily: PyPI stores the canonical `2025.8.1`. Comparison now
67
+ normalises both sides -- leading zeros, a leading `v`, `-`/`_` separators and
68
+ the pre-release spellings. Three such pins sit in unmodified Home Assistant
69
+ requirements, and a false block breaks a build.
70
+ - **A package the checkout provides itself is no longer looked up.** All three
71
+ names blocked in a 6,335-package scan of `vercel/next.js` were the
72
+ repository's own packages, `@next/font` among them. A monorepo depends on
73
+ itself, and not always through `workspace:*` -- an exact pin is just as
74
+ common. Names declared by any `package.json` or `pyproject.toml` in the scan
75
+ are now excluded, which is the same rule as `workspace:` stated differently.
76
+ - **`MANIFEST.in` is no longer read as a requirements file.** `.in` is the
77
+ pip-tools convention and also the extension of a packaging directives file.
78
+ Read as requirements it reported `graft` as a package that exists -- there is
79
+ a real project of that name -- and blocked `recursive-exclude`.
80
+
81
+ ### Documentation
82
+ - The `--json` example showed the bare array from before 0.14.0. It is a
83
+ `{schema, tool, summary, findings}` envelope, and the documented example now
84
+ matches, including `source` and `line`.
85
+
86
+ 605 tests.
87
+
88
+ ## [0.19.3] - 2026-09-03
89
+
90
+ Found by reviewing the published 0.19.2 rather than the working tree. Two of
91
+ the four are false blocks, which this project treats as its worst failure, and
92
+ both have the same cause: `jslocks.py` was written after the test file that
93
+ exists to defend the rule "a dependency naming its own source is not the
94
+ registry's business", so `yarn.lock` and `pnpm-lock.yaml` were never covered by
95
+ it.
96
+
97
+ ### Fixed
98
+ - **A yarn `owner/repo#ref` dependency was blocked.** GitHub shorthand was
99
+ handled for `package.json` and missed in `yarn.lock`, so
100
+ `internal-lib@acme/internal-lib#v1.2.3` was looked up on npmjs, found absent,
101
+ and reported as a package that does not exist.
102
+ - **A pnpm git or URL dependency produced a nonsense package name.** The
103
+ protocol is only a prefix in lockfile v5; from v6 it follows `name@`, so the
104
+ guard never fired. `github.com/acme/forked/abc123` was read as a package
105
+ called `github.com/acme/forked`, and
106
+ `foo@https://codeload.github.com/...` as one called
107
+ `foo@https://codeload.github.com/acme/foo`. Both were then blocked.
108
+ - **`--timeout` did nothing.** `def _get_json(url, timeout=TIMEOUT)` bound the
109
+ module global once, at import; the CLI assigned `registries.TIMEOUT` and
110
+ nothing ever re-read it, so every request used 15 seconds regardless.
111
+ - **`.windsurfrules` was found and never scanned.** The directory search
112
+ offered it up, the parser refused it, and the CLI ignores an unreadable
113
+ *discovered* file on purpose -- so nothing was printed. There is now a test
114
+ asserting that every file the search returns can actually be parsed.
115
+
116
+ ### Added
117
+ - End-to-end tests for suppression. The ignore file was loaded and applied in
118
+ `main()` only, and nothing asserted its effect on a verdict or an exit code:
119
+ the entire policy call could be replaced with `used = []` and all 539 tests
120
+ still passed. Three tests now cover the real path, and they fail against that
121
+ stub.
122
+
123
+ 571 tests.
124
+
9
125
  ## [0.19.2] - 2026-09-02
10
126
 
11
127
  Found by throwing hostile input at the parsers rather than by reading them.
@@ -733,7 +849,10 @@ First release.
733
849
  - No corpus of hallucinated package names is shipped, following the decision of
734
850
  the USENIX'25 authors not to publish theirs.
735
851
 
736
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.19.2...HEAD
852
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.21.0...HEAD
853
+ [0.21.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.21.0
854
+ [0.20.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.20.0
855
+ [0.19.3]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.3
737
856
  [0.19.2]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.2
738
857
  [0.19.1]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.1
739
858
  [0.19.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.19.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.19.2
3
+ Version: 0.21.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
@@ -340,19 +340,32 @@ malformed file **stops the run** rather than quietly leaving you unprotected.
340
340
  ### JSON output
341
341
 
342
342
  ```console
343
- $ ghostpkg check somepkgthatisnotreal9911 --json
344
- [
345
- {
346
- "name": "somepkgthatisnotreal9911",
347
- "ecosystem": "pypi",
348
- "verdict": "BLOCK",
349
- "reasons": [
350
- "does not exist on pypi"
351
- ]
352
- }
353
- ]
343
+ $ ghostpkg scan requirements.txt --json
344
+ {
345
+ "schema": 1,
346
+ "tool": { "name": "ghostpkg", "version": "0.20.0" },
347
+ "summary": { "checked": 2, "blocked": 1, "warned": 0, "errored": 0 },
348
+ "findings": [
349
+ {
350
+ "name": "somepkgthatisnotreal9911",
351
+ "ecosystem": "pypi",
352
+ "verdict": "BLOCK",
353
+ "source": "requirements.txt",
354
+ "line": 2,
355
+ "exists": false,
356
+ "latest_version": null,
357
+ "reasons": [
358
+ { "rule": "GP001", "text": "does not exist on pypi" }
359
+ ]
360
+ }
361
+ ]
362
+ }
354
363
  ```
355
364
 
365
+ `schema` is there so a consumer can tell what it is reading; `source` and
366
+ `line` say where the name was written. `--format github` emits workflow
367
+ commands instead, and `--format text` is the default.
368
+
356
369
  ---
357
370
 
358
371
  ## Every signal it checks
@@ -292,19 +292,32 @@ malformed file **stops the run** rather than quietly leaving you unprotected.
292
292
  ### JSON output
293
293
 
294
294
  ```console
295
- $ ghostpkg check somepkgthatisnotreal9911 --json
296
- [
297
- {
298
- "name": "somepkgthatisnotreal9911",
299
- "ecosystem": "pypi",
300
- "verdict": "BLOCK",
301
- "reasons": [
302
- "does not exist on pypi"
303
- ]
304
- }
305
- ]
295
+ $ ghostpkg scan requirements.txt --json
296
+ {
297
+ "schema": 1,
298
+ "tool": { "name": "ghostpkg", "version": "0.20.0" },
299
+ "summary": { "checked": 2, "blocked": 1, "warned": 0, "errored": 0 },
300
+ "findings": [
301
+ {
302
+ "name": "somepkgthatisnotreal9911",
303
+ "ecosystem": "pypi",
304
+ "verdict": "BLOCK",
305
+ "source": "requirements.txt",
306
+ "line": 2,
307
+ "exists": false,
308
+ "latest_version": null,
309
+ "reasons": [
310
+ { "rule": "GP001", "text": "does not exist on pypi" }
311
+ ]
312
+ }
313
+ ]
314
+ }
306
315
  ```
307
316
 
317
+ `schema` is there so a consumer can tell what it is reading; `source` and
318
+ `line` say where the name was written. `--format github` emits workflow
319
+ commands instead, and `--format text` is the default.
320
+
308
321
  ---
309
322
 
310
323
  ## Every signal it checks
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.19.2"
3
+ __version__ = "0.21.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -15,7 +15,13 @@ from . import __version__, registries
15
15
  from .assess import Finding
16
16
  from .cache import Cache
17
17
  from .discover import discover
18
- from .manifests import UnsupportedManifest, load_manifest, parse_requirements
18
+ from .manifests import (
19
+ UnsupportedManifest,
20
+ declared_name,
21
+ load_manifest,
22
+ parse_npm_names,
23
+ parse_requirements,
24
+ )
19
25
  from .policy import PolicyError, apply as apply_policy, load as load_policy
20
26
  from .report import Palette, as_github, as_json, render, summarise, use_colour
21
27
  from .scanner import DEFAULT_WORKERS, evaluate
@@ -134,6 +140,16 @@ def main(argv: list[str] | None = None) -> int:
134
140
  print(f"ghostpkg: no manifests found in {where}", file=sys.stderr)
135
141
  return EXIT_NOTHING_SCANNED
136
142
 
143
+ # Names the checkout provides itself. A monorepo depends on its own
144
+ # packages, and not always through `workspace:*` -- an exact pin is
145
+ # just as common, and looking those up on the public registry
146
+ # blocked every one of them.
147
+ local: set[str] = set()
148
+ for path in paths:
149
+ own = declared_name(path)
150
+ if own:
151
+ local.add(own.lower())
152
+
137
153
  for path in paths:
138
154
  try:
139
155
  found, ecosystem = load_manifest(path)
@@ -151,6 +167,8 @@ def main(argv: list[str] | None = None) -> int:
151
167
  return EXIT_ERROR
152
168
  continue
153
169
  for requirement in found:
170
+ if requirement.name.lower() in local:
171
+ continue # provided by this checkout
154
172
  by_ecosystem.setdefault(
155
173
  requirement.ecosystem or ecosystem, []
156
174
  ).append(requirement)
@@ -162,7 +180,15 @@ def main(argv: list[str] | None = None) -> int:
162
180
  return EXIT_NOTHING_SCANNED
163
181
  else:
164
182
  # Accept a pin on the command line too: `ghostpkg check requests==2.31.0`.
165
- by_ecosystem[args.ecosystem] = parse_requirements("\n".join(args.names))
183
+ # npm names go through their own reader, because a scoped name is not
184
+ # expressible in PEP 508 and the requirements parser dropped every one.
185
+ if args.ecosystem == "npm":
186
+ by_ecosystem[args.ecosystem] = parse_npm_names(args.names)
187
+ else:
188
+ by_ecosystem[args.ecosystem] = parse_requirements("\n".join(args.names))
189
+ if not by_ecosystem[args.ecosystem]:
190
+ print(f"ghostpkg: no package names in {' '.join(args.names)}", file=sys.stderr)
191
+ return EXIT_NOTHING_SCANNED
166
192
 
167
193
  try:
168
194
  policy, policy_path = load_policy(args.config)