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
+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}"