ghostpkg 0.24.2__tar.gz → 0.24.4__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.2 → ghostpkg-0.24.4}/.github/workflows/publish.yml +15 -14
  2. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.pre-commit-hooks.yaml +1 -1
  3. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/2.0 +1 -1
  4. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/CHANGELOG.md +34 -1
  5. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/PKG-INFO +3 -3
  6. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/README.en.md +2 -2
  7. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/README.md +2 -2
  8. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/__init__.py +1 -1
  9. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/policy.py +181 -181
  10. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/pyproject.toml +1 -1
  11. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_action.py +27 -0
  12. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/bug.yml +0 -0
  13. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  14. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/false-positive.yml +0 -0
  15. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ISSUE_TEMPLATE/missed-package.yml +0 -0
  16. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/ghostpkg-ignore.json +0 -0
  17. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/workflows/ci.yml +0 -0
  18. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.github/workflows/field.yml +0 -0
  19. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/.gitignore +0 -0
  20. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/CONTRIBUTING.md +0 -0
  21. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/LICENSE +0 -0
  22. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/SECURITY.md +0 -0
  23. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/action.yml +0 -0
  24. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/banner.html +0 -0
  25. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/banner.png +0 -0
  26. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/demo.gif +0 -0
  27. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/assets/make_demo.py +0 -0
  28. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/__main__.py +0 -0
  29. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/assess.py +0 -0
  30. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/cache.py +0 -0
  31. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/cli.py +0 -0
  32. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/data.py +0 -0
  33. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/discover.py +0 -0
  34. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/inspection.py +0 -0
  35. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/jslocks.py +0 -0
  36. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/manifests.py +0 -0
  37. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/prose.py +0 -0
  38. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/registries.py +0 -0
  39. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/report.py +0 -0
  40. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/rules.py +0 -0
  41. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/ghostpkg/scanner.py +0 -0
  42. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/scripts/fieldtest.py +0 -0
  43. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_assess.py +0 -0
  44. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_cache.py +0 -0
  45. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_discover.py +0 -0
  46. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_field_findings.py +0 -0
  47. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_hostile_input.py +0 -0
  48. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_inspection.py +0 -0
  49. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_jslocks.py +0 -0
  50. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_lockfiles.py +0 -0
  51. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_manifests.py +0 -0
  52. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_mutation_gaps.py +0 -0
  53. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_not_a_package.py +0 -0
  54. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_policy.py +0 -0
  55. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_prose.py +0 -0
  56. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_registries.py +0 -0
  57. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_regressions.py +0 -0
  58. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_report.py +0 -0
  59. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_review_findings.py +0 -0
  60. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_stated_sources.py +0 -0
  61. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_trusted_scopes.py +0 -0
  62. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_versions.py +0 -0
  63. {ghostpkg-0.24.2 → ghostpkg-0.24.4}/tests/test_withdrawn.py +0 -0
@@ -6,26 +6,24 @@
6
6
  # whole subject is supply-chain trust, publishing from a laptop with a token
7
7
  # pasted into the shell is the wrong example to set.
8
8
  #
9
- # ONE-TIME SETUP, on pypi.org -> ghostpkg -> Manage -> Publishing:
10
- # Owner: M1rwana12
11
- # Repository name: ghostpkg
12
- # Workflow name: publish.yml
13
- # Environment name: pypi
14
- # Until that exists, this workflow will fail -- which is why it only runs when
15
- # started by hand. Once a run has succeeded, add:
9
+ # The publisher is registered on PyPI, so this now runs on its own when a
10
+ # release is published:
16
11
  #
17
- # release:
18
- # types: [published]
12
+ # Owner: M1rwana12 Repository: ghostpkg
13
+ # Workflow: publish.yml Environment: pypi
19
14
  #
20
- # to the triggers below so tagging a release is enough.
15
+ # A manual run is still available for a tag that was released before this was
16
+ # wired up, or to retry one that failed after the release already existed.
21
17
 
22
18
  name: publish
23
19
 
24
20
  on:
21
+ release:
22
+ types: [published]
25
23
  workflow_dispatch:
26
24
  inputs:
27
25
  tag:
28
- description: Tag to build and publish, e.g. v0.18.0
26
+ description: Tag to build and publish, e.g. v0.24.4
29
27
  required: true
30
28
 
31
29
  permissions:
@@ -37,7 +35,8 @@ jobs:
37
35
  steps:
38
36
  - uses: actions/checkout@v4
39
37
  with:
40
- ref: ${{ inputs.tag }}
38
+ # A release event carries its own tag; a manual run names one.
39
+ ref: ${{ inputs.tag || github.event.release.tag_name }}
41
40
 
42
41
  - uses: actions/setup-python@v5
43
42
  with:
@@ -50,10 +49,12 @@ jobs:
50
49
  python -m twine check dist/*
51
50
 
52
51
  - name: Check the tag matches the version in the package
52
+ env:
53
+ TAG: ${{ inputs.tag || github.event.release.tag_name }}
53
54
  run: |
54
55
  version=$(python -c "import ghostpkg; print(ghostpkg.__version__)")
55
- if [ "v$version" != "${{ inputs.tag }}" ]; then
56
- echo "::error::tag ${{ inputs.tag }} does not match __version__ $version"
56
+ if [ "v$version" != "$TAG" ]; then
57
+ echo "::error::tag $TAG does not match __version__ $version"
57
58
  exit 1
58
59
  fi
59
60
 
@@ -2,7 +2,7 @@
2
2
  #
3
3
  # repos:
4
4
  # - repo: https://github.com/M1rwana12/ghostpkg
5
- # rev: v0.24.0
5
+ # rev: v0.24.4
6
6
  # hooks:
7
7
  # - id: ghostpkg
8
8
  #
@@ -1 +1 @@
1
- [REAL PIP] install requests flask
1
+ [REAL PIP] install requests flask
@@ -6,6 +6,37 @@ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.24.4] - 2026-09-04
10
+
11
+ ### Changed
12
+ - **Releases are published by CI through PyPI Trusted Publishing.** The
13
+ publisher is registered, so `publish.yml` now runs when a release is
14
+ published rather than only when started by hand. PyPI mints a short-lived
15
+ credential for this exact workflow in this exact repository, so there is no
16
+ long-lived token to leak.
17
+
18
+ This is the first release built and uploaded that way rather than from a
19
+ laptop with a token pasted into the shell. For a tool whose whole subject is
20
+ supply-chain trust, that was the wrong example to set.
21
+
22
+ A manual run is still available, for a tag released before this was wired up
23
+ or to retry one that failed after the release already existed.
24
+
25
+ ## [0.24.3] - 2026-09-04
26
+
27
+ ### Fixed
28
+ - **The documented snippets pinned `@v0.24.0`, two releases behind.** Each
29
+ release script replaced the *previous* version string, so a snippet already
30
+ left on an older one was never touched again. The GitHub Marketplace listing
31
+ renders that snippet, so it told people to pin a version predating the fixes
32
+ it advertises.
33
+
34
+ Replacement is now by pattern rather than by exact previous string, and a
35
+ test fails if any documented pin disagrees with `__version__` -- with a
36
+ second test guarding against the patterns silently matching nothing.
37
+
38
+ 711 tests.
39
+
9
40
  ## [0.24.2] - 2026-09-04
10
41
 
11
42
  ### Changed
@@ -1015,7 +1046,9 @@ First release.
1015
1046
  - No corpus of hallucinated package names is shipped, following the decision of
1016
1047
  the USENIX'25 authors not to publish theirs.
1017
1048
 
1018
- [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.24.2...HEAD
1049
+ [Unreleased]: https://github.com/M1rwana12/ghostpkg/compare/v0.24.4...HEAD
1050
+ [0.24.4]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.4
1051
+ [0.24.3]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.3
1019
1052
  [0.24.2]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.2
1020
1053
  [0.24.1]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.1
1021
1054
  [0.24.0]: https://github.com/M1rwana12/ghostpkg/releases/tag/v0.24.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ghostpkg
3
- Version: 0.24.2
3
+ Version: 0.24.4
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
@@ -445,7 +445,7 @@ length, and only applies to packages young enough to plausibly be a squat.
445
445
  ### In CI
446
446
 
447
447
  ```yaml
448
- - uses: M1rwana12/ghostpkg@v0.24.0
448
+ - uses: M1rwana12/ghostpkg@v0.24.4
449
449
  ```
450
450
 
451
451
  That is the whole step. It searches the checkout, skips `node_modules` and
@@ -476,7 +476,7 @@ Or without the action, if you prefer:
476
476
  ```yaml
477
477
  repos:
478
478
  - repo: https://github.com/M1rwana12/ghostpkg
479
- rev: v0.24.0
479
+ rev: v0.24.4
480
480
  hooks:
481
481
  - id: ghostpkg
482
482
  ```
@@ -397,7 +397,7 @@ length, and only applies to packages young enough to plausibly be a squat.
397
397
  ### In CI
398
398
 
399
399
  ```yaml
400
- - uses: M1rwana12/ghostpkg@v0.24.0
400
+ - uses: M1rwana12/ghostpkg@v0.24.4
401
401
  ```
402
402
 
403
403
  That is the whole step. It searches the checkout, skips `node_modules` and
@@ -428,7 +428,7 @@ Or without the action, if you prefer:
428
428
  ```yaml
429
429
  repos:
430
430
  - repo: https://github.com/M1rwana12/ghostpkg
431
- rev: v0.24.0
431
+ rev: v0.24.4
432
432
  hooks:
433
433
  - id: ghostpkg
434
434
  ```
@@ -194,7 +194,7 @@ ghostpkg scan ../інший-проєкт
194
194
  ### У CI
195
195
 
196
196
  ```yaml
197
- - uses: M1rwana12/ghostpkg@v0.24.0
197
+ - uses: M1rwana12/ghostpkg@v0.24.4
198
198
  ```
199
199
 
200
200
  Це весь крок. Він шукає маніфести в чекауті, обходить `node_modules` і подібні
@@ -218,7 +218,7 @@ ghostpkg scan ../інший-проєкт
218
218
  ```yaml
219
219
  repos:
220
220
  - repo: https://github.com/M1rwana12/ghostpkg
221
- rev: v0.24.0
221
+ rev: v0.24.4
222
222
  hooks:
223
223
  - id: ghostpkg
224
224
  ```
@@ -1,6 +1,6 @@
1
1
  """ghostpkg -- catch package names that do not exist before you install them."""
2
2
 
3
- __version__ = "0.24.2"
3
+ __version__ = "0.24.4"
4
4
 
5
5
  from .assess import Finding, Verdict, assess
6
6
  from .registries import PackageFacts, RegistryError, fetch
@@ -1,181 +1,181 @@
1
- """Suppressing findings you have already decided about.
2
-
3
- One false positive is enough for a team to remove a security check from CI, so
4
- there has to be a way to say "we know about this one". Three things make that
5
- safe rather than a hole:
6
-
7
- **The file is never read from the project directory.** ghostpkg is meant to sit
8
- in front of a coding agent, and an agent with shell access can edit files in the
9
- repository it is working on. A `.ghostpkgignore` next to the code would be a
10
- suppression list the thing being guarded can rewrite. So the file comes from an
11
- explicit path, an environment variable, or the user's own config directory --
12
- never from the tree being scanned.
13
-
14
- **A reason is required.** An entry nobody can justify in one sentence is an
15
- entry nobody will dare remove later.
16
-
17
- **A malformed file is an error, not a shrug.** Degrading quietly to "no
18
- suppressions" would be the safe direction; degrading quietly to "no protection"
19
- would not, and from the outside the two are indistinguishable. Failing loudly
20
- is the only honest option.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import fnmatch
26
- import json
27
- import os
28
- import sys
29
- from dataclasses import dataclass
30
- from datetime import date
31
- from pathlib import Path
32
-
33
- CONFIG_ENV = "GHOSTPKG_CONFIG"
34
- CONFIG_NAME = "ignore.json"
35
-
36
-
37
- class PolicyError(ValueError):
38
- """The ignore file exists but could not be used."""
39
-
40
-
41
- @dataclass(frozen=True)
42
- class Rule:
43
- """One suppression. `package` and `rule` accept glob patterns."""
44
-
45
- package: str
46
- reason: str
47
- ecosystem: str = "*"
48
- rule: str = "*"
49
- expires: date | None = None
50
-
51
- def expired(self, today: date | None = None) -> bool:
52
- return self.expires is not None and self.expires < (today or date.today())
53
-
54
- def matches(self, name: str, ecosystem: str, rule_id: str | None) -> bool:
55
- if self.ecosystem != "*" and self.ecosystem != ecosystem:
56
- return False
57
- if self.rule != "*" and not fnmatch.fnmatch(rule_id or "", self.rule):
58
- return False
59
- return fnmatch.fnmatch(name.lower(), self.package.lower())
60
-
61
-
62
- def config_dir() -> Path:
63
- if sys.platform == "win32":
64
- base = os.environ.get("APPDATA") or os.path.expanduser("~")
65
- return Path(base) / "ghostpkg"
66
- if sys.platform == "darwin":
67
- return Path(os.path.expanduser("~/Library/Application Support")) / "ghostpkg"
68
- base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
69
- return Path(base) / "ghostpkg"
70
-
71
-
72
- def locate(explicit: str | Path | None = None) -> Path | None:
73
- """Where the ignore file lives, or None. Never the project directory."""
74
- if explicit:
75
- return Path(explicit)
76
- from_env = os.environ.get(CONFIG_ENV)
77
- if from_env:
78
- return Path(from_env)
79
- candidate = config_dir() / CONFIG_NAME
80
- return candidate if candidate.is_file() else None
81
-
82
-
83
- def _parse_date(value, where: str) -> date | None:
84
- if value is None:
85
- return None
86
- try:
87
- return date.fromisoformat(str(value))
88
- except ValueError:
89
- raise PolicyError(
90
- f"{where}: 'expires' must be a date like 2026-12-31, got {value!r}"
91
- ) from None
92
-
93
-
94
- def parse(text: str, origin: str = "ignore file") -> list[Rule]:
95
- try:
96
- data = json.loads(text)
97
- except ValueError as exc:
98
- raise PolicyError(f"{origin}: not valid JSON ({exc})") from None
99
-
100
- entries = data.get("ignore") if isinstance(data, dict) else data
101
- if not isinstance(entries, list):
102
- raise PolicyError(f"{origin}: expected a list under 'ignore'")
103
-
104
- rules: list[Rule] = []
105
- for index, entry in enumerate(entries, 1):
106
- where = f"{origin}, entry {index}"
107
- if not isinstance(entry, dict):
108
- raise PolicyError(f"{where}: expected an object")
109
- package = entry.get("package")
110
- reason = entry.get("reason")
111
- if not package or not isinstance(package, str):
112
- raise PolicyError(f"{where}: 'package' is required")
113
- if not reason or not isinstance(reason, str):
114
- raise PolicyError(
115
- f"{where}: 'reason' is required -- a suppression nobody can "
116
- f"justify is one nobody will dare remove"
117
- )
118
- rules.append(
119
- Rule(
120
- package=package,
121
- reason=reason,
122
- ecosystem=str(entry.get("ecosystem") or "*"),
123
- rule=str(entry.get("rule") or "*"),
124
- expires=_parse_date(entry.get("expires"), where),
125
- )
126
- )
127
- return rules
128
-
129
-
130
- def load(explicit: str | Path | None = None) -> tuple[list[Rule], Path | None]:
131
- """Read the ignore file. Missing is fine; malformed is not."""
132
- path = locate(explicit)
133
- if path is None:
134
- return [], None
135
- try:
136
- text = path.read_text(encoding="utf-8-sig")
137
- except OSError as exc:
138
- raise PolicyError(f"could not read {path}: {exc}") from None
139
- return parse(text, str(path)), path
140
-
141
-
142
- def apply(finding, rules: list[Rule], today: date | None = None):
143
- """Drop suppressed reasons and re-derive the verdict.
144
-
145
- Returns the finding and the rules that fired, so the caller can say what was
146
- suppressed rather than silently showing a clean result.
147
- """
148
- from .assess import Verdict # noqa: PLC0415 - avoids a circular import
149
-
150
- if not rules:
151
- return finding, []
152
-
153
- kept, used = [], []
154
- for reason in finding.reasons:
155
- rule_id = getattr(reason, "rule", None)
156
- match = next(
157
- (
158
- r
159
- for r in rules
160
- if not r.expired(today)
161
- and r.matches(finding.name, finding.ecosystem, rule_id)
162
- ),
163
- None,
164
- )
165
- if match is None:
166
- kept.append(reason)
167
- else:
168
- used.append(match)
169
-
170
- if not used:
171
- return finding, []
172
-
173
- finding.reasons = kept
174
- if not kept:
175
- finding.verdict = Verdict.OK
176
- elif finding.verdict is Verdict.BLOCK and not any(
177
- getattr(r, "rule", None) in finding.blocking for r in kept
178
- ):
179
- # The reason that blocked is gone; what remains is only advisory.
180
- finding.verdict = Verdict.WARN
181
- return finding, used
1
+ """Suppressing findings you have already decided about.
2
+
3
+ One false positive is enough for a team to remove a security check from CI, so
4
+ there has to be a way to say "we know about this one". Three things make that
5
+ safe rather than a hole:
6
+
7
+ **The file is never read from the project directory.** ghostpkg is meant to sit
8
+ in front of a coding agent, and an agent with shell access can edit files in the
9
+ repository it is working on. A `.ghostpkgignore` next to the code would be a
10
+ suppression list the thing being guarded can rewrite. So the file comes from an
11
+ explicit path, an environment variable, or the user's own config directory --
12
+ never from the tree being scanned.
13
+
14
+ **A reason is required.** An entry nobody can justify in one sentence is an
15
+ entry nobody will dare remove later.
16
+
17
+ **A malformed file is an error, not a shrug.** Degrading quietly to "no
18
+ suppressions" would be the safe direction; degrading quietly to "no protection"
19
+ would not, and from the outside the two are indistinguishable. Failing loudly
20
+ is the only honest option.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import fnmatch
26
+ import json
27
+ import os
28
+ import sys
29
+ from dataclasses import dataclass
30
+ from datetime import date
31
+ from pathlib import Path
32
+
33
+ CONFIG_ENV = "GHOSTPKG_CONFIG"
34
+ CONFIG_NAME = "ignore.json"
35
+
36
+
37
+ class PolicyError(ValueError):
38
+ """The ignore file exists but could not be used."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Rule:
43
+ """One suppression. `package` and `rule` accept glob patterns."""
44
+
45
+ package: str
46
+ reason: str
47
+ ecosystem: str = "*"
48
+ rule: str = "*"
49
+ expires: date | None = None
50
+
51
+ def expired(self, today: date | None = None) -> bool:
52
+ return self.expires is not None and self.expires < (today or date.today())
53
+
54
+ def matches(self, name: str, ecosystem: str, rule_id: str | None) -> bool:
55
+ if self.ecosystem != "*" and self.ecosystem != ecosystem:
56
+ return False
57
+ if self.rule != "*" and not fnmatch.fnmatch(rule_id or "", self.rule):
58
+ return False
59
+ return fnmatch.fnmatch(name.lower(), self.package.lower())
60
+
61
+
62
+ def config_dir() -> Path:
63
+ if sys.platform == "win32":
64
+ base = os.environ.get("APPDATA") or os.path.expanduser("~")
65
+ return Path(base) / "ghostpkg"
66
+ if sys.platform == "darwin":
67
+ return Path(os.path.expanduser("~/Library/Application Support")) / "ghostpkg"
68
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
69
+ return Path(base) / "ghostpkg"
70
+
71
+
72
+ def locate(explicit: str | Path | None = None) -> Path | None:
73
+ """Where the ignore file lives, or None. Never the project directory."""
74
+ if explicit:
75
+ return Path(explicit)
76
+ from_env = os.environ.get(CONFIG_ENV)
77
+ if from_env:
78
+ return Path(from_env)
79
+ candidate = config_dir() / CONFIG_NAME
80
+ return candidate if candidate.is_file() else None
81
+
82
+
83
+ def _parse_date(value, where: str) -> date | None:
84
+ if value is None:
85
+ return None
86
+ try:
87
+ return date.fromisoformat(str(value))
88
+ except ValueError:
89
+ raise PolicyError(
90
+ f"{where}: 'expires' must be a date like 2026-12-31, got {value!r}"
91
+ ) from None
92
+
93
+
94
+ def parse(text: str, origin: str = "ignore file") -> list[Rule]:
95
+ try:
96
+ data = json.loads(text)
97
+ except ValueError as exc:
98
+ raise PolicyError(f"{origin}: not valid JSON ({exc})") from None
99
+
100
+ entries = data.get("ignore") if isinstance(data, dict) else data
101
+ if not isinstance(entries, list):
102
+ raise PolicyError(f"{origin}: expected a list under 'ignore'")
103
+
104
+ rules: list[Rule] = []
105
+ for index, entry in enumerate(entries, 1):
106
+ where = f"{origin}, entry {index}"
107
+ if not isinstance(entry, dict):
108
+ raise PolicyError(f"{where}: expected an object")
109
+ package = entry.get("package")
110
+ reason = entry.get("reason")
111
+ if not package or not isinstance(package, str):
112
+ raise PolicyError(f"{where}: 'package' is required")
113
+ if not reason or not isinstance(reason, str):
114
+ raise PolicyError(
115
+ f"{where}: 'reason' is required -- a suppression nobody can "
116
+ f"justify is one nobody will dare remove"
117
+ )
118
+ rules.append(
119
+ Rule(
120
+ package=package,
121
+ reason=reason,
122
+ ecosystem=str(entry.get("ecosystem") or "*"),
123
+ rule=str(entry.get("rule") or "*"),
124
+ expires=_parse_date(entry.get("expires"), where),
125
+ )
126
+ )
127
+ return rules
128
+
129
+
130
+ def load(explicit: str | Path | None = None) -> tuple[list[Rule], Path | None]:
131
+ """Read the ignore file. Missing is fine; malformed is not."""
132
+ path = locate(explicit)
133
+ if path is None:
134
+ return [], None
135
+ try:
136
+ text = path.read_text(encoding="utf-8-sig")
137
+ except OSError as exc:
138
+ raise PolicyError(f"could not read {path}: {exc}") from None
139
+ return parse(text, str(path)), path
140
+
141
+
142
+ def apply(finding, rules: list[Rule], today: date | None = None):
143
+ """Drop suppressed reasons and re-derive the verdict.
144
+
145
+ Returns the finding and the rules that fired, so the caller can say what was
146
+ suppressed rather than silently showing a clean result.
147
+ """
148
+ from .assess import Verdict # noqa: PLC0415 - avoids a circular import
149
+
150
+ if not rules:
151
+ return finding, []
152
+
153
+ kept, used = [], []
154
+ for reason in finding.reasons:
155
+ rule_id = getattr(reason, "rule", None)
156
+ match = next(
157
+ (
158
+ r
159
+ for r in rules
160
+ if not r.expired(today)
161
+ and r.matches(finding.name, finding.ecosystem, rule_id)
162
+ ),
163
+ None,
164
+ )
165
+ if match is None:
166
+ kept.append(reason)
167
+ else:
168
+ used.append(match)
169
+
170
+ if not used:
171
+ return finding, []
172
+
173
+ finding.reasons = kept
174
+ if not kept:
175
+ finding.verdict = Verdict.OK
176
+ elif finding.verdict is Verdict.BLOCK and not any(
177
+ getattr(r, "rule", None) in finding.blocking for r in kept
178
+ ):
179
+ # The reason that blocked is gone; what remains is only advisory.
180
+ finding.verdict = Verdict.WARN
181
+ return finding, used
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ghostpkg"
7
- version = "0.24.2"
7
+ version = "0.24.4"
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"
@@ -189,3 +189,30 @@ class TestTheHookIsWellFormed:
189
189
  )
190
190
  def test_unrelated_files_are_not_matched(self, path):
191
191
  assert not re.search(hook()["files"], path)
192
+
193
+
194
+ class TestTheDocumentedVersionIsTheRealOne:
195
+ """Every release script replaced the *previous* version string, so a
196
+ snippet left on an older one was never touched again. `README.md` sat on
197
+ `@v0.24.0` through two releases, and the GitHub Marketplace renders that
198
+ snippet -- so the listing told people to pin a version that predates the
199
+ fixes it advertises."""
200
+
201
+ @pytest.mark.parametrize(
202
+ "path", ["README.md", "README.en.md", ".pre-commit-hooks.yaml"]
203
+ )
204
+ def test_every_pinned_version_matches_the_package(self, path):
205
+ from ghostpkg import __version__
206
+
207
+ text = read(ROOT / path)
208
+ pinned = set(re.findall(r"M1rwana12/ghostpkg@v([0-9.]+)", text))
209
+ pinned |= set(re.findall(r"rev: v([0-9.]+)", text))
210
+ assert pinned <= {__version__}, f"{path} pins {pinned}, package is {__version__}"
211
+
212
+ def test_at_least_one_snippet_is_pinned(self):
213
+ """A guard on the guard: if the patterns stop matching, the test above
214
+ passes for the wrong reason."""
215
+ from ghostpkg import __version__
216
+
217
+ text = read(ROOT / "README.en.md") + read(ROOT / "README.md")
218
+ assert f"M1rwana12/ghostpkg@v{__version__}" in text
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