allelix 2.2.2__tar.gz → 2.2.3__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 (87) hide show
  1. {allelix-2.2.2 → allelix-2.2.3}/PKG-INFO +5 -3
  2. {allelix-2.2.2 → allelix-2.2.3}/README.md +4 -2
  3. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/gnomad.py +21 -26
  4. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/twentythreeandme.py +27 -5
  5. {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/PKG-INFO +5 -3
  6. {allelix-2.2.2 → allelix-2.2.3}/pyproject.toml +1 -1
  7. allelix-2.2.3/tests/test_changelog_sanitize.py +179 -0
  8. allelix-2.2.2/tests/test_changelog_sanitize.py +0 -83
  9. {allelix-2.2.2 → allelix-2.2.3}/LICENSE +0 -0
  10. {allelix-2.2.2 → allelix-2.2.3}/allelix/__init__.py +0 -0
  11. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/__init__.py +0 -0
  12. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/alphamissense.py +0 -0
  13. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/base.py +0 -0
  14. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/cadd.py +0 -0
  15. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/clinvar.py +0 -0
  16. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/gwas.py +0 -0
  17. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/pharmgkb.py +0 -0
  18. {allelix-2.2.2 → allelix-2.2.3}/allelix/annotators/snpedia.py +0 -0
  19. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/__init__.py +0 -0
  20. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/_helpers.py +0 -0
  21. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/_options.py +0 -0
  22. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/analyze.py +0 -0
  23. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/config.py +0 -0
  24. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/db.py +0 -0
  25. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/focused.py +0 -0
  26. {allelix-2.2.2 → allelix-2.2.3}/allelix/cli/utility.py +0 -0
  27. {allelix-2.2.2 → allelix-2.2.3}/allelix/compare.py +0 -0
  28. {allelix-2.2.2 → allelix-2.2.3}/allelix/config.py +0 -0
  29. {allelix-2.2.2 → allelix-2.2.3}/allelix/data/__init__.py +0 -0
  30. {allelix-2.2.2 → allelix-2.2.3}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
  31. {allelix-2.2.2 → allelix-2.2.3}/allelix/data/high_value_snps.yaml +0 -0
  32. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/__init__.py +0 -0
  33. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/_versions.py +0 -0
  34. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/alphamissense_loader.py +0 -0
  35. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/cadd_loader.py +0 -0
  36. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/cpic_loader.py +0 -0
  37. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/gnomad_loader.py +0 -0
  38. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/gwas_loader.py +0 -0
  39. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/loader_utils.py +0 -0
  40. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/manager.py +0 -0
  41. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/pharmgkb_loader.py +0 -0
  42. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/schema.py +0 -0
  43. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/snpedia_loader.py +0 -0
  44. {allelix-2.2.2 → allelix-2.2.3}/allelix/databases/snpedia_parser.py +0 -0
  45. {allelix-2.2.2 → allelix-2.2.3}/allelix/exporters/__init__.py +0 -0
  46. {allelix-2.2.2 → allelix-2.2.3}/allelix/exporters/plink.py +0 -0
  47. {allelix-2.2.2 → allelix-2.2.3}/allelix/models.py +0 -0
  48. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/__init__.py +0 -0
  49. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/_helpers.py +0 -0
  50. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ancestrydna.py +0 -0
  51. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/base.py +0 -0
  52. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ftdna.py +0 -0
  53. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ftdna_famfinder.py +0 -0
  54. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/ftdna_illumina.py +0 -0
  55. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/livingdna.py +0 -0
  56. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/myhappygenes.py +0 -0
  57. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/myheritage.py +0 -0
  58. {allelix-2.2.2 → allelix-2.2.3}/allelix/parsers/vcf.py +0 -0
  59. {allelix-2.2.2 → allelix-2.2.3}/allelix/py.typed +0 -0
  60. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/__init__.py +0 -0
  61. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/_pipeline.py +0 -0
  62. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/diff.py +0 -0
  63. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/high_value.py +0 -0
  64. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/html.py +0 -0
  65. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/json_report.py +0 -0
  66. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/methylation.py +0 -0
  67. {allelix-2.2.2 → allelix-2.2.3}/allelix/reports/terminal.py +0 -0
  68. {allelix-2.2.2 → allelix-2.2.3}/allelix/utils/__init__.py +0 -0
  69. {allelix-2.2.2 → allelix-2.2.3}/allelix/utils/allele.py +0 -0
  70. {allelix-2.2.2 → allelix-2.2.3}/allelix/utils/build_detect.py +0 -0
  71. {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/SOURCES.txt +0 -0
  72. {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/dependency_links.txt +0 -0
  73. {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/entry_points.txt +0 -0
  74. {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/requires.txt +0 -0
  75. {allelix-2.2.2 → allelix-2.2.3}/allelix.egg-info/top_level.txt +0 -0
  76. {allelix-2.2.2 → allelix-2.2.3}/setup.cfg +0 -0
  77. {allelix-2.2.2 → allelix-2.2.3}/tests/test_cli.py +0 -0
  78. {allelix-2.2.2 → allelix-2.2.3}/tests/test_cli_helpers.py +0 -0
  79. {allelix-2.2.2 → allelix-2.2.3}/tests/test_compare.py +0 -0
  80. {allelix-2.2.2 → allelix-2.2.3}/tests/test_config.py +0 -0
  81. {allelix-2.2.2 → allelix-2.2.3}/tests/test_end_to_end.py +0 -0
  82. {allelix-2.2.2 → allelix-2.2.3}/tests/test_extract_rsidless_gh128.py +0 -0
  83. {allelix-2.2.2 → allelix-2.2.3}/tests/test_mock_data_invariants.py +0 -0
  84. {allelix-2.2.2 → allelix-2.2.3}/tests/test_models.py +0 -0
  85. {allelix-2.2.2 → allelix-2.2.3}/tests/test_original_rsid_never_serializes.py +0 -0
  86. {allelix-2.2.2 → allelix-2.2.3}/tests/test_registry.py +0 -0
  87. {allelix-2.2.2 → allelix-2.2.3}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.2.2
3
+ Version: 2.2.3
4
4
  Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
5
5
  Author: Allelix
6
6
  Maintainer-email: dial481 <dial481@users.noreply.github.com>
@@ -190,8 +190,10 @@ disease findings. Wherever you write them via `--output <path>`,
190
190
  that's where they sit until you delete them. Allelix doesn't
191
191
  auto-clean and won't warn you when you write to `/tmp/` or any
192
192
  other shared location. Treat the files as personal data: read them,
193
- move them somewhere you control, or delete when you're done. A
194
- data-lifecycle subcommand is planned for v2.1.
193
+ move them somewhere you control, or delete when you're done.
194
+ `allelix db clean` (shipped in v2.2.0) handles the cache side of
195
+ the lifecycle; output reports are still the user's responsibility
196
+ to manage.
195
197
 
196
198
  ## Configuration
197
199
 
@@ -151,8 +151,10 @@ disease findings. Wherever you write them via `--output <path>`,
151
151
  that's where they sit until you delete them. Allelix doesn't
152
152
  auto-clean and won't warn you when you write to `/tmp/` or any
153
153
  other shared location. Treat the files as personal data: read them,
154
- move them somewhere you control, or delete when you're done. A
155
- data-lifecycle subcommand is planned for v2.1.
154
+ move them somewhere you control, or delete when you're done.
155
+ `allelix db clean` (shipped in v2.2.0) handles the cache side of
156
+ the lifecycle; output reports are still the user's responsibility
157
+ to manage.
156
158
 
157
159
  ## Configuration
158
160
 
@@ -247,34 +247,29 @@ class GnomadAnnotator(Annotator):
247
247
  return {}
248
248
  conn = self._connection()
249
249
  result: dict[tuple[str, int], list[tuple[str, str, str]]] = {}
250
- # GH #134: issue a single combined SQL statement per chunk
251
- # using SQLite's row-value ``(chrom, pos) IN ((?, ?), ...)``
252
- # form instead of grouping by chromosome and issuing one
253
- # statement per (chrom, chunk) pair. On WGS input each batch
254
- # touches all ~24 chromosomes; the per-chrom shape was a 24x
255
- # query multiplier the new ``idx_gnomad_position`` index could
256
- # not amortize away. The row-value IN form keeps the same
257
- # carrier-rule contract — both ``chrom`` and ``pos`` must
258
- # match the input pair simultaneously — and the
259
- # ``idx_gnomad_position`` covering index on (chrom, pos)
260
- # serves the lookup directly. ``_BULK_BATCH_SIZE`` here
261
- # refers to (chrom, pos) PAIRS not single ints, so each
262
- # chunk binds 2 * batch_size parameters.
250
+ # Issue a single combined SQL statement per chunk using SQLite's
251
+ # row-value ``(chrom, pos) IN ((?, ?), ...)`` form instead of
252
+ # grouping by chromosome and issuing one statement per
253
+ # (chrom, chunk) pair. On WGS input each batch touches all ~24
254
+ # chromosomes; the per-chrom shape was a 24x query multiplier
255
+ # the ``idx_gnomad_position`` index could not amortize away. The
256
+ # row-value IN form keeps the same carrier-rule contract — both
257
+ # ``chrom`` and ``pos`` must match the input pair simultaneously
258
+ # — and the ``idx_gnomad_position`` covering index on
259
+ # ``(chrom, pos)`` serves the lookup directly.
263
260
  #
264
- # SQLite parameter-limit awareness: 2 * _BULK_BATCH_SIZE =
265
- # 1800 bind parameters per chunk. The historical SQLite
266
- # ``SQLITE_MAX_VARIABLE_NUMBER`` default was 999, raised to
267
- # 32766 in SQLite 3.32 (2020-05-22). Every Python version
268
- # Allelix supports (>= 3.11) ships with SQLite >= 3.34, so
269
- # the 1800-param chunk is well within the modern limit. If a
270
- # future change bumps ``_BULK_BATCH_SIZE`` toward 16000 or
271
- # the project drops Python's bundled SQLite for a system
272
- # ``libsqlite3`` of unknown vintage, re-check this against
273
- # the runtime ``sqlite3.sqlite_version`` before assuming
274
- # capacity.
261
+ # Each input pair binds 2 SQLite parameters (chrom + pos), so
262
+ # the chunk size is ``_BULK_BATCH_SIZE // 2`` to stay inside the
263
+ # 999-param convention every other annotator and gnomAD's own
264
+ # multi-param queries respect (``bulk_lookup_by_alt`` does the
265
+ # same 2-param shape with the same ``// 2``;
266
+ # ``bulk_lookup_by_position`` uses ``// 4`` for its 4-param
267
+ # shape, same convention scaled). Net effective batch size:
268
+ # 450 pairs x 2 = 900 params per chunk.
269
+ chunk_pairs = _BULK_BATCH_SIZE // 2
275
270
  position_list = list(positions)
276
- for i in range(0, len(position_list), _BULK_BATCH_SIZE):
277
- batch = position_list[i : i + _BULK_BATCH_SIZE]
271
+ for i in range(0, len(position_list), chunk_pairs):
272
+ batch = position_list[i : i + chunk_pairs]
278
273
  placeholders = ",".join("(?, ?)" for _ in batch)
279
274
  params: list[str | int] = []
280
275
  for chrom, pos in batch:
@@ -42,7 +42,26 @@ if TYPE_CHECKING:
42
42
 
43
43
  logger = logging.getLogger(__name__)
44
44
 
45
- _CANONICAL_PREFIX = "# This data file generated by 23andMe"
45
+ # Known canonical first-line banner variants 23andMe wraps raw exports in.
46
+ # The original v2/v3/v4-era exports used the verbatim ``generated by`` form;
47
+ # the June 2026 v5 export prose was edited to ``is generated by`` (with a
48
+ # trailing period in some samples). Match either; future vendor banner
49
+ # edits get a new entry here.
50
+ #
51
+ # Why a tuple of exact prefixes instead of a loose ``"23andMe" in line``
52
+ # substring match: a bare substring check false-positives on transcoded
53
+ # fixtures and third-party tool output. Files that strip the canonical
54
+ # banner entirely (WGSExtract output, Genetic Lifehacks converter output,
55
+ # privacy-conscious users who delete the comment block) are out of scope
56
+ # for this parser by design — they are handled by ``GenericTsvParser`` at
57
+ # the end of the dispatch chain (planned, see GH #146 / GH #127). That
58
+ # two-layer design keeps banner-bearing 23andMe files attributed to
59
+ # "23andMe" and banner-less 23andMe-shape files attributed to "generic-tsv"
60
+ # without guessing a vendor.
61
+ _CANONICAL_PREFIXES: tuple[str, ...] = (
62
+ "# This data file generated by 23andMe",
63
+ "# This data file is generated by 23andMe",
64
+ )
46
65
  SNIFF_LINE_LIMIT = 50
47
66
  EXPECTED_COLUMNS = 4
48
67
 
@@ -58,10 +77,13 @@ class TwentyThreeAndMeParser(GenotypeParser):
58
77
  def can_parse(self, file_path: Path) -> bool:
59
78
  """Recognize a real 23andMe export by its canonical first-line header.
60
79
 
61
- Real exports start with ``# This data file generated by 23andMe at: ...``.
80
+ Real exports start with one of the variants in ``_CANONICAL_PREFIXES``:
81
+ the historical ``# This data file generated by 23andMe at: ...`` form
82
+ or the June 2026 v5 ``# This data file is generated by 23andMe.`` form.
62
83
  Some files have user-prepended comments before the canonical line; the
63
- fallback loop scans up to ``SNIFF_LINE_LIMIT`` comment lines. A bare
64
- "23andMe" mention without the canonical prefix is rejected.
84
+ scan loop reads up to ``SNIFF_LINE_LIMIT`` comment lines. A bare
85
+ "23andMe" substring mention without one of the canonical prefixes is
86
+ rejected (see ``_CANONICAL_PREFIXES`` rationale).
65
87
  """
66
88
  try:
67
89
  with file_path.open("r", encoding="utf-8") as fh:
@@ -71,7 +93,7 @@ class TwentyThreeAndMeParser(GenotypeParser):
71
93
  return False
72
94
  if not line.startswith("#"):
73
95
  return False
74
- if line.startswith(_CANONICAL_PREFIX):
96
+ if any(line.startswith(p) for p in _CANONICAL_PREFIXES):
75
97
  return True
76
98
  except (OSError, UnicodeDecodeError):
77
99
  return False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.2.2
3
+ Version: 2.2.3
4
4
  Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
5
5
  Author: Allelix
6
6
  Maintainer-email: dial481 <dial481@users.noreply.github.com>
@@ -190,8 +190,10 @@ disease findings. Wherever you write them via `--output <path>`,
190
190
  that's where they sit until you delete them. Allelix doesn't
191
191
  auto-clean and won't warn you when you write to `/tmp/` or any
192
192
  other shared location. Treat the files as personal data: read them,
193
- move them somewhere you control, or delete when you're done. A
194
- data-lifecycle subcommand is planned for v2.1.
193
+ move them somewhere you control, or delete when you're done.
194
+ `allelix db clean` (shipped in v2.2.0) handles the cache side of
195
+ the lifecycle; output reports are still the user's responsibility
196
+ to manage.
195
197
 
196
198
  ## Configuration
197
199
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "allelix"
7
- version = "2.2.2"
7
+ version = "2.2.3"
8
8
  description = "Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,179 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Allelix
3
+ """Release-gate sanitize guard for the public CHANGELOG.
4
+
5
+ GH #118: CHANGELOG.md ships verbatim from the private allelix-dev
6
+ repo to the public allelix repo and PyPI. Bare `#NN` references
7
+ auto-link on GitHub against the surrounding repo's numbering — on
8
+ the public repo they ALL 404, because the issues/PRs only exist on
9
+ the private tracker. Same hazard for an `### Internal` section,
10
+ which is forensic provenance no public user needs.
11
+
12
+ These two assertions are the structural guard the issue requires:
13
+ - any bare issue/PR reference (#NN) fails the gate
14
+ - any `### Internal` heading fails the gate
15
+
16
+ The guard runs on every fast-tier pytest pass, so a PR that
17
+ re-introduces either form gets caught at PR time, not at tag time.
18
+
19
+ If you genuinely need internal traceability for an entry, put the
20
+ issue number in the git commit body (the private dev branch keeps
21
+ the squash commit + the private-only release commit body — both
22
+ retain forensic context for anyone tracing history). Public users
23
+ read the CHANGELOG; everything in there must stand on its own.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from pathlib import Path
30
+
31
+ import pytest
32
+
33
+ _CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
34
+ _ISSUE_REF_PATTERN = re.compile(r"#\d+\b")
35
+ _INTERNAL_HEADING_PATTERN = re.compile(r"^###\s+Internal\b", re.MULTILINE)
36
+
37
+
38
+ @pytest.fixture(scope="module")
39
+ def changelog_text() -> str:
40
+ return _CHANGELOG.read_text(encoding="utf-8")
41
+
42
+
43
+ def test_changelog_has_no_issue_or_pr_references(changelog_text: str) -> None:
44
+ """GH #118: every `#NN` token in CHANGELOG.md auto-links to the
45
+ surrounding repo on GitHub. Private-repo issue/PR numbers don't
46
+ exist on the public repo, so every shipped `#NN` is a dead link.
47
+
48
+ If you need to credit an issue, restructure the prose so it
49
+ stands without the tracker number, and put the reference in the
50
+ git commit body (forensic context the squash commit retains).
51
+ """
52
+ matches = _ISSUE_REF_PATTERN.findall(changelog_text)
53
+ assert not matches, (
54
+ f"CHANGELOG.md contains {len(matches)} bare issue/PR reference(s) "
55
+ f"that would become dead links on the public repo: "
56
+ f"{sorted(set(matches))[:10]}{'…' if len(set(matches)) > 10 else ''}. "
57
+ f"Strip them (forensic context lives in the git commit body, not "
58
+ f"in the user-facing CHANGELOG)."
59
+ )
60
+
61
+
62
+ def test_changelog_has_no_internal_section(changelog_text: str) -> None:
63
+ """GH #118: an `### Internal` heading carries forensic provenance
64
+ (architectural notes, plumbing details, fixture changes) that no
65
+ public user needs. The squash commit on main + the private
66
+ release commit body both retain this context for anyone tracing
67
+ history.
68
+ """
69
+ matches = _INTERNAL_HEADING_PATTERN.findall(changelog_text)
70
+ assert not matches, (
71
+ "CHANGELOG.md contains an '### Internal' heading. Move that "
72
+ "content into the squash commit body on main (the load-bearing "
73
+ "forensic surface) and drop the heading from the CHANGELOG."
74
+ )
75
+
76
+
77
+ def test_changelog_keep_a_changelog_header_intact(changelog_text: str) -> None:
78
+ """Light sanity check that the file structure isn't accidentally
79
+ truncated by an over-aggressive sanitize pass — the Keep a
80
+ Changelog header should always be present at the top."""
81
+ first_lines = "\n".join(changelog_text.splitlines()[:3])
82
+ assert "# Changelog" in first_lines
83
+ assert "Keep a Changelog" in changelog_text
84
+
85
+
86
+ # Drift-vulnerable count patterns. These match the three shapes that have
87
+ # historically shipped in release content and silently rotted across DB
88
+ # refreshes: panel-coverage ratios ("3/20 found"), timing arrows
89
+ # ("~165s → ~133s"), and explicit before/after pairs ("went from X to Y").
90
+ # Each is an absolute measurement against a specific DB snapshot — the same
91
+ # code on a refreshed DB will produce different numbers, so any reader
92
+ # (or reviewer) re-running the analysis later sees what looks like a
93
+ # regression.
94
+ #
95
+ # The patterns are deliberately narrow: they flag comparison constructs
96
+ # specifically, not bare numbers in infrastructure descriptions ("10
97
+ # non-blank lines", "57M-row cache", "999-param convention"). The
98
+ # harness output format (``✓ <key>: N annotations, M unique keys, all
99
+ # invariants hold``) deliberately does not match, because that line IS
100
+ # the drift-tolerant signal — see ``test_data/check_ground_truth.py``.
101
+ _DRIFT_PATTERNS = (
102
+ re.compile(r"\d+/\d+ found"), # "3/20 found"
103
+ re.compile(r"~\d+\s*s\b.*→.*~\d+\s*s\b"), # "~165s → ~133s"
104
+ re.compile(r"went from \d", re.IGNORECASE), # "went from X to Y"
105
+ )
106
+
107
+ # Caveat keywords that opt a drift-pattern line back in: if a paragraph
108
+ # contains a comparison count AND one of these keywords nearby, the
109
+ # author has explicitly tagged the measurement as a snapshot. The check
110
+ # is per-section (not per-line) so a single calibration paragraph at
111
+ # the top of an [Unreleased] block covers all measurements inside.
112
+ _SNAPSHOT_CAVEAT_KEYWORDS = (
113
+ "snapshot",
114
+ "drift",
115
+ "point-in-time",
116
+ "harness floor",
117
+ "harness invariant",
118
+ "ground-truth harness",
119
+ )
120
+
121
+
122
+ def _extract_unreleased_section(text: str) -> str:
123
+ """Return the ``[Unreleased]`` block (without surrounding sections).
124
+
125
+ The guard scopes drift-pattern enforcement to ``[Unreleased]`` only.
126
+ Already-shipped release sections are immutable history; rewriting
127
+ them would create churn for no reader benefit (the corresponding
128
+ PyPI sdist and squash commit body are forever pinned regardless).
129
+ The discipline is forward-looking — every release shipped from this
130
+ commit onward gets the check.
131
+ """
132
+ marker_open = "\n## [Unreleased]"
133
+ if marker_open not in text:
134
+ return ""
135
+ after = text.split(marker_open, 1)[1]
136
+ # The block runs until the next ``## [`` heading or EOF.
137
+ next_section = re.search(r"\n## \[", after)
138
+ if next_section:
139
+ return after[: next_section.start()]
140
+ return after
141
+
142
+
143
+ def test_changelog_unreleased_has_no_drift_vulnerable_counts(
144
+ changelog_text: str,
145
+ ) -> None:
146
+ """GH #145-era discipline: raw drift-vulnerable counts in
147
+ ``[Unreleased]`` must carry an explicit snapshot caveat, or the
148
+ measurement must be quoted via the §19 ground-truth harness output
149
+ format (which is drift-tolerant by design).
150
+
151
+ See ``CONTRIBUTING.md`` § Release-content discipline for the
152
+ rationale and the two acceptable shapes.
153
+ """
154
+ unreleased = _extract_unreleased_section(changelog_text)
155
+ if not unreleased.strip():
156
+ # Empty [Unreleased] section is fine — nothing to validate.
157
+ return
158
+ drift_hits: list[str] = []
159
+ for pattern in _DRIFT_PATTERNS:
160
+ for match in pattern.finditer(unreleased):
161
+ drift_hits.append(match.group(0))
162
+ if not drift_hits:
163
+ return
164
+ # Any drift hit requires a snapshot caveat keyword anywhere in the
165
+ # [Unreleased] block. One calibration paragraph at the top of the
166
+ # block covers all measurements inside.
167
+ lowered = unreleased.lower()
168
+ if any(kw in lowered for kw in _SNAPSHOT_CAVEAT_KEYWORDS):
169
+ return
170
+ raise AssertionError(
171
+ f"CHANGELOG.md [Unreleased] contains {len(drift_hits)} drift-vulnerable "
172
+ f"count(s) without a snapshot caveat: {drift_hits[:5]}"
173
+ f"{'…' if len(drift_hits) > 5 else ''}. "
174
+ f"Either remove the raw counts, wrap them with an explicit snapshot "
175
+ f"caveat (any of: {', '.join(_SNAPSHOT_CAVEAT_KEYWORDS)}), or quote "
176
+ f"the §19 ground-truth harness output format instead "
177
+ f"(`✓ <key>: N annotations, M unique keys, all invariants hold`). "
178
+ f"See CONTRIBUTING.md § Release-content discipline."
179
+ )
@@ -1,83 +0,0 @@
1
- # SPDX-License-Identifier: AGPL-3.0-or-later
2
- # Copyright (C) 2026 Allelix
3
- """Release-gate sanitize guard for the public CHANGELOG.
4
-
5
- GH #118: CHANGELOG.md ships verbatim from the private allelix-dev
6
- repo to the public allelix repo and PyPI. Bare `#NN` references
7
- auto-link on GitHub against the surrounding repo's numbering — on
8
- the public repo they ALL 404, because the issues/PRs only exist on
9
- the private tracker. Same hazard for an `### Internal` section,
10
- which is forensic provenance no public user needs.
11
-
12
- These two assertions are the structural guard the issue requires:
13
- - any bare issue/PR reference (#NN) fails the gate
14
- - any `### Internal` heading fails the gate
15
-
16
- The guard runs on every fast-tier pytest pass, so a PR that
17
- re-introduces either form gets caught at PR time, not at tag time.
18
-
19
- If you genuinely need internal traceability for an entry, put the
20
- issue number in the git commit body (the private dev branch keeps
21
- the squash commit + the private-only release commit body — both
22
- retain forensic context for anyone tracing history). Public users
23
- read the CHANGELOG; everything in there must stand on its own.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import re
29
- from pathlib import Path
30
-
31
- import pytest
32
-
33
- _CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
34
- _ISSUE_REF_PATTERN = re.compile(r"#\d+\b")
35
- _INTERNAL_HEADING_PATTERN = re.compile(r"^###\s+Internal\b", re.MULTILINE)
36
-
37
-
38
- @pytest.fixture(scope="module")
39
- def changelog_text() -> str:
40
- return _CHANGELOG.read_text(encoding="utf-8")
41
-
42
-
43
- def test_changelog_has_no_issue_or_pr_references(changelog_text: str) -> None:
44
- """GH #118: every `#NN` token in CHANGELOG.md auto-links to the
45
- surrounding repo on GitHub. Private-repo issue/PR numbers don't
46
- exist on the public repo, so every shipped `#NN` is a dead link.
47
-
48
- If you need to credit an issue, restructure the prose so it
49
- stands without the tracker number, and put the reference in the
50
- git commit body (forensic context the squash commit retains).
51
- """
52
- matches = _ISSUE_REF_PATTERN.findall(changelog_text)
53
- assert not matches, (
54
- f"CHANGELOG.md contains {len(matches)} bare issue/PR reference(s) "
55
- f"that would become dead links on the public repo: "
56
- f"{sorted(set(matches))[:10]}{'…' if len(set(matches)) > 10 else ''}. "
57
- f"Strip them (forensic context lives in the git commit body, not "
58
- f"in the user-facing CHANGELOG)."
59
- )
60
-
61
-
62
- def test_changelog_has_no_internal_section(changelog_text: str) -> None:
63
- """GH #118: an `### Internal` heading carries forensic provenance
64
- (architectural notes, plumbing details, fixture changes) that no
65
- public user needs. The squash commit on main + the private
66
- release commit body both retain this context for anyone tracing
67
- history.
68
- """
69
- matches = _INTERNAL_HEADING_PATTERN.findall(changelog_text)
70
- assert not matches, (
71
- "CHANGELOG.md contains an '### Internal' heading. Move that "
72
- "content into the squash commit body on main (the load-bearing "
73
- "forensic surface) and drop the heading from the CHANGELOG."
74
- )
75
-
76
-
77
- def test_changelog_keep_a_changelog_header_intact(changelog_text: str) -> None:
78
- """Light sanity check that the file structure isn't accidentally
79
- truncated by an over-aggressive sanitize pass — the Keep a
80
- Changelog header should always be present at the top."""
81
- first_lines = "\n".join(changelog_text.splitlines()[:3])
82
- assert "# Changelog" in first_lines
83
- assert "Keep a Changelog" in changelog_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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes