linter: expand to 21 rules covering all 8 source types (v0.3.0)

Close the defense-in-depth gap flagged after iter 6: chapter, magazine,
newspaper, social_media, podcast, and video exemplars had ZERO applicable
linter rules, so their 100% linter pass rate was trivially true. Every
exemplar now has 3-6 structural rules firing.

Bump LINTER_VERSION to v0.3.0. Per harness discipline, this invalidates
prior run logs' scores for cross-version comparison (they stay in the
log as history). The re-baseline run still scores scalar 1.000, canary
1.000 on all 14 exemplars — formatter was already producing output that
matches the new structural conventions, so tightening the linter didn't
surface any regressions.

New rules (12):
 - chapter_book_title_italicized
 - chapter_in_book_marker
 - magazine_name_italicized
 - newspaper_name_italicized
 - periodical_comma_before_date  (magazine + newspaper; not journal)
 - social_media_post_quoted
 - social_media_platform_comma_date
 - podcast_series_italicized
 - podcast_episode_quoted
 - podcast_format_label
 - video_title_quoted             (in quotes, NOT italicized)
 - video_format_label

Extended:
 - article_title_quoted           now covers journal, magazine, newspaper,
                                  and accepts ?/! as terminal punctuation

Each rule has a positive + negative unit test with the CMOS/Purdue source
cited in the docstring. Tests: 48 → 78 (30 new).

Per-exemplar applicable-rule counts after this change:
 book    4    journal  5-6  web        3
 chapter 4    magazine 5    social     4
 podcast 5    video    4    newspaper  5
