fix: citation-surface version drift + version-consistency invariant 12 (#763)

* fix: citation-surface version drift + version-consistency invariant 12 (#754)

CITATION.cff and POSITIONING.md citation prose sat at 3.14.0 while the
suite moved to 3.20.1 (Zenodo v3.20.1 deposit exists — pure metadata
drift). Bump both to 3.20.1 and add lint invariant 12 so the drift class
fails CI: CITATION.cff version: and every (Version X.Y.Z) token in
POSITIONING.md must equal the suite version. Absent file = skip
(invariant-8 posture); present-but-versionless CITATION.cff = error.
6 new mutation tests (53 -> 59), full suite green, real-repo lint green.

Closes #754

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n3WG8Z3Us8fX8UhXS51Ki

* refactor: harden invariant 12 per 4-angle cleanup review

- CITATION.cff parsed as YAML (regex scrape misread quoted versions as
  drift); absence now errors like README.md so deletion cannot silently
  disable the invariant; version goes through the broad-capture +
  strict-semver idiom (non-canonical reported as such, not as drift)
- date-released gated against the CHANGELOG latest-entry date with the
  invariant-10 +/-7-day window (the second half of the #754 drift)
- POSITIONING token uses broad capture + strict validation so v-prefixed
  or truncated edits error instead of being filtered (pre-#169 lesson)
- regex moved to the numbered constants block; helpers moved to the test
  fixture header; surfaces wired into _write_aligned_fixture so every
  pass-case exercises invariant 12; class renamed TestCitationSurfaces
- 11 targeted tests (53 -> 64), green under pytest and unittest runners

Refs #754

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n3WG8Z3Us8fX8UhXS51Ki

* fix: harden invariant 12 date handling per codex review (2 P2)

- catch ValueError from PyYAML's timestamp constructor (impossible
  dates like 2026-02-30 raise ValueError, not YAMLError) so the lint
  reports instead of crashing
- reject datetime values (unquoted timestamps parse as a date SUBCLASS
  that would TypeError against the date baseline) as not-strict-
  YYYY-MM-DD errors
- 2 regression tests (64 -> 66), both verified red before the fix

Refs #754

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n3WG8Z3Us8fX8UhXS51Ki

* fix: require date-released in invariant 12 (codex round-2 P2)

An absent or null date-released silently skipped the freshness check,
so deleting the field disabled the invariant's date half. Absence now
errors (same posture as file absence). 1 regression test (66 -> 67),
verified red before the fix.

Refs #754

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n3WG8Z3Us8fX8UhXS51Ki

* fix: close invariant-12 silent-pass paths per codex round 3 (2 of 3 P2)

- POSITIONING clause captured whole then stripped + strictly validated,
  so a malformed edit ('(Version 3.4.0 )') errors instead of dropping
  out of the capture and passing a stale citation
- missing-version regression test isolated from the missing-date error
  (fixture keeps a valid date-released; asserts the specific diagnostic)
- duplicate-YAML-key finding adjudicated NOT-FIX under the threat model:
  last-wins matches every CFF consumer (GitHub cite widget / Zenodo /
  cffconvert), so a duplicate-key file renders the identical citation
  everywhere - untidiness, not drift; documented as a known limitation
  in the checker docstring
- 1 new test (68 total), suite green under both runners

Refs #754

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n3WG8Z3Us8fX8UhXS51Ki

* fix: catch empty POSITIONING payload + refresh CHANGELOG counts (codex round 4)

- capture class widened to [^)]* so '(Version )' reaches strict
  validation and errors instead of falling out of the capture
- CHANGELOG test counts refreshed (53 -> 69) and review provenance
  updated to the 5-round threat-model-bounded trajectory
- 1 regression test, verified red before the fix

Refs #754

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011n3WG8Z3Us8fX8UhXS51Ki

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Edward Cheng-I Wu
2026-08-17 15:57:19 +08:00
committed by GitHub
parent 07a2afd94c
commit 704b46d247
5 changed files with 407 additions and 6 deletions
+4
View File
@@ -10,6 +10,10 @@ All notable changes to this project will be documented in this file.
- **Stage capability/evidence matrix with enforceable claim ceilings (#745).** New `shared/contracts/capability/stage_capability_matrix.json` (`stage-capability-matrix/1.0`) records, per pipeline task family, the mechanism/contract status, deterministic-conformance status, behavioral-evidence provenance (eval, model, population, date, result), external/human outcome evidence, known exclusions, transport limits, the maximum currently licensed claim, and the next required evaluation. `scripts/check_stage_capability_matrix.py` (70 mutation tests, CI-wired) enforces the frozen task-family vocabulary shared with the #742 profile contract, non-collapsible evidence statuses in both directions (`DESIGNED` / `NOT_RUN` / `MEASURED` / `MIXED` / `OUT_OF_SCOPE`; an unrun eval can never carry numbers, and measurement provenance is refused on unmeasured rows), in-repo eval references, measurement-report binding with sibling-supersession detection (a re-measurement cannot leave a stale row silently authoritative), falsifiable `conformance_pinned_by` lints/tests on every `CI_GATED`/`TESTED` row, containment-checked verbatim claim anchors into README with a shipped row/anchor inventory lock, future-dated and stale measurements refused without a note, never-licensed stems and unmeasured percentages refused in claim prose, conservative effectiveness-language discipline on unmeasured rows, and byte-identity of the generated `docs/STAGE_CAPABILITY_MATRIX.md` view. Seeded with 13 rows covering all nine task families from the current evaluation record: 4 measured/mixed rows (RQ wording advisory, tortured-phrase mechanical conformance, seeded-defect reviewer panel including its currently-failing severity-agreement gate, revision claim-drift guard) and 9 designed/not-run rows whose ceilings state exactly that. The matrix makes evidence gaps inspectable; it does not itself improve stage performance, and registering a row licenses at most that row's recorded claim ceiling.
### Fixed
- **Citation-surface version drift + version-consistency invariant 12 (#754).** `CITATION.cff` and the `POSITIONING.md` citation prose had silently sat at 3.14.0 for six minor releases while the suite moved to 3.20.1 (the Zenodo archive itself was current — v3.20.1 deposit published 2026-08-16 — so this was pure metadata drift; root cause: the file was added 2026-06-15, after the version lint existed, and nothing forced new version-bearing surfaces into the lint's coverage). Both surfaces now read 3.20.1, and `scripts/check_version_consistency.py` gains invariant 12: CITATION.cff is parsed as YAML (a regex scrape would misread the legitimate quoted spelling as drift) with absence an error like README.md, its `version` gated through the broad-capture + strict-semver idiom, and its `date-released` compared against the latest CHANGELOG entry date with the invariant-10 ±7-day window (the second half of the same drift — the date had sat 6 weeks stale); every `(Version X.Y.Z)` token in POSITIONING.md must be canonical and equal the suite version, so a `v`-prefixed or truncated human edit errors instead of being silently filtered (the pre-#169 lesson). Both surfaces are wired into the aligned test fixture, so every pass-case test now exercises the invariant; 16 targeted mutation tests (53 → 69). Hardened through a 4-angle cleanup review plus a 5-round cross-model review under an explicit threat-model ceiling (crash-on-malformed-date, silent-skip, and silent-capture-dropout paths closed; the duplicate-YAML-key construction adjudicated as a documented known limitation — last-wins matches every CFF consumer). Filed from the 2026-08-17 ISO 42001-spirit audit (#761).
## [3.20.1] - 2026-08-15 — Contract-honesty hardening and bounded evaluation substrates
### Added
+4 -4
View File
@@ -27,9 +27,9 @@ keywords:
- peer-review
- scholarly-writing
license: CC-BY-NC-4.0
version: 3.14.0
date-released: 2026-07-02
version: 3.20.1
date-released: 2026-08-16
# Zenodo concept DOI — represents all versions, always resolves to the latest.
# (The v3.14.0 version DOI is minted by Zenodo on release; until then, cite the
# concept DOI above, which always resolves to the latest version.)
# (Per-release version DOIs are minted by Zenodo automatically; citing the
# concept DOI above always resolves to the latest version.)
doi: 10.5281/zenodo.20696614
+1 -1
View File
@@ -95,5 +95,5 @@ These reflect our policy intent. See the [CC BY-NC 4.0 license](https://creative
If you use ARS in your research, please cite it:
```
Wu, C.-I. (2026). Academic Research Skills for Claude Code (Version 3.14.0) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.20696614
Wu, C.-I. (2026). Academic Research Skills for Claude Code (Version 3.20.1) [Computer software]. Zenodo. https://doi.org/10.5281/zenodo.20696614
```
+128 -1
View File
@@ -35,6 +35,10 @@ Invariants enforced:
11. The newest "## vX.Y… Key Additions" heading in .claude/CLAUDE.md matches
the suite version (#487), compared at the heading's own precision —
`## v3.14 Key Additions` matches suite 3.14.0.
12. The citation surfaces track the release (#754): CITATION.cff `version`
equals the suite version and its `date-released` lies within ±7 days of
the latest CHANGELOG entry's date; every `(Version X.Y.Z)` token in
POSITIONING.md equals the suite version.
Tag gate (#487): `--tag <ref>` additionally requires the given git tag
(leading `v` optional) to equal the suite version — the one comparison
@@ -49,9 +53,11 @@ import json
import re
import sys
from collections import Counter
from datetime import date
from datetime import date, datetime
from pathlib import Path
import yaml
from _skill_lint import parse_frontmatter, FrontmatterError
@@ -123,6 +129,14 @@ NEXT_ENTRY_RE = re.compile(r"##\s+\[")
# Strict YYYY-MM-DD guard: date.fromisoformat() accepts compact 20260422 and
# ISO week dates, so a shape check gates before parsing (codex P2-2).
ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Invariant 12: the POSITIONING.md citation-prose token, e.g. `(Version 3.20.1)`.
# Captures the WHOLE parenthesized payload, then strip + strict-validate (the
# post-#169 idiom above): a non-canonical spelling (`v3.20.1`, `3.20.1-rc1`,
# bare `3.20`) or a malformed clause (`3.14.0 ` with a stray space, `3.14.0
# draft`) surfaces as an error instead of the clause dropping out of a
# narrower character class and silently passing (codex round-3 P2).
# CITATION.cff has no regex — it is YAML and is parsed as YAML.
POSITIONING_VERSION_RE = re.compile(r"\(Version\s+([^)]*)\)")
PIPELINE_SKILL_NAME = "academic-pipeline"
@@ -365,6 +379,9 @@ def check(root: Path, tag: str | None = None) -> list[str]:
errors.extend(_check_docs_versions(root, suite_version))
# Invariant 11: newest Key Additions heading matches the suite version.
errors.extend(_check_key_additions(claude_md, claude_text, suite_version))
# Invariant 12: citation surfaces (CITATION.cff / POSITIONING.md)
# track the suite version + release date.
errors.extend(_check_citation_surfaces(root, suite_version, latest_date))
# Tag gate: the pushed tag (when given) must equal the suite version. This
# runs OUTSIDE the `suite_version is not None` block on purpose: `--tag`
@@ -663,6 +680,116 @@ def _check_agent_count_claim(root: Path) -> list[str]:
return []
def _check_citation_surfaces(
root: Path, suite_version: str, latest_date: str | None
) -> list[str]:
"""Invariant 12: citation surfaces track the release (#754: both surfaces
had silently drifted six minor releases behind — the file was added
2026-06-15, after this lint existed, and never entered its coverage).
CITATION.cff is outward-facing release metadata like README.md, so its
ABSENCE is an error — a deleted/renamed file must not silently disable
the invariant (the invariant-4 marketplace-manifest lesson). It is YAML
and is parsed as YAML: a regex scrape would misread legitimate spellings
(`version: "3.20.1"`, trailing comments) as drift. `date-released` is
required — an absent/null field errors rather than silently disabling
the freshness half — and is compared against the latest CHANGELOG
entry's date with the invariant-10 ±LAST_UPDATED_MAX_DAYS window
(window skipped only when the CHANGELOG date is unavailable — that
already errored upstream).
POSITIONING.md is repo-specific prose: absence or a token-free file is a
skip (the token is the claim; no claim, no drift), but a present token
must be canonical and equal to the suite version.
Known limitation (accepted, 2026-08-17 threat-model adjudication):
duplicate YAML keys resolve last-wins, the same semantics every CFF
consumer applies (GitHub's cite widget, Zenodo, cffconvert), so a
duplicate-key file renders the identical citation everywhere —
untidiness, not drift. Guarding it would require a custom
duplicate-rejecting loader whose complexity the harm does not pay for."""
errors: list[str] = []
cff = root / "CITATION.cff"
if not cff.is_file():
errors.append(f"{cff}: not found")
else:
try:
data = yaml.safe_load(cff.read_text(encoding="utf-8"))
except (yaml.YAMLError, ValueError):
# ValueError: PyYAML's timestamp constructor raises it (not
# YAMLError) on impossible dates like 2026-02-30 (codex P2) —
# a lint reports drift, never crashes.
data = None
if not isinstance(data, dict):
errors.append(f"{cff}: not parseable as a YAML mapping")
else:
raw = data.get("version")
if raw is None:
errors.append(f"{cff}: no 'version' key found")
else:
token = str(raw)
if not _is_strict_semver(token):
errors.append(
f"{cff}: version token {token!r} is not a canonical "
"N.N.N or N.N.N.N version"
)
elif token != suite_version:
errors.append(
f"{cff}: version {token!r} does not match suite "
f"version {suite_version!r}"
)
released = data.get("date-released")
if released is None:
# Absent or null must error, not skip — deleting the field
# would otherwise disable the freshness half of the invariant
# (codex round-2 P2; same posture as file absence).
errors.append(f"{cff}: no 'date-released' key found")
elif latest_date is not None:
base = _parse_iso_date(latest_date)
if isinstance(released, datetime):
# CFF requires a bare YYYY-MM-DD. An unquoted timestamp
# parses as datetime — a date SUBCLASS that would
# TypeError against the date baseline below (codex P2) —
# so it routes to the not-strict error, never a crash.
rel = None
elif isinstance(released, date):
rel = released
else:
rel = _parse_iso_date(str(released))
if rel is None:
errors.append(
f"{cff}: date-released {str(released)!r} is not a "
"strict YYYY-MM-DD date"
)
elif (
base is not None
and abs((rel - base).days) > LAST_UPDATED_MAX_DAYS
):
errors.append(
f"{cff}: date-released {rel.isoformat()} is more than "
f"{LAST_UPDATED_MAX_DAYS} days from the latest "
f"CHANGELOG entry date {latest_date} (stale release "
"metadata)"
)
positioning = root / "POSITIONING.md"
if positioning.is_file():
for raw_token in POSITIONING_VERSION_RE.findall(
positioning.read_text(encoding="utf-8")
):
token = raw_token.strip()
if not _is_strict_semver(token):
errors.append(
f"{positioning}: citation prose token {token!r} is not a "
"canonical N.N.N or N.N.N.N version"
)
elif token != suite_version:
errors.append(
f"{positioning}: citation prose cites Version {token} but "
f"suite version is {suite_version!r}"
)
return errors
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
+270
View File
@@ -176,6 +176,36 @@ def _write_docs(
(docs / "PERFORMANCE.zh-TW.md").write_text("# 效能\n\n" + zh_body, encoding="utf-8")
def _write_cff(
root: Path, version: str, date_released: str = "2026-04-22"
) -> None:
"""Fixture CITATION.cff at `version` (invariant 12). The default
date-released matches the fixture CHANGELOG's latest-entry date so the
±7-day window passes unless a test drifts it."""
(root / "CITATION.cff").write_text(
textwrap.dedent(
f"""\
cff-version: 1.2.0
title: fixture
type: software
version: {version}
date-released: {date_released}
"""
),
encoding="utf-8",
)
def _write_positioning(root: Path, version: str) -> None:
"""Fixture POSITIONING.md citation prose at `version` (invariant 12)."""
(root / "POSITIONING.md").write_text(
"# Positioning\n\n"
"Wu, C.-I. (2026). Fixture Suite "
f"(Version {version}) [Computer software]. Zenodo.\n",
encoding="utf-8",
)
def _write_aligned_fixture(
root: Path,
last_updated: str | None = "2026-04-22",
@@ -205,6 +235,8 @@ def _write_aligned_fixture(
_write_changelog(root, latest_version="3.5.0")
_write_plugin_manifests(root, "3.5.0")
_write_readme(root, "3.5.0")
_write_cff(root, "3.5.0")
_write_positioning(root, "3.5.0")
# en has an extra plain H2 (translation asymmetry is allowed); the
# version-bearing heading is present in both and at a past version.
_write_docs(
@@ -229,6 +261,8 @@ def _write_aligned_fixture_v351(root: Path) -> None:
_write_changelog(root, latest_version="3.5.1")
_write_plugin_manifests(root, "3.5.1")
_write_readme(root, "3.5.1")
_write_cff(root, "3.5.1")
_write_positioning(root, "3.5.1")
_write_docs(
root,
en_h2=["Token usage", "Corpus ingestion (v3.4.0+)"],
@@ -1171,5 +1205,241 @@ class TestTagMatch(unittest.TestCase):
self.assertIn("v9.9.9", result.stdout)
class TestCitationSurfaces(unittest.TestCase):
"""Invariant 12: CITATION.cff (YAML-parsed, absence errors) and
POSITIONING.md citation prose (optional) track the release. The aligned
baseline is exercised by every pass-case test via _write_aligned_fixture,
which writes both surfaces."""
def test_cff_drift_fails(self) -> None:
"""CITATION.cff stuck below the suite version — the #754 drift class."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
_write_cff(root, "3.4.0")
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("CITATION.cff", result.stdout)
self.assertIn("3.4.0", result.stdout)
def test_cff_quoted_version_passes(self) -> None:
"""`version: "3.5.0"` is a legitimate CFF spelling — YAML parsing must
not report the quotes as drift (regression: the first regex-based
draft of this invariant did exactly that)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "CITATION.cff").write_text(
'cff-version: 1.2.0\ntitle: fixture\ntype: software\n'
'version: "3.5.0"\ndate-released: 2026-04-22\n',
encoding="utf-8",
)
result = _run(root)
self.assertEqual(
result.returncode, 0,
msg=f"stdout={result.stdout!r} stderr={result.stderr!r}",
)
def test_cff_noncanonical_version_fails_as_noncanonical(self) -> None:
"""A 2-segment `version: 3.5` must be reported as non-canonical, not
as a drift mismatch a maintainer would chase with a version bump."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "CITATION.cff").write_text(
"cff-version: 1.2.0\ntitle: fixture\ntype: software\n"
"version: 3.5\ndate-released: 2026-04-22\n",
encoding="utf-8",
)
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("canonical", result.stdout)
def test_cff_missing_version_key_fails(self) -> None:
"""A present CITATION.cff with no version key is malformed — error,
never a silent skip. date-released stays valid so this test fails
for the missing-version diagnostic specifically, not the
missing-date one (codex round-3 P2: conflated fixtures let a
missing-version regression hide behind the date error)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "CITATION.cff").write_text(
"cff-version: 1.2.0\ntitle: fixture\ntype: software\n"
"date-released: 2026-04-22\n",
encoding="utf-8",
)
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("no 'version' key found", result.stdout)
def test_cff_absent_fails(self) -> None:
"""CITATION.cff is outward-facing release metadata like README.md —
deleting it must not silently disable the invariant."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "CITATION.cff").unlink()
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("CITATION.cff", result.stdout)
def test_cff_stale_date_released_fails(self) -> None:
"""date-released more than 7 days from the CHANGELOG latest-entry date
— the second half of the #754 drift (version was bumped by hand while
the date sat 6 weeks stale)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
_write_cff(root, "3.5.0", date_released="2026-01-01")
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("date-released", result.stdout)
def test_cff_missing_date_released_fails(self) -> None:
"""Omitting (or nulling) date-released must error, not silently skip
the freshness check — deleting the field must not disable the
invariant (codex review round-2 P2; same posture as file absence)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "CITATION.cff").write_text(
"cff-version: 1.2.0\ntitle: fixture\ntype: software\n"
"version: 3.5.0\n",
encoding="utf-8",
)
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("date-released", result.stdout)
def test_cff_impossible_date_fails_cleanly(self) -> None:
"""`date-released: 2026-02-30` — PyYAML's timestamp constructor raises
ValueError, not YAMLError; the lint must emit an error, never crash
with a traceback (codex review P2)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
_write_cff(root, "3.5.0", date_released="2026-02-30")
result = _run(root)
self.assertEqual(
result.returncode, 1,
msg=f"stdout={result.stdout!r} stderr={result.stderr!r}",
)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("CITATION.cff", result.stdout)
def test_cff_timestamp_date_fails_as_nonstrict(self) -> None:
"""An unquoted `2026-04-22T00:00:00Z` parses as datetime — a date
SUBCLASS that would TypeError against the date baseline. It must be
rejected as not a strict YYYY-MM-DD, never crash (codex review P2)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
_write_cff(root, "3.5.0", date_released="2026-04-22T00:00:00Z")
result = _run(root)
self.assertEqual(
result.returncode, 1,
msg=f"stdout={result.stdout!r} stderr={result.stderr!r}",
)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("strict YYYY-MM-DD", result.stdout)
def test_cff_date_within_window_passes(self) -> None:
"""A date-released within the ±7-day window of the CHANGELOG date
(fixture CHANGELOG: 2026-04-22) passes."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
_write_cff(root, "3.5.0", date_released="2026-04-25")
result = _run(root)
self.assertEqual(
result.returncode, 0,
msg=f"stdout={result.stdout!r} stderr={result.stderr!r}",
)
def test_positioning_drift_fails(self) -> None:
"""POSITIONING.md citation prose stuck below the suite version."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
_write_positioning(root, "3.4.0")
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("POSITIONING.md", result.stdout)
self.assertIn("3.4.0", result.stdout)
def test_positioning_vprefixed_token_fails_as_noncanonical(self) -> None:
"""`(Version v3.5.0)` — the likeliest human edit of the citation line —
must error as non-canonical, not slip through a filtering regex (the
pre-#169 pattern this file's header warns about)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "POSITIONING.md").write_text(
"# Positioning\n\nCite: Fixture Suite (Version v3.5.0).\n",
encoding="utf-8",
)
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("canonical", result.stdout)
def test_positioning_malformed_clause_still_validated(self) -> None:
"""`(Version 3.4.0 )` — a stray space must not drop the clause out of
the capture and silently pass a stale citation (codex round-3 P2):
the payload is captured whole, stripped, then validated."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "POSITIONING.md").write_text(
"# Positioning\n\nCite: Fixture Suite (Version 3.4.0 ).\n",
encoding="utf-8",
)
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("3.4.0", result.stdout)
def test_positioning_empty_payload_fails(self) -> None:
"""`(Version )` — deleting the version mid-edit must reach strict
validation and error, not fall out of the capture (codex round-4
P2)."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "POSITIONING.md").write_text(
"# Positioning\n\nCite: Fixture Suite (Version ).\n",
encoding="utf-8",
)
result = _run(root)
self.assertEqual(result.returncode, 1, msg=f"stdout={result.stdout!r}")
self.assertIn("canonical", result.stdout)
def test_positioning_absent_passes(self) -> None:
"""POSITIONING.md is repo-specific prose — absence is a skip."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "POSITIONING.md").unlink()
result = _run(root)
self.assertEqual(
result.returncode, 0,
msg=f"stdout={result.stdout!r} stderr={result.stderr!r}",
)
def test_positioning_without_version_token_passes(self) -> None:
"""A POSITIONING.md with no `(Version X.Y.Z)` token has nothing to
check — the token is the claim; no claim, no drift."""
with TemporaryDirectory() as tmp:
root = Path(tmp)
_write_aligned_fixture(root)
(root / "POSITIONING.md").write_text(
"# Positioning\n\nNo citation prose here.\n", encoding="utf-8"
)
result = _run(root)
self.assertEqual(
result.returncode, 0,
msg=f"stdout={result.stdout!r} stderr={result.stderr!r}",
)
if __name__ == "__main__":
unittest.main()