ghostpkg 0.22.0__tar.gz → 0.24.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 (63) hide show
  1. ghostpkg-0.24.0/.github/workflows/field.yml +71 -0
  2. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/CHANGELOG.md +84 -1
  3. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/PKG-INFO +3 -3
  4. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/README.en.md +2 -2
  5. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/README.md +2 -2
  6. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/__init__.py +1 -1
  7. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/assess.py +47 -3
  8. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/prose.py +41 -13
  9. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/scanner.py +3 -1
  10. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/pyproject.toml +1 -1
  11. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/scripts/fieldtest.py +38 -7
  12. ghostpkg-0.24.0/tests/test_mutation_gaps.py +266 -0
  13. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_prose.py +56 -0
  14. ghostpkg-0.24.0/tests/test_trusted_scopes.py +140 -0
  15. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  16. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  17. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  18. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  19. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/ghostpkg-ignore.json +0 -0
  20. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/workflows/ci.yml +0 -0
  21. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.github/workflows/publish.yml +0 -0
  22. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.gitignore +0 -0
  23. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/.pre-commit-hooks.yaml +0 -0
  24. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/2.0 +0 -0
  25. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/CONTRIBUTING.md +0 -0
  26. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/LICENSE +0 -0
  27. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/SECURITY.md +0 -0
  28. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/action.yml +0 -0
  29. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/banner.html +0 -0
  30. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/banner.png +0 -0
  31. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/demo.gif +0 -0
  32. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/assets/make_demo.py +0 -0
  33. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/__main__.py +0 -0
  34. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/cache.py +0 -0
  35. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/cli.py +0 -0
  36. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/data.py +0 -0
  37. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/discover.py +0 -0
  38. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/inspection.py +0 -0
  39. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/jslocks.py +0 -0
  40. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/manifests.py +0 -0
  41. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/policy.py +0 -0
  42. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/registries.py +0 -0
  43. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/report.py +0 -0
  44. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/ghostpkg/rules.py +0 -0
  45. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_action.py +0 -0
  46. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_assess.py +0 -0
  47. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_cache.py +0 -0
  48. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_discover.py +0 -0
  49. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_field_findings.py +0 -0
  50. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_hostile_input.py +0 -0
  51. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_inspection.py +0 -0
  52. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_jslocks.py +0 -0
  53. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_lockfiles.py +0 -0
  54. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_manifests.py +0 -0
  55. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_not_a_package.py +0 -0
  56. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_policy.py +0 -0
  57. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_registries.py +0 -0
  58. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_regressions.py +0 -0
  59. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_report.py +0 -0
  60. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_review_findings.py +0 -0
  61. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_stated_sources.py +0 -0
  62. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_versions.py +0 -0
  63. {ghostpkg-0.22.0 → ghostpkg-0.24.0}/tests/test_withdrawn.py +0 -0
@@ -0,0 +1,71 @@
1
+ # Scanning real repositories, on a schedule rather than on every push.
2
+ #
3
+ # This gate has found a defect the unit tests missed on three consecutive runs,
4
+ # including two that reported a package as non-existent while `pip` installed it
5
+ # without complaint. It is the most productive check the project has, and it is
6
+ # the one nobody would remember to run by hand.
7
+ #
8
+ # It is not on every push because a full pass is roughly 40,000 registry
9
+ # lookups. That is rude to PyPI and npm, and the answers barely move day to day.
10
+ # The push job runs the three smallest repositories, which is about 700 names
11
+ # and mostly served from cache.
12
+ #
13
+ # Runs on Linux specifically: `vercel/next.js` cannot be cloned on Windows,
14
+ # where a path in its test fixtures exceeds the limit, so the largest monorepo
15
+ # in the set is only reachable here.
16
+
17
+ name: field
18
+
19
+ on:
20
+ schedule:
21
+ # 04:20 UTC, off the hour to avoid the stampede every cron service creates.
22
+ - cron: "20 4 * * *"
23
+ workflow_dispatch:
24
+ inputs:
25
+ quick:
26
+ description: Only the three smallest repositories
27
+ type: boolean
28
+ default: false
29
+ push:
30
+ branches: [main]
31
+ paths:
32
+ # Only when something that could change a verdict has changed.
33
+ - "ghostpkg/**"
34
+ - "scripts/fieldtest.py"
35
+ - ".github/workflows/field.yml"
36
+
37
+ permissions:
38
+ contents: read
39
+
40
+ jobs:
41
+ scan:
42
+ runs-on: ubuntu-latest
43
+ timeout-minutes: 45
44
+ steps:
45
+ - uses: actions/checkout@v7
46
+
47
+ - uses: actions/setup-python@v7
48
+ with:
49
+ python-version: "3.12"
50
+
51
+ - run: pip install -e .
52
+
53
+ - name: Scan real repositories
54
+ run: |
55
+ # A push gets the quick set; a schedule or a manual run gets all of
56
+ # them, unless the manual run asked for quick.
57
+ if [ "${{ github.event_name }}" = "push" ] || [ "${{ inputs.quick }}" = "true" ]; then
58
+ python scripts/fieldtest.py --quick
59
+ else
60
+ python scripts/fieldtest.py
61
+ fi
62
+
63
+ - name: What a failure here means
64
+ if: failure()
65
+ run: |
66
+ echo "::error::A block was reported on a repository whose dependencies"
67
+ echo "::error::real people install every day. Treat it as a false"
68
+ echo "::error::positive until proven otherwise: find where the name"
69
+ echo "::error::came from, fix the parser, and re-run. If the block is"
70
+ echo "::error::genuinely correct, add it to EXPECTED in"
71
+ echo "::error::scripts/fieldtest.py with a reason a reader can check."
@@ -6,6 +6,87 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.24.0] - 2026-09-04
10
+
11
+ The gate now runs in CI, and it scanned **88,904 packages across sixteen
12
+ repositories**. Six blocks, and **all six are real** -- unsatisfiable pins in
13
+ Ray and a package next.js references that was never published. Zero false
14
+ positives at that scale is the number this project exists to hold.
15
+
16
+ ### Added
17
+ - **A scope the project already leans on is no longer treated as a stranger.**
18
+ Almost all remaining noise was "published under a year ago" on the dozen
19
+ platform binaries a compiled tool ships at once:
20
+ `@oxfmt/binding-darwin-arm64`, `@oxc-parser/binding-linux-riscv64-musl`.
21
+
22
+ The justification is structural rather than statistical: **an npm scope is
23
+ owned**. To publish `@oxfmt/anything` you must control `@oxfmt`, so a young
24
+ package in a scope the scan already depends on three times over is the same
25
+ publisher shipping another build.
26
+
27
+ Measured before it was built -- two members silences 41% of warnings, three
28
+ silences 40%, six silences 39%, so the threshold sits where it is defensible
29
+ rather than where it is largest. In practice: Sentry **114 warnings to 22**,
30
+ Vue **15 to 5**, and no change whatever to the block count. A squatted scope
31
+ (`@types-node` is not `@types/node`) appears once in a scan, not three times,
32
+ which is what the threshold separates. Nothing that blocks is affected, and
33
+ neither is the lookalike check.
34
+
35
+ - **`.github/workflows/field.yml`** runs the gate nightly and on any push that
36
+ touches the scanner. On Linux, because `vercel/next.js` cannot be cloned on
37
+ Windows -- a path in its test fixtures exceeds the limit, so the largest
38
+ monorepo in the set was unreachable until now.
39
+
40
+ - The repository list is **sixteen**, each carrying a note saying which defect
41
+ it found, so a future reader can tell why the list is what it is instead of
42
+ trimming it for being slow.
43
+
44
+ ### Verified
45
+ `EXPECTED` now records the three names that genuinely do not exist, each
46
+ checked against the registry by hand: `jaxlib==0.4.17` (PyPI's oldest is
47
+ 0.4.18), `tensorflow-macos==2.20.0` (Apple stopped at 2.16.2), and
48
+ `tsconfig-mod` (404 on npm).
49
+
50
+ 707 tests.
51
+
52
+ ## [0.23.0] - 2026-09-04
53
+
54
+ Mutation testing: 186 single-line changes to the source, each run against the
55
+ whole suite. **53 left it green.** The code was right in every case -- what was
56
+ missing was anything that would notice if it stopped being. Writing the tests to
57
+ kill the survivors turned up a real bug the suite had never had a chance to see.
58
+
59
+ ### Fixed
60
+ - **A version in an install command made prose extraction skip the whole
61
+ command.** `npm install react@18` and `pip install flask==3.0` both yielded
62
+ nothing: the `@` was read as a direct reference and the `==` stopped the token
63
+ matching the name pattern. A pinned install command is the commonest form
64
+ there is, so the feature built to read install commands was silently skipping
65
+ most of them.
66
+
67
+ The version is now split off and carried through, so a hallucinated *version*
68
+ in a README is checked as well as a hallucinated name. Re-measured on 22 real
69
+ READMEs, because widening this parser has cost false positives before: 19
70
+ names, **none that fail to exist**.
71
+
72
+ ### Added
73
+ Tests for the survivors that carry a real consequence:
74
+ - **Each finding keeps its own file and line.** Reversing the thread pool's
75
+ result list left all 656 tests passing -- the printed names still looked
76
+ right, because a finding carries its own name, but the origin stamped on it
77
+ came from a different package. A CI annotation would have pointed at the
78
+ wrong line and marked the wrong one clean.
79
+ - **`--deep` and `--strict` reach the scan.** Both could be forced to `False`
80
+ in the call without a single failure: the flag would silently do nothing.
81
+ - **A name from prose keeps its own ecosystem**, so `npm install left-pad` in a
82
+ README is not looked up on PyPI.
83
+ - Chained commands (`cd app && npm install x`), unknown installers
84
+ (`brew install jq`), age measured from the first upload rather than the
85
+ latest, `cache.put` refusing a negative, `-r base.txt # comment` still
86
+ following the include, and the error counts in both output formats.
87
+
88
+ 693 tests. Field gate: 4,461 packages, zero blocks.
89
+
9
90
  ## [0.22.0] - 2026-09-04