This commit is contained in:
cmos dev
2026-04-10 22:01:56 -04:00
parent ced9c73474
commit fe5e071066
2 changed files with 646 additions and 10 deletions
+304 -4
View File
@@ -24,7 +24,7 @@ from typing import Callable
from harness.score import Exemplar
LINTER_VERSION = "v0.2.0"
LINTER_VERSION = "v0.3.0"
@dataclass
@@ -153,20 +153,30 @@ def rule_journal_title_italicized(candidate: str, exemplar: Exemplar) -> RuleRes
)
_PERIODICAL_TYPES = ("journal", "magazine", "newspaper")
def rule_article_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Article titles are wrapped in double quotes, with the trailing period inside the quotes.
Source: Purdue OWL CMOS 18 NB poster page 3 "Bibliography: Basics"
"titles of articles, chapters, poems, etc. are placed in quotation marks."
CMOS also places the terminating period inside the closing quote
(American style).
(American style). Applies to journal, magazine, and newspaper articles.
"""
if exemplar.type != "journal":
if exemplar.type not in _PERIODICAL_TYPES:
return RuleResult(name="article_title_quoted", applicable=False, passed=True)
article_title = exemplar.canonical.get("article_title")
if not article_title:
return RuleResult(name="article_title_quoted", applicable=False, passed=True)
passed = f'"{article_title}."' in candidate
# Period OR question mark OR exclamation point closing — CMOS preserves
# terminal punctuation that's part of the title (e.g., "Are Flax Seeds All
# That?").
variants = (
f'"{article_title}."',
f'"{article_title}"' if article_title[-1] in "?!" else None,
)
passed = any(v is not None and v in candidate for v in variants)
return RuleResult(
name="article_title_quoted",
applicable=True,
@@ -243,6 +253,284 @@ def rule_web_page_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult
)
# --- Chapter rules ------------------------------------------------------------
def rule_chapter_book_title_italicized(candidate: str, exemplar: Exemplar) -> RuleResult:
"""For a chapter in an edited volume, the enclosing book's title is italicized.
Source: Purdue OWL CMOS 18 NB page 3, "Article, Chapter, Essay, etc., in
a Book or Edited Collection" — the book title is italicized like any
other book title.
"""
if exemplar.type != "chapter":
return RuleResult(name="chapter_book_title_italicized", applicable=False, passed=True)
book_title = exemplar.canonical.get("book_title")
if not book_title:
return RuleResult(name="chapter_book_title_italicized", applicable=False, passed=True)
passed = f"*{book_title}*" in candidate
return RuleResult(
name="chapter_book_title_italicized",
applicable=True,
passed=passed,
message="" if passed else f"enclosing book title '{book_title}' must be wrapped in *...*",
)
def rule_chapter_in_book_marker(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Chapter entries include the keyword "In" before the italicized book title.
Source: Purdue OWL CMOS 18 NB page 3 example: "Chilson, Peter. 'The
Border.' In *The Best American Travel Writing 2008*, ..." The "In"
keyword distinguishes a chapter from a standalone book.
"""
if exemplar.type != "chapter":
return RuleResult(name="chapter_in_book_marker", applicable=False, passed=True)
# "In *" — capital I, capital N, space, asterisk marking the start of the
# italicized book title. Strict; catches "in *" (lowercase) as a failure.
passed = " In *" in candidate or candidate.startswith("In *")
return RuleResult(
name="chapter_in_book_marker",
applicable=True,
passed=passed,
message="" if passed else "chapter entry must contain 'In *BookTitle*'",
)
# --- Magazine / newspaper rules -----------------------------------------------
def rule_magazine_name_italicized(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Magazine titles are italicized.
Source: CMOS 18 quick guide example (e.g., *New Yorker*, December 18,
2023).
"""
if exemplar.type != "magazine":
return RuleResult(name="magazine_name_italicized", applicable=False, passed=True)
magazine = exemplar.canonical.get("magazine")
if not magazine:
return RuleResult(name="magazine_name_italicized", applicable=False, passed=True)
passed = f"*{magazine}*" in candidate
return RuleResult(
name="magazine_name_italicized",
applicable=True,
passed=passed,
message="" if passed else f"magazine title '{magazine}' must be wrapped in *...*",
)
def rule_newspaper_name_italicized(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Newspaper titles are italicized.
Source: CMOS 18 quick guide example (e.g., *New York Times*, December
13, 2023).
"""
if exemplar.type != "newspaper":
return RuleResult(name="newspaper_name_italicized", applicable=False, passed=True)
newspaper = exemplar.canonical.get("newspaper")
if not newspaper:
return RuleResult(name="newspaper_name_italicized", applicable=False, passed=True)
passed = f"*{newspaper}*" in candidate
return RuleResult(
name="newspaper_name_italicized",
applicable=True,
passed=passed,
message="" if passed else f"newspaper title '{newspaper}' must be wrapped in *...*",
)
def rule_periodical_comma_before_date(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Magazine and newspaper entries use a comma between the italic name and the date.
Journals use a different structure (volume, issue, year in parens), so
this rule only applies to magazines and newspapers.
Source: CMOS 18 quick guide examples — "*New Yorker*, December 18,
2023."; "*New York Times*, December 13, 2023."
"""
if exemplar.type not in ("magazine", "newspaper"):
return RuleResult(name="periodical_comma_before_date", applicable=False, passed=True)
name = exemplar.canonical.get("magazine") or exemplar.canonical.get("newspaper")
if not name:
return RuleResult(name="periodical_comma_before_date", applicable=False, passed=True)
passed = f"*{name}*, " in candidate
return RuleResult(
name="periodical_comma_before_date",
applicable=True,
passed=passed,
message=(
""
if passed
else f"'*{name}*' must be followed by a comma-space, not a period or nothing"
),
)
# --- Social media rules -------------------------------------------------------
def rule_social_media_post_quoted(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Social media post content is wrapped in straight double quotes.
Source: CMOS 18 quick guide example: 'Chicago Manual of Style. "Is the
world ready for singular they? ..." Facebook, April 17, 2015. URL.'
Original post casing must be preserved (this rule checks for the
canonical form literally — if the formatter title-cases the post, the
match fails).
"""
if exemplar.type != "social_media":
return RuleResult(name="social_media_post_quoted", applicable=False, passed=True)
post_text = exemplar.canonical.get("post_text")
if not post_text:
return RuleResult(name="social_media_post_quoted", applicable=False, passed=True)
passed = f'"{post_text}"' in candidate
return RuleResult(
name="social_media_post_quoted",
applicable=True,
passed=passed,
message="" if passed else "post content must appear verbatim inside double quotes",
)
def rule_social_media_platform_comma_date(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Social media entries use a comma between the platform name and the date.
Source: CMOS 18 quick guide: '... Facebook, April 17, 2015. URL.'
"""
if exemplar.type != "social_media":
return RuleResult(
name="social_media_platform_comma_date", applicable=False, passed=True
)
platform = exemplar.canonical.get("platform")
if not platform:
return RuleResult(
name="social_media_platform_comma_date", applicable=False, passed=True
)
passed = f"{platform}, " in candidate
return RuleResult(
name="social_media_platform_comma_date",
applicable=True,
passed=passed,
message=(
""
if passed
else f"'{platform}' must be followed by a comma-space before the date"
),
)
# --- Podcast rules ------------------------------------------------------------
def rule_podcast_series_italicized(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Podcast series titles are italicized like book titles.
Source: CMOS 18 quick guide example: 'Ober, Lauren, host. *The Loudest
Girl in the World*. Season 1, episode 2, ...'
"""
if exemplar.type != "podcast":
return RuleResult(name="podcast_series_italicized", applicable=False, passed=True)
podcast = exemplar.canonical.get("podcast")
if not podcast:
return RuleResult(name="podcast_series_italicized", applicable=False, passed=True)
passed = f"*{podcast}*" in candidate
return RuleResult(
name="podcast_series_italicized",
applicable=True,
passed=passed,
message="" if passed else f"podcast series '{podcast}' must be wrapped in *...*",
)
def rule_podcast_episode_quoted(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Podcast episode titles are in straight double quotes.
Source: CMOS 18 quick guide example (Ober). Terminal punctuation (! ?
.) that's part of the title must also be inside the closing quote.
"""
if exemplar.type != "podcast":
return RuleResult(name="podcast_episode_quoted", applicable=False, passed=True)
episode = exemplar.canonical.get("episode_title")
if not episode:
return RuleResult(name="podcast_episode_quoted", applicable=False, passed=True)
# Accept the episode with its source-provided trailing punctuation OR
# with an added period (CMOS American-style period inside quote).
variants = [f'"{episode}"', f'"{episode}."']
passed = any(v in candidate for v in variants)
return RuleResult(
name="podcast_episode_quoted",
applicable=True,
passed=passed,
message="" if passed else f'episode title must appear in "{episode}"',
)
def rule_podcast_format_label(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Podcast entries include a 'Podcast,' format label before the duration.
Source: CMOS 18 quick guide example: '... Pushkin Industries,
September 13, 2022. Podcast, 41 min., 37 sec. URL.'
"""
if exemplar.type != "podcast":
return RuleResult(name="podcast_format_label", applicable=False, passed=True)
passed = "Podcast, " in candidate
return RuleResult(
name="podcast_format_label",
applicable=True,
passed=passed,
message="" if passed else "podcast entry must contain the 'Podcast, ' format label",
)
# --- Video rules --------------------------------------------------------------
def rule_video_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Video titles are in straight double quotes (not italicized).
Unlike books/journals/podcasts, video/TED-talk titles are treated as
shorter works and put in quotes.
Source: CMOS 18 quick guide example: 'Cowan, Vaitea. "How Green
Hydrogen Could End the Fossil Fuel Era." TED Talk, ...'
"""
if exemplar.type != "video":
return RuleResult(name="video_title_quoted", applicable=False, passed=True)
title = exemplar.canonical.get("title")
if not title:
return RuleResult(name="video_title_quoted", applicable=False, passed=True)
# Must be in quotes, not in italic markers.
passed = f'"{title}."' in candidate and f"*{title}*" not in candidate
return RuleResult(
name="video_title_quoted",
applicable=True,
passed=passed,
message=(
""
if passed
else f'video title must appear as "{title}." (in quotes, not italicized)'
),
)
def rule_video_format_label(candidate: str, exemplar: Exemplar) -> RuleResult:
"""Video entries include a 'Video,' format label before the duration.
Source: CMOS 18 quick guide example: '... April 2022. Video, 9 min.,
15 sec. URL.'
"""
if exemplar.type != "video":
return RuleResult(name="video_format_label", applicable=False, passed=True)
passed = "Video, " in candidate
return RuleResult(
name="video_format_label",
applicable=True,
passed=passed,
message="" if passed else "video entry must contain the 'Video, ' format label",
)
RULES: list[RuleFn] = [
rule_ends_with_period,
rule_no_place_of_publication_for_books,
@@ -253,6 +541,18 @@ RULES: list[RuleFn] = [
rule_page_range_uses_en_dash,
rule_doi_is_https_url,
rule_web_page_title_quoted,
rule_chapter_book_title_italicized,
rule_chapter_in_book_marker,
rule_magazine_name_italicized,
rule_newspaper_name_italicized,
rule_periodical_comma_before_date,
rule_social_media_post_quoted,
rule_social_media_platform_comma_date,
rule_podcast_series_italicized,
rule_podcast_episode_quoted,
rule_podcast_format_label,
rule_video_title_quoted,
rule_video_format_label,
]
+342 -6
View File
@@ -15,12 +15,24 @@ from cmos.linter import (
lint,
rule_article_title_quoted,
rule_book_title_italicized,
rule_chapter_book_title_italicized,
rule_chapter_in_book_marker,
rule_doi_is_https_url,
rule_ends_with_period,
rule_journal_title_italicized,
rule_magazine_name_italicized,
rule_newspaper_name_italicized,
rule_no_ibid,
rule_no_place_of_publication_for_books,
rule_page_range_uses_en_dash,
rule_periodical_comma_before_date,
rule_podcast_episode_quoted,
rule_podcast_format_label,
rule_podcast_series_italicized,
rule_social_media_platform_comma_date,
rule_social_media_post_quoted,
rule_video_format_label,
rule_video_title_quoted,
rule_web_page_title_quoted,
)
from harness.score import Exemplar
@@ -81,6 +93,114 @@ WEB = Exemplar(
expected_bibliography='Google. "Privacy Policy." Privacy & Terms. Effective November 15, 2023. https://policies.google.com/privacy.',
)
CHAPTER = Exemplar(
name="chapter",
source="test",
type="chapter",
tags=["chapter"],
canary=False,
messy_input="",
canonical={
"author": "Doyle, Kathleen",
"chapter_title": "The Queen Mary Psalter",
"book_title": "The Book by Design: The Remarkable Story of the World's Greatest Invention",
"editors": "edited by P. J. M. Marks and Stephen Parkin",
"publisher": "University of Chicago Press",
"year": 2023,
},
expected_bibliography='Doyle, Kathleen. "The Queen Mary Psalter." In *The Book by Design: The Remarkable Story of the World\'s Greatest Invention*, edited by P. J. M. Marks and Stephen Parkin. University of Chicago Press, 2023.',
)
MAGAZINE = Exemplar(
name="magazine",
source="test",
type="magazine",
tags=["magazine"],
canary=False,
messy_input="",
canonical={
"author": "Mead, Rebecca",
"article_title": "Terms of Aggrievement",
"magazine": "New Yorker",
"date": "December 18, 2023",
},
expected_bibliography='Mead, Rebecca. "Terms of Aggrievement." *New Yorker*, December 18, 2023.',
)
NEWSPAPER = Exemplar(
name="newspaper",
source="test",
type="newspaper",
tags=["newspaper"],
canary=False,
messy_input="",
canonical={
"author": "Blum, Dani",
"article_title": "Are Flax Seeds All That?",
"newspaper": "New York Times",
"date": "December 13, 2023",
"url": "https://www.nytimes.com/2023/12/13/well/eat/flax-seeds-benefits.html",
},
expected_bibliography='Blum, Dani. "Are Flax Seeds All That?" *New York Times*, December 13, 2023. https://www.nytimes.com/2023/12/13/well/eat/flax-seeds-benefits.html.',
)
SOCIAL = Exemplar(
name="social_media",
source="test",
type="social_media",
tags=["social_media"],
canary=False,
messy_input="",
canonical={
"author": "Chicago Manual of Style",
"post_text": "Is the world ready for singular they? We thought so in 1993.",
"platform": "Facebook",
"date": "April 17, 2015",
"url": "https://www.facebook.com/ChicagoManual/posts/10152906193679151",
},
expected_bibliography='Chicago Manual of Style. "Is the world ready for singular they? We thought so in 1993." Facebook, April 17, 2015. https://www.facebook.com/ChicagoManual/posts/10152906193679151.',
)
PODCAST = Exemplar(
name="podcast",
source="test",
type="podcast",
tags=["podcast"],
canary=False,
messy_input="",
canonical={
"host": "Ober, Lauren, host",
"podcast": "The Loudest Girl in the World",
"season_episode": "Season 1, episode 2",
"episode_title": "Goodbye, Routine; Hello, Meltdown!",
"publisher": "Pushkin Industries",
"date": "September 13, 2022",
"format": "Podcast",
"duration": "41 min., 37 sec.",
"url": "https://www.pushkin.fm/podcasts/loudest-girl-in-the-world",
},
expected_bibliography='Ober, Lauren, host. *The Loudest Girl in the World*. Season 1, episode 2, "Goodbye, Routine; Hello, Meltdown!" Pushkin Industries, September 13, 2022. Podcast, 41 min., 37 sec. https://www.pushkin.fm/podcasts/loudest-girl-in-the-world.',
)
VIDEO = Exemplar(
name="video",
source="test",
type="video",
tags=["video"],
canary=False,
messy_input="",
canonical={
"author": "Cowan, Vaitea",
"title": "How Green Hydrogen Could End the Fossil Fuel Era",
"venue": "TED Talk, Vancouver, BC",
"date": "April 2022",
"format": "Video",
"duration": "9 min., 15 sec.",
"url": "https://www.ted.com/talks/vaitea_cowan_how_green_hydrogen_could_end_the_fossil_fuel_era",
},
expected_bibliography='Cowan, Vaitea. "How Green Hydrogen Could End the Fossil Fuel Era." TED Talk, Vancouver, BC, April 2022. Video, 9 min., 15 sec. https://www.ted.com/talks/vaitea_cowan_how_green_hydrogen_could_end_the_fossil_fuel_era.',
)
def test_linter_version_is_set():
assert LINTER_VERSION
@@ -254,13 +374,229 @@ def test_web_page_title_quoted_fails_when_unquoted():
assert result.passed is False
# --- Article title rule extended to magazines and newspapers -----------------
def test_article_title_quoted_passes_on_magazine():
# Magazines follow the same article-title convention as journals.
result = rule_article_title_quoted(MAGAZINE.expected_bibliography, MAGAZINE)
assert result.applicable is True
assert result.passed is True
def test_article_title_quoted_passes_on_newspaper():
result = rule_article_title_quoted(NEWSPAPER.expected_bibliography, NEWSPAPER)
assert result.applicable is True
assert result.passed is True
def test_article_title_quoted_not_applicable_to_book():
result = rule_article_title_quoted(BOOK.expected_bibliography, BOOK)
assert result.applicable is False
# --- Chapter rules ------------------------------------------------------------
def test_chapter_book_title_italicized_passes():
# Chapters have their enclosing book's title italicized (CMOS 18,
# "Article, Chapter, Essay, etc., in a Book" model).
result = rule_chapter_book_title_italicized(CHAPTER.expected_bibliography, CHAPTER)
assert result.applicable is True
assert result.passed is True
def test_chapter_book_title_italicized_fails_when_plain():
bad = CHAPTER.expected_bibliography.replace(
"*The Book by Design: The Remarkable Story of the World's Greatest Invention*",
"The Book by Design: The Remarkable Story of the World's Greatest Invention",
)
result = rule_chapter_book_title_italicized(bad, CHAPTER)
assert result.passed is False
def test_chapter_book_title_italicized_not_applicable_to_book():
result = rule_chapter_book_title_italicized(BOOK.expected_bibliography, BOOK)
assert result.applicable is False
def test_chapter_in_book_marker_passes():
# Chapter entries include the keyword "In" before the italicized book
# title to distinguish them from standalone books.
result = rule_chapter_in_book_marker(CHAPTER.expected_bibliography, CHAPTER)
assert result.passed is True
def test_chapter_in_book_marker_fails_without_in():
bad = CHAPTER.expected_bibliography.replace(" In *", " *")
result = rule_chapter_in_book_marker(bad, CHAPTER)
assert result.passed is False
# --- Magazine and newspaper name italicized ----------------------------------
def test_magazine_name_italicized_passes():
# CMOS 18: magazine titles are italicized.
result = rule_magazine_name_italicized(MAGAZINE.expected_bibliography, MAGAZINE)
assert result.passed is True
def test_magazine_name_italicized_fails_when_plain():
bad = MAGAZINE.expected_bibliography.replace("*New Yorker*", "New Yorker")
result = rule_magazine_name_italicized(bad, MAGAZINE)
assert result.passed is False
def test_newspaper_name_italicized_passes():
result = rule_newspaper_name_italicized(NEWSPAPER.expected_bibliography, NEWSPAPER)
assert result.passed is True
def test_newspaper_name_italicized_fails_when_plain():
bad = NEWSPAPER.expected_bibliography.replace("*New York Times*", "New York Times")
result = rule_newspaper_name_italicized(bad, NEWSPAPER)
assert result.passed is False
# --- Periodical comma before date (magazine/newspaper only) -------------------
def test_periodical_comma_before_date_passes_on_magazine():
# Magazines and newspapers use a COMMA between the italicized name and
# the date, not a period: *New Yorker*, December 18, 2023.
result = rule_periodical_comma_before_date(MAGAZINE.expected_bibliography, MAGAZINE)
assert result.passed is True
def test_periodical_comma_before_date_fails_when_period_used():
bad = MAGAZINE.expected_bibliography.replace("*New Yorker*,", "*New Yorker*.")
result = rule_periodical_comma_before_date(bad, MAGAZINE)
assert result.passed is False
def test_periodical_comma_before_date_passes_on_newspaper():
result = rule_periodical_comma_before_date(NEWSPAPER.expected_bibliography, NEWSPAPER)
assert result.passed is True
def test_periodical_comma_before_date_not_applicable_to_journal():
# Journals use volume/issue/year in parens, no comma-after-italic pattern.
result = rule_periodical_comma_before_date(JOURNAL.expected_bibliography, JOURNAL)
assert result.applicable is False
# --- Social media rules -------------------------------------------------------
def test_social_media_post_quoted_passes():
# The post content is wrapped in straight double quotes.
result = rule_social_media_post_quoted(SOCIAL.expected_bibliography, SOCIAL)
assert result.passed is True
def test_social_media_post_quoted_fails_when_title_cased():
# Title-casing is a common LLM error on social media content.
bad = SOCIAL.expected_bibliography.replace(
"Is the world ready for singular they? We thought so in 1993.",
"Is the World Ready for Singular They? We Thought So in 1993.",
)
result = rule_social_media_post_quoted(bad, SOCIAL)
assert result.passed is False
def test_social_media_platform_comma_date_passes():
# The platform name is followed by a comma (not a period) before the date.
result = rule_social_media_platform_comma_date(SOCIAL.expected_bibliography, SOCIAL)
assert result.passed is True
def test_social_media_platform_comma_date_fails_when_period_used():
bad = SOCIAL.expected_bibliography.replace("Facebook, April", "Facebook. April")
result = rule_social_media_platform_comma_date(bad, SOCIAL)
assert result.passed is False
# --- Podcast rules ------------------------------------------------------------
def test_podcast_series_italicized_passes():
# Podcast series titles are italicized like book titles.
result = rule_podcast_series_italicized(PODCAST.expected_bibliography, PODCAST)
assert result.passed is True
def test_podcast_series_italicized_fails_when_plain():
bad = PODCAST.expected_bibliography.replace(
"*The Loudest Girl in the World*", "The Loudest Girl in the World"
)
result = rule_podcast_series_italicized(bad, PODCAST)
assert result.passed is False
def test_podcast_episode_quoted_passes():
# Episode titles are in quotes (including terminal punctuation like !).
result = rule_podcast_episode_quoted(PODCAST.expected_bibliography, PODCAST)
assert result.passed is True
def test_podcast_episode_quoted_fails_when_unquoted():
bad = PODCAST.expected_bibliography.replace(
'"Goodbye, Routine; Hello, Meltdown!"',
"Goodbye, Routine; Hello, Meltdown!",
)
result = rule_podcast_episode_quoted(bad, PODCAST)
assert result.passed is False
def test_podcast_format_label_passes():
# CMOS includes a "Podcast," format label before duration.
result = rule_podcast_format_label(PODCAST.expected_bibliography, PODCAST)
assert result.passed is True
def test_podcast_format_label_fails_when_missing():
bad = PODCAST.expected_bibliography.replace("Podcast, ", "")
result = rule_podcast_format_label(bad, PODCAST)
assert result.passed is False
# --- Video rules --------------------------------------------------------------
def test_video_title_quoted_passes():
# Video/TED Talk titles are in quotes (not italicized).
result = rule_video_title_quoted(VIDEO.expected_bibliography, VIDEO)
assert result.passed is True
def test_video_title_quoted_fails_when_italicized():
bad = VIDEO.expected_bibliography.replace(
'"How Green Hydrogen Could End the Fossil Fuel Era."',
"*How Green Hydrogen Could End the Fossil Fuel Era.*",
)
result = rule_video_title_quoted(bad, VIDEO)
assert result.passed is False
def test_video_format_label_passes():
# CMOS includes a "Video," format label before duration.
result = rule_video_format_label(VIDEO.expected_bibliography, VIDEO)
assert result.passed is True
def test_video_format_label_fails_when_missing():
bad = VIDEO.expected_bibliography.replace("Video, ", "")
result = rule_video_format_label(bad, VIDEO)
assert result.passed is False
# --- Aggregate check_passes ---------------------------------------------------
def test_check_passes_on_canonical_outputs():
# All three seed exemplars' expected_bibliography strings must lint clean.
# If any of them fail, either the linter is too strict or the exemplar is
# wrong — either way, we want to know immediately.
assert check_passes(BOOK.expected_bibliography, BOOK)
assert check_passes(JOURNAL.expected_bibliography, JOURNAL)
assert check_passes(WEB.expected_bibliography, WEB)
# Every in-test exemplar's expected_bibliography must lint clean.
# If any fail, either the linter is too strict or the exemplar is wrong —
# either way, we want to know immediately.
for ex in (BOOK, JOURNAL, WEB, CHAPTER, MAGAZINE, NEWSPAPER, SOCIAL, PODCAST, VIDEO):
assert check_passes(ex.expected_bibliography, ex), f"linter rejected {ex.name}"