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:
+304
-4
@@ -24,7 +24,7 @@ from typing import Callable
|
|||||||
|
|
||||||
from harness.score import Exemplar
|
from harness.score import Exemplar
|
||||||
|
|
||||||
LINTER_VERSION = "v0.2.0"
|
LINTER_VERSION = "v0.3.0"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@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:
|
def rule_article_title_quoted(candidate: str, exemplar: Exemplar) -> RuleResult:
|
||||||
"""Article titles are wrapped in double quotes, with the trailing period inside the quotes.
|
"""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" —
|
Source: Purdue OWL CMOS 18 NB poster page 3 "Bibliography: Basics" —
|
||||||
"titles of articles, chapters, poems, etc. are placed in quotation marks."
|
"titles of articles, chapters, poems, etc. are placed in quotation marks."
|
||||||
CMOS also places the terminating period inside the closing quote
|
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)
|
return RuleResult(name="article_title_quoted", applicable=False, passed=True)
|
||||||
article_title = exemplar.canonical.get("article_title")
|
article_title = exemplar.canonical.get("article_title")
|
||||||
if not article_title:
|
if not article_title:
|
||||||
return RuleResult(name="article_title_quoted", applicable=False, passed=True)
|
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(
|
return RuleResult(
|
||||||
name="article_title_quoted",
|
name="article_title_quoted",
|
||||||
applicable=True,
|
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] = [
|
RULES: list[RuleFn] = [
|
||||||
rule_ends_with_period,
|
rule_ends_with_period,
|
||||||
rule_no_place_of_publication_for_books,
|
rule_no_place_of_publication_for_books,
|
||||||
@@ -253,6 +541,18 @@ RULES: list[RuleFn] = [
|
|||||||
rule_page_range_uses_en_dash,
|
rule_page_range_uses_en_dash,
|
||||||
rule_doi_is_https_url,
|
rule_doi_is_https_url,
|
||||||
rule_web_page_title_quoted,
|
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
@@ -15,12 +15,24 @@ from cmos.linter import (
|
|||||||
lint,
|
lint,
|
||||||
rule_article_title_quoted,
|
rule_article_title_quoted,
|
||||||
rule_book_title_italicized,
|
rule_book_title_italicized,
|
||||||
|
rule_chapter_book_title_italicized,
|
||||||
|
rule_chapter_in_book_marker,
|
||||||
rule_doi_is_https_url,
|
rule_doi_is_https_url,
|
||||||
rule_ends_with_period,
|
rule_ends_with_period,
|
||||||
rule_journal_title_italicized,
|
rule_journal_title_italicized,
|
||||||
|
rule_magazine_name_italicized,
|
||||||
|
rule_newspaper_name_italicized,
|
||||||
rule_no_ibid,
|
rule_no_ibid,
|
||||||
rule_no_place_of_publication_for_books,
|
rule_no_place_of_publication_for_books,
|
||||||
rule_page_range_uses_en_dash,
|
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,
|
rule_web_page_title_quoted,
|
||||||
)
|
)
|
||||||
from harness.score import Exemplar
|
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.',
|
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():
|
def test_linter_version_is_set():
|
||||||
assert LINTER_VERSION
|
assert LINTER_VERSION
|
||||||
@@ -254,13 +374,229 @@ def test_web_page_title_quoted_fails_when_unquoted():
|
|||||||
assert result.passed is False
|
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 ---------------------------------------------------
|
# --- Aggregate check_passes ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_check_passes_on_canonical_outputs():
|
def test_check_passes_on_canonical_outputs():
|
||||||
# All three seed exemplars' expected_bibliography strings must lint clean.
|
# Every in-test exemplar's expected_bibliography must lint clean.
|
||||||
# If any of them fail, either the linter is too strict or the exemplar is
|
# If any fail, either the linter is too strict or the exemplar is wrong —
|
||||||
# wrong — either way, we want to know immediately.
|
# either way, we want to know immediately.
|
||||||
assert check_passes(BOOK.expected_bibliography, BOOK)
|
for ex in (BOOK, JOURNAL, WEB, CHAPTER, MAGAZINE, NEWSPAPER, SOCIAL, PODCAST, VIDEO):
|
||||||
assert check_passes(JOURNAL.expected_bibliography, JOURNAL)
|
assert check_passes(ex.expected_bibliography, ex), f"linter rejected {ex.name}"
|
||||||
assert check_passes(WEB.expected_bibliography, WEB)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user