10
91
 
11
92
  Four agents scanned **21 repositories and 78,000 package names**. They found
@@ -902,7 +983,9 @@ First release.
902
983
  - No corpus of hallucinated package names is shipped, following the decision of
903
984
  the USENIX'25 authors not to publish theirs.
904
985
 
905
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.22.0...HEAD
986
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.24.0...HEAD
987
+ [0.24.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.0
988
+ [0.23.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.23.0
906
989
  [0.22.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.22.0
907
990
  [0.21.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.21.0
908
991
  [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.24.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.24.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.24.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.24.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.24.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.24.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.24.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.24.0"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -216,11 +216,48 @@ def nearest_popular(name: str, ecosystem: str = "pypi") -> tuple[str, int] | Non
216
216
  return best
217
217
 
218
218
 
219
+ #: How many packages a scope needs in one scan before its young members stop
220
+ #: being warned about. Measured across four repositories: two silences 41% of
221
+ #: all warnings, three silences 40%, six silences 39%. The effect saturates
222
+ #: immediately, so the threshold sits where it is defensible rather than where
223
+ #: it is largest.
224
+ TRUSTED_SCOPE_MEMBERS = 3
225
+
226
+
227
+ def trusted_scopes(names: "list[str]") -> "set[str]":
228
+ """npm scopes this scan depends on several times over.
229
+
230
+ The justification is structural rather than statistical: **an npm scope is
231
+ owned**. To publish `@oxfmt/binding-darwin-arm64` you must control
232
+ `@oxfmt`. So a young package in a scope the project already uses three
233
+ times is the same publisher shipping another build -- usually one of the
234
+ dozen platform binaries a compiled tool releases at once -- and not a
235
+ stranger who guessed a name.
236
+
237
+ A scope is itself squattable: `@types-node` is not `@types/node`. That is
238
+ the risk this has to survive, and the threshold is what does it -- a
239
+ squatted scope appears once in a scan, not three times. An attacker who
240
+ genuinely owned a scope a project leaned on that heavily would have far
241
+ better attacks available than guessing one more name inside it.
242
+
243
+ Measured: 106 of 268 warnings across four popular repositories, every one
244
+ of them a platform binary, and none of them something a person would want
245
+ to be told about.
246
+ """
247
+ counts: dict[str, int] = {}
248
+ for name in names:
249
+ if name.startswith("@") and "/" in name:
250
+ scope = name.split("/", 1)[0]
251
+ counts[scope] = counts.get(scope, 0) + 1
252
+ return {scope for scope, n in counts.items() if n >= TRUSTED_SCOPE_MEMBERS}
253
+
254
+
219
255
  def assess(
220
256
  facts: PackageFacts,
221
257
  strict: bool = False,
222
258
  signals: "list | None" = None,
223
259
  specifier: str | None = None,
260
+ known_scope: bool = False,
224
261
  ) -> Finding:
225
262
  """Turn registry facts, and optionally --deep install-script signals, into
226
263
  a verdict.
@@ -304,6 +341,13 @@ def assess(
304
341
 
305
342
  reasons: list[Reason] = []
306
343
 
344
+ # `known_scope` says the scan already depends on this scope several times,
345
+ # so "new and thin" describes a known publisher rather than a stranger.
346
+ # It quiets the soft signals only. Nothing that blocks is affected, and
347
+ # neither is the lookalike check -- a typo inside a trusted scope is still
348
+ # worth saying.
349
+ soft = not known_scope
350
+
307
351
  # A withdrawn version exists, so it is not a block -- and pip installs one
308
352
  # anyway when it is pinned explicitly, so blocking would be stricter than
309
353
  # the package manager itself. But the maintainer said not to use it, and
@@ -320,7 +364,7 @@ def assess(
320
364
  )
321
365
  )
322
366
 
323
- if facts.age_days is not None:
367
+ if soft and facts.age_days is not None:
324
368
  if facts.age_days < YOUNG_DAYS:
325
369
  reasons.append(
326
370
  Reason(GP_RECENT, f"first published {facts.age_days} days ago")
@@ -361,10 +405,10 @@ def assess(
361
405
  )
362
406
  )
363
407
 
364
- if is_young and facts.release_count <= 1:
408
+ if soft and is_young and facts.release_count <= 1:
365
409
  reasons.append(Reason(GP_ONE_RELEASE, "only one release"))
366
410
 
367
- if is_young and not facts.has_repo_url:
411
+ if soft and is_young and not facts.has_repo_url:
368
412
  reasons.append(Reason(GP_NO_REPO, "no repository or homepage link"))
369
413
 
370
414
  install_reasons = [
@@ -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,
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import concurrent.futures
10
10
  from dataclasses import replace
11
11
 
12
- from .assess import NEW_DAYS, Finding, Verdict, assess, exact_pin
12
+ from .assess import NEW_DAYS, Finding, Verdict, assess, exact_pin, trusted_scopes
13
13
  from .cache import Cache
14
14
  from .inspection import InspectionError, inspect_package
15
15
  from .manifests import Requirement
@@ -30,6 +30,7 @@ def evaluate(
30
30
  items = [
31
31
  r if isinstance(r, Requirement) else Requirement(name=r) for r in requirements
32
32
  ]
33
+ scopes = trusted_scopes([item.name for item in items])
33
34
 
34
35
  def one(requirement: Requirement) -> Finding:
35
36
  name = requirement.name
@@ -94,6 +95,7 @@ def evaluate(
94
95
  # A constraints entry is a bound, not an install, so its pin is not
95
96
  # a claim that such a version exists. The name is still checked.
96
97
  specifier=None if requirement.constraint else requirement.specifier,
98
+ known_scope=name.split("/", 1)[0] in scopes if name.startswith("@") else False,
97
99
  )
98
100
  finding.source = requirement.source
99
101
  finding.line = requirement.line
@@ -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.24.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"
@@ -29,20 +29,51 @@ import tempfile
29
29
  import time
30
30
  from pathlib import Path
31
31
 
32
- #: Chosen for the shapes they contain, not for popularity: a pnpm monorepo with
33
- #: hundreds of workspace members, a Python project with pinned calendar
34
- #: versions, and a repository that mixes both ecosystems in one tree.
32
+ #: Chosen for the shapes they contain, not for popularity. Every one of these
33
+ #: found a defect nothing else did, and the note says which -- so a future
34
+ #: reader can tell why the list is what it is rather than trimming it.
35
+ #:
36
+ #: The boolean is whether `--quick` includes it.
35
37
  REPOS = [
36
- ("pallets/flask", "uv.lock and example sub-projects", True),
38
+ ("pallets/flask", "uv.lock, example sub-projects", True),
37
39
  ("vuejs/core", "pnpm workspace, lockfile v9", True),
40
+ ("expressjs/express", "small and plain npm; a fast canary", True),
41
+
38
42
  ("getsentry/sentry", "pnpm-lock and uv.lock in one tree", False),
39
- ("home-assistant/core", "calendar versions with leading zeros", False),
43
+ ("home-assistant/core", "constraints pinned to 1000000000.0.0; calendar versions", False),
44
+ ("vercel/turborepo", "pnpm v5 keys with peer suffixes -- 17 false blocks", False),
45
+ ("sveltejs/kit", "pnpm `name@file:` workspace fixtures -- 5 false blocks", False),
46
+ ("withastro/astro", "more `name@file:` fixtures, scoped -- 3 false blocks", False),
47
+ ("storybookjs/storybook", "yarn berry `npm:v1.1.0` ranges -- 2 false blocks", False),
48
+ ("apache/airflow", "127 checksum files, five named like constraints", False),
49
+ ("ray-project/ray", "PEP 440 local versions, materialised symlinks", False),
50
+ ("nrwl/nx", "pnpm monorepo, agent instruction files", False),
51
+ ("angular/angular", "15,000 names, the widest single scan", False),
52
+ ("jupyterlab/jupyterlab", "Python and npm in one tree", False),
53
+ ("apache/superset", "6,000 names, heavy extras", False),
40
54
  ("vercel/next.js", "491 package.json files, internal deps by exact pin", False),
41
55
  ]
42
56
 
43
57
  #: Blocks that are correct. A repository may genuinely depend on something that
44
- #: no longer exists; each entry needs a reason a reader can check.
45
- EXPECTED: dict[str, str] = {}
58
+ #: does not exist, and every entry here was verified against the registry by
59
+ #: hand -- the reason has to be checkable by the next reader.
60
+ #:
61
+ #: Across 88,904 packages in sixteen repositories these six are the only blocks,
62
+ #: and all six are real. That is the number the gate exists to hold.
63
+ EXPECTED = {
64
+ # PyPI's jaxlib release list begins at 0.4.18. Older wheels were served
65
+ # from Google storage and the PyPI records were removed, so
66
+ # `pip install -r` on that file fails today.
67
+ "jaxlib": "ray pins 0.4.17; PyPI's oldest jaxlib release is 0.4.18",
68
+
69
+ # Apple stopped publishing tensorflow-macos at 2.16.2. Ray copied the
70
+ # `tensorflow==2.20.0` pin onto the macOS package, guarded by a
71
+ # `sys_platform == 'darwin'` marker, so it only fails on a Mac.
72
+ "tensorflow-macos": "ray pins 2.20.0; PyPI's newest is 2.16.2",
73
+
74
+ # 404 on npmjs.
75
+ "tsconfig-mod": "referenced by next.js and not published on npm",
76
+ }
46
77
 
47
78
  CACHE = Path(tempfile.gettempdir()) / "ghostpkg-fieldtest"
48
79
 
@@ -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"]
@@ -0,0 +1,140 @@
1
+ """A scope the project already leans on is not a stranger.
2
+
3
+ 268 warnings across four popular repositories was the last barrier a reviewer
4
+ named: almost all of them "published under a year ago" on the dozen platform
5
+ binaries a compiled tool ships at once -- `@oxfmt/binding-darwin-arm64`,
6
+ `@oxc-parser/binding-linux-riscv64-musl`.
7
+
8
+ The justification is structural rather than statistical: **an npm scope is
9
+ owned**. To publish `@oxfmt/anything` you must control `@oxfmt`. So a young
10
+ package in a scope the scan already depends on three times is the same
11
+ publisher shipping another build.
12
+
13
+ Measured before it was built. Two members silences 41% of warnings, three
14
+ silences 40%, six silences 39% -- it saturates at once, so the threshold sits
15
+ where it is defensible rather than where it is largest. In practice: Sentry 114
16
+ warnings to 22, Vue 15 to 5, and no change at all to the block count.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import pytest
22
+
23
+ from ghostpkg.assess import TRUSTED_SCOPE_MEMBERS, Verdict, assess, trusted_scopes
24
+ from ghostpkg.registries import PackageFacts
25
+
26
+
27
+ def young(name, **kwargs):
28
+ base = dict(
29
+ name=name, ecosystem="npm", exists=True,
30
+ age_days=40, release_count=1, has_repo_url=False,
31
+ )
32
+ base.update(kwargs)
33
+ return PackageFacts(**base)
34
+
35
+
36
+ class TestWhichScopesAreTrusted:
37
+ def test_a_scope_seen_often_enough(self):
38
+ names = [f"@oxfmt/binding-{i}" for i in range(TRUSTED_SCOPE_MEMBERS)]
39
+ assert trusted_scopes(names) == {"@oxfmt"}
40
+
41
+ def test_one_short_is_not_enough(self):
42
+ names = [f"@oxfmt/binding-{i}" for i in range(TRUSTED_SCOPE_MEMBERS - 1)]
43
+ assert trusted_scopes(names) == set()
44
+
45
+ def test_a_squatted_scope_appears_once(self):
46
+ """`@types-node` is not `@types/node`. A squatted scope shows up a
47
+ single time in a scan, which is exactly what the threshold separates."""
48
+ names = ["@types/node", "@types/react", "@types/jest", "@types-node/core"]
49
+ assert trusted_scopes(names) == {"@types"}
50
+
51
+ def test_unscoped_names_never_count(self):
52
+ assert trusted_scopes(["react", "vue", "lodash", "express"]) == set()
53
+
54
+ def test_scopes_are_counted_separately(self):
55
+ names = ["@a/one", "@a/two", "@a/three", "@b/one", "@b/two"]
56
+ assert trusted_scopes(names) == {"@a"}
57
+
58
+
59
+ class TestWhatBeingTrustedChanges:
60
+ def test_the_soft_signals_are_dropped(self):
61
+ finding = assess(young("@oxfmt/binding-darwin-arm64"), known_scope=True)
62
+ assert finding.verdict is Verdict.OK
63
+ assert finding.reasons == []
64
+
65
+ def test_the_same_package_outside_a_trusted_scope_is_warned_about(self):
66
+ finding = assess(young("@oxfmt/binding-darwin-arm64"), known_scope=False)
67
+ assert finding.verdict is Verdict.WARN
68
+ assert len(finding.reasons) == 3
69
+
70
+ def test_a_missing_package_is_still_blocked(self):
71
+ """Nothing that blocks is affected. A trusted scope does not make a
72
+ name that does not exist acceptable."""
73
+ facts = PackageFacts(name="@oxfmt/ghost", ecosystem="npm", exists=False)
74
+ assert assess(facts, known_scope=True).verdict is Verdict.BLOCK
75
+
76
+ def test_a_bad_pin_is_still_blocked(self):
77
+ facts = young("@oxfmt/binding-x", versions=("1.0.0",), latest_version="1.0.0")
78
+ finding = assess(facts, specifier="99.9.9", known_scope=True)
79
+ assert finding.verdict is Verdict.BLOCK
80
+
81
+ def test_a_confiscated_name_is_still_blocked(self):
82
+ facts = young("@oxfmt/binding-x", security_hold=True)
83
+ assert assess(facts, known_scope=True).verdict is Verdict.BLOCK
84
+
85
+ def test_install_signals_still_block_a_young_package(self):
86
+ """`--deep` findings are evidence about the package itself, not a guess
87
+ from its age, so the scope says nothing about them."""
88
+ from ghostpkg.inspection import Signal
89
+
90
+ signals = [Signal(kind="pipe-to-shell", detail="curl | sh", where="package.json")]
91
+ finding = assess(young("@oxfmt/binding-x"), signals=signals, known_scope=True)
92
+ assert finding.verdict is Verdict.BLOCK
93
+
94
+
95
+ class TestEndToEnd:
96
+ def test_a_family_of_binaries_produces_no_warnings(self, tmp_path, monkeypatch):
97
+ import json
98
+
99
+ from ghostpkg.cli import main
100
+
101
+ monkeypatch.setattr(
102
+ "ghostpkg.scanner.fetch",
103
+ lambda name, ecosystem: young(name, ecosystem=ecosystem),
104
+ )
105
+ deps = {f"@oxfmt/binding-{p}": "1.0.0" for p in ("darwin-arm64", "linux-x64", "win32-x64")}
106
+ (tmp_path / "package.json").write_text(
107
+ json.dumps({"name": "app", "dependencies": deps}), encoding="utf-8"
108
+ )
109
+ assert main(["scan", str(tmp_path / "package.json"), "--no-cache"]) == 0
110
+
111
+ def test_a_lone_scoped_newcomer_is_still_warned_about(self, tmp_path, monkeypatch, capsys):
112
+ import json
113
+
114
+ from ghostpkg.cli import main
115
+
116
+ monkeypatch.setattr(
117
+ "ghostpkg.scanner.fetch",
118
+ lambda name, ecosystem: young(name, ecosystem=ecosystem),
119
+ )
120
+ (tmp_path / "package.json").write_text(
121
+ json.dumps({"name": "app", "dependencies": {"@stranger/thing": "1.0.0"}}),
122
+ encoding="utf-8",
123
+ )
124
+ main(["scan", str(tmp_path / "package.json"), "--no-cache"])
125
+ assert "WARNING" in capsys.readouterr().out
126
+
127
+ def test_strict_still_fails_on_a_lone_newcomer(self, tmp_path, monkeypatch):
128
+ import json
129
+
130
+ from ghostpkg.cli import main
131
+
132
+ monkeypatch.setattr(
133
+ "ghostpkg.scanner.fetch",
134
+ lambda name, ecosystem: young(name, ecosystem=ecosystem),
135
+ )
136
+ (tmp_path / "package.json").write_text(
137
+ json.dumps({"name": "app", "dependencies": {"@stranger/thing": "1.0.0"}}),
138
+ encoding="utf-8",
139
+ )
140
+ assert main(["scan", str(tmp_path / "package.json"), "--strict", "--no-cache"]) == 1
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