mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-07-16 14:42:29 -05:00
The source-note check parsed segments up to the next double-quote on a line. That worked for Quarto attribute strings (fig-cap="... Source: [@k].") but mis-handled markdown-image alt text where Source: lives before the closing ](path) and trailing attributes (e.g. fig-align= "center") supply a later quote. Result: 14 false-positive 'missing trailing period' errors on kits/ image alt text and mlsysim/docs/math.qmd. Switch to context-aware bounding: count double-quotes preceding Source:. If odd, we are inside an attribute string and use " as the terminator. Otherwise prefer ](. Inline links inside an attribute (Source: [N](URL).) remain bounded at the closing " — " wins because we are still inside the attribute string.
248 lines
8.8 KiB
Python
248 lines
8.8 KiB
Python
"""Check: Source-attribution formatting in prose and captions.
|
|
|
|
This is the binder-native replacement for the old `manage_sources.py`
|
|
one-off. It enforces the small set of source-attribution conventions the
|
|
book uses in figure captions, table captions, listings, and the rare
|
|
body-prose source note:
|
|
|
|
- `Source:` is capitalized
|
|
- source notes end with a period
|
|
- academic sources use `Source: [@key]`
|
|
- source notes are not wrapped in stray asterisks
|
|
- source notes do not contain doubled periods or extra brackets
|
|
|
|
The check deliberately skips code fences, raw HTML/LaTeX blocks, YAML,
|
|
and similar protected contexts via `LineWalker`. It does not try to
|
|
rewrite content; it only flags formatting problems that should be fixed
|
|
in the source text itself.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from audit.ledger import Issue, make_issue_id
|
|
from audit.protected_contexts import LineWalker, is_python_chunk_option
|
|
|
|
CATEGORY = "source-attribution"
|
|
RULE = "book-prose-merged.md section 6 / source note hygiene"
|
|
RULE_TEXT = "Source notes should be capitalized, punctuated, and bracketed consistently"
|
|
|
|
_ASTERISK_SOURCE_RE = re.compile(r"\*[Ss]ource:[^*]*\*")
|
|
_LOWERCASE_SOURCE_RE = re.compile(r"\bsource:")
|
|
_CAPITALIZED_SOURCE_RE = re.compile(r"\bSource:\s*")
|
|
_SOURCE_RE = re.compile(r"\b(?:Source|source):\s*")
|
|
_MALFORMED_ACADEMIC_RE = re.compile(r"\b[Ss]ource:\s*@[^[]")
|
|
_EXTRA_BRACKETS_RE = re.compile(r"\b[Ss]ource:\s*\[\[@")
|
|
|
|
|
|
def _looks_like_source_note(line: str, start: int) -> bool:
|
|
"""Heuristic guard so we only flag actual source notes.
|
|
|
|
A plain prose phrase such as "common source:" should not be treated
|
|
as an editorial source note. Real source notes usually start at a
|
|
sentence boundary or after a structural delimiter in captions.
|
|
"""
|
|
if start <= 0:
|
|
return True
|
|
prefix = line[:start].rstrip()
|
|
if not prefix:
|
|
return True
|
|
last = prefix[-1]
|
|
return last in ".:!?*\"')]}"
|
|
|
|
|
|
def _skip_line(line: str, state) -> bool:
|
|
"""Skip blocks that should never be scanned for source notes."""
|
|
if state.in_yaml or state.in_code_fence or state.in_display_math:
|
|
return True
|
|
if state.in_html_style_block or state.in_html_comment:
|
|
return True
|
|
if is_python_chunk_option(line):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _source_segment(line: str) -> tuple[int, str] | None:
|
|
"""Return the first source-note segment on the line, if present.
|
|
|
|
The returned segment begins at `Source:` / `source:` and runs until
|
|
the right terminator for its context: a closing double quote when the
|
|
note sits inside a Quarto attribute string (`fig-cap="... Source:
|
|
[@key]."`), or the markdown-image close `](` when the note sits in
|
|
image alt text (`{...}`). Inline
|
|
links inside an attribute string (`Source: [Name](URL).`) must NOT
|
|
truncate at `](`; the closing `"` bounds the note correctly.
|
|
"""
|
|
m = _SOURCE_RE.search(line)
|
|
if not m:
|
|
return None
|
|
start = m.start()
|
|
rest = line[start:]
|
|
# An odd count of double quotes before `Source:` means we are inside
|
|
# an attribute value; in that case `"` is the only correct terminator.
|
|
in_attr_string = line[:start].count('"') % 2 == 1
|
|
if in_attr_string:
|
|
quote = rest.find('"')
|
|
segment = rest[:quote] if quote != -1 else rest
|
|
else:
|
|
img_close = rest.find("](")
|
|
quote = rest.find('"')
|
|
if img_close != -1:
|
|
segment = rest[:img_close]
|
|
elif quote != -1:
|
|
segment = rest[:quote]
|
|
else:
|
|
segment = rest
|
|
# Quarto table captions carry trailing `{#tbl-...}` metadata after the
|
|
# caption text. Stop before that marker so the punctuation check only
|
|
# evaluates the human-written source note.
|
|
meta = segment.find(" {#")
|
|
if meta != -1:
|
|
segment = segment[:meta]
|
|
return start, segment
|
|
|
|
|
|
def check(
|
|
file_path: Path,
|
|
text: str,
|
|
scope: str,
|
|
start_counter: int = 0,
|
|
) -> tuple[list[Issue], int]:
|
|
"""Scan for source-attribution formatting issues."""
|
|
issues: list[Issue] = []
|
|
counter = start_counter
|
|
|
|
walker = LineWalker(text)
|
|
for line, state, line_num in walker:
|
|
if _skip_line(line, state):
|
|
continue
|
|
|
|
# Stray asterisk wrapping is a stylistic error on the full line.
|
|
for m in _ASTERISK_SOURCE_RE.finditer(line):
|
|
issues.append(
|
|
Issue(
|
|
id=make_issue_id(scope, "asterisk_sources", counter),
|
|
category="asterisk_sources",
|
|
rule=RULE,
|
|
rule_text=RULE_TEXT,
|
|
file=str(file_path),
|
|
line=line_num,
|
|
col=m.start(),
|
|
before=line,
|
|
auto_fixable=False,
|
|
needs_subagent=False,
|
|
reason="Source note is wrapped in asterisks",
|
|
)
|
|
)
|
|
counter += 1
|
|
|
|
# Lowercase source marker.
|
|
lower = _LOWERCASE_SOURCE_RE.search(line)
|
|
if lower and not _CAPITALIZED_SOURCE_RE.search(line) and _looks_like_source_note(line, lower.start()):
|
|
issues.append(
|
|
Issue(
|
|
id=make_issue_id(scope, "lowercase_sources", counter),
|
|
category="lowercase_sources",
|
|
rule=RULE,
|
|
rule_text=RULE_TEXT,
|
|
file=str(file_path),
|
|
line=line_num,
|
|
col=lower.start(),
|
|
before=line,
|
|
auto_fixable=False,
|
|
needs_subagent=False,
|
|
reason="Use capitalized 'Source:'",
|
|
)
|
|
)
|
|
counter += 1
|
|
|
|
segment_info = _source_segment(line)
|
|
if segment_info is None:
|
|
continue
|
|
start, segment = segment_info
|
|
if not _looks_like_source_note(line, start):
|
|
continue
|
|
|
|
# Missing terminal period on the source note itself.
|
|
trimmed = segment.rstrip()
|
|
if trimmed and not trimmed.endswith("."):
|
|
issues.append(
|
|
Issue(
|
|
id=make_issue_id(scope, "missing_periods", counter),
|
|
category="missing_periods",
|
|
rule=RULE,
|
|
rule_text=RULE_TEXT,
|
|
file=str(file_path),
|
|
line=line_num,
|
|
col=start,
|
|
before=line,
|
|
auto_fixable=False,
|
|
needs_subagent=False,
|
|
reason="Source note should end with a period",
|
|
)
|
|
)
|
|
counter += 1
|
|
|
|
# Double periods in the source note.
|
|
if ".." in segment:
|
|
issues.append(
|
|
Issue(
|
|
id=make_issue_id(scope, "double_periods", counter),
|
|
category="double_periods",
|
|
rule=RULE,
|
|
rule_text=RULE_TEXT,
|
|
file=str(file_path),
|
|
line=line_num,
|
|
col=start,
|
|
before=line,
|
|
auto_fixable=False,
|
|
needs_subagent=False,
|
|
reason="Source note contains a double period",
|
|
)
|
|
)
|
|
counter += 1
|
|
|
|
# Academic source notes must use bracketed citations.
|
|
if _MALFORMED_ACADEMIC_RE.search(segment):
|
|
m = _MALFORMED_ACADEMIC_RE.search(segment)
|
|
issues.append(
|
|
Issue(
|
|
id=make_issue_id(scope, "malformed_citations", counter),
|
|
category="malformed_citations",
|
|
rule=RULE,
|
|
rule_text=RULE_TEXT,
|
|
file=str(file_path),
|
|
line=line_num,
|
|
col=start + (m.start() if m else 0),
|
|
before=line,
|
|
auto_fixable=False,
|
|
needs_subagent=False,
|
|
reason="Academic source should use bracketed citation syntax",
|
|
)
|
|
)
|
|
counter += 1
|
|
|
|
# Extra brackets around academic citations.
|
|
if _EXTRA_BRACKETS_RE.search(segment):
|
|
m = _EXTRA_BRACKETS_RE.search(segment)
|
|
issues.append(
|
|
Issue(
|
|
id=make_issue_id(scope, "extra_brackets", counter),
|
|
category="extra_brackets",
|
|
rule=RULE,
|
|
rule_text=RULE_TEXT,
|
|
file=str(file_path),
|
|
line=line_num,
|
|
col=start + (m.start() if m else 0),
|
|
before=line,
|
|
auto_fixable=False,
|
|
needs_subagent=False,
|
|
reason="Source note has extra citation brackets",
|
|
)
|
|
)
|
|
counter += 1
|
|
|
|
return issues, counter
|