mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
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:
committed by
GitHub
parent
07a2afd94c
commit
704b46d247
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user