diff --git a/scripts/check_style.py b/scripts/check_style.py index aeaeb5d..0ced611 100755 --- a/scripts/check_style.py +++ b/scripts/check_style.py @@ -80,8 +80,15 @@ ] # Section 2, "Habits to avoid". +# +# `robust` and `powerful` are deliberately absent. Both are technical terms in +# this corpus — a robust estimator, statistical power — and a rule that flags +# them reported twelve non-defects in the research docs and one quotation of +# Hansen's SPA test. A word that is marketing in a landing page and a term of +# art in a research note cannot be decided by a word list, so the list keeps +# only the words that have no technical sense here. MARKETING_WORDS = [ - "powerful", "seamless", "robust", "cutting-edge", "comprehensive", + "seamless", "cutting-edge", "comprehensive", "world-class", "next-generation", "state-of-the-art", "best-in-class", ] FILLER_PHRASES = [ @@ -92,10 +99,12 @@ # Section 13 says "non-placeholder buckets" and section 7 line 104 says the # placeholder form is ``, ``, `$PROJECTS_ROOT`. So a # `gs://` URI whose bucket segment is a placeholder is the correct form, not a -# finding. +# finding. `synthetic-` is on the list because the organisation's fixtures are +# named that way; without it every fixture a doc quotes becomes a finding the +# day the doc is written. PLACEHOLDER_BUCKET = re.compile( r"^[<{$]|[<>{}]|\.\.\." - r"|^(?:your|my|example|sample|placeholder|shared)[-_]" + r"|^(?:your|my|example|sample|placeholder|shared|synthetic)[-_]" r"|^bucket(?:-|$)" ) @@ -108,6 +117,18 @@ class FileContext(NamedTuple): url_spans: list[tuple[int, int]] prose: str # the text with fenced blocks blanked out, offsets preserved + masked: str # `prose` with inline code blanked out too + + +def phrase_is_prose(text: str, match: re.Match, ctx: FileContext) -> bool: + """A rule about phrasing describes prose; backticks name the phrase. + + The style guide writes `this round` when it means the words, and a value + rule such as `gs://` is the opposite case: a bucket inside backticks is + still a bucket. That is why this is a per-rule predicate and not a blanket + skip on code spans. + """ + return bool(ctx.masked[match.start():match.end()].strip()) def bucket_is_real(text: str, match: re.Match, ctx: FileContext) -> bool: @@ -167,12 +188,15 @@ def hex_is_prose(text: str, match: re.Match, ctx: FileContext) -> bool: "content", "personal subdomain (section 13)", None), (re.compile(r"\b[0-9a-f]{40}\b"), "content", "40-character hex string in prose (section 5)", hex_is_prose), - (re.compile(r"\|\s*(PR\b|#\d+)"), + # `| PR |` is a table header; `| PR #12 |` and `| #12 |` are the rows this + # rule is about. Matching the header too reported the guide's own row + # describing the rule. + (re.compile(r"\|\s*(?:PR\s*#?\d+|#\d+)"), "content", "PR-number status table (section 5)", None), (re.compile(r"\b(this round|this task|in this task)\b", re.IGNORECASE), - "content", "task-scope wording (section 5)", None), + "content", "task-scope wording (section 5)", phrase_is_prose), (re.compile(r"\b(the owner approved|approved by)\b", re.IGNORECASE), - "content", "owner approval note (section 5)", None), + "content", "owner approval note (section 5)", phrase_is_prose), ] @@ -328,8 +352,12 @@ def check_links(root: Path, report: Report) -> None: # cannot verify. Reporting it as broken makes the check unusable in # CI while passing locally, which is the worst of both. if not resolved.is_relative_to(root): + # `xref` is not in PHASE_BLOCKING, so this stays a note in + # both phases. `tools/check_cross_repo_links.py` resolves these + # against the sibling's default branch on a schedule; a check + # that says "not checked" has no standing to block. report.add( - "content", + "xref", f"{path.relative_to(root).as_posix()}: cross-repository reference, not checked -> {target}", ) continue @@ -381,17 +409,38 @@ def check_language(root: Path, report: Report) -> None: if not is_markdown(path): continue rel = path.relative_to(root).as_posix() - for number, line in enumerate( - path.read_text(encoding="utf-8", errors="ignore").splitlines(), 1 - ): + # A word inside a fenced block or a code span is being named, not used. + # The guide lists the words it forbids, and without this the guide is + # the largest offender in the organisation. + text = blank_code_spans(blank_fenced_blocks(path.read_text(encoding="utf-8", errors="ignore"))) + first_heading = True + for number, line in enumerate(text.splitlines(), 1): for match in marketing.finditer(line): report.add("language", f"{rel}:{number}: marketing word '{match.group(0)}' (section 2)") for match in filler.finditer(line): report.add("language", f"{rel}:{number}: filler phrase '{match.group(0)}' (section 2)") if line.startswith("#") and has_emoji(line): report.add("language", f"{rel}:{number}: emoji in heading (section 2)") - if line.startswith("#") and DATED_TITLE.search(line): - report.add("language", f"{rel}:{number}: dated title in a reader-facing doc (section 5)") + if line.startswith("#"): + # Section 5 is about the title a reader sees in a list of + # documents. A dated section inside a research log is a + # chronology, not a title. + if first_heading and DATED_TITLE.search(line): + report.add("language", f"{rel}:{number}: dated title in a reader-facing doc (section 5)") + first_heading = False + + +CODE_SPAN = re.compile(r"`[^`\n]+`") + + +def blank_code_spans(text: str) -> str: + """Replace inline code with spaces, keeping offsets. + + A word in backticks is a mention: the style guide writes `robust` when it + means the word, and a research note writes a commit id as a value. Neither + is prose, and both were reported as prose. + """ + return CODE_SPAN.sub(lambda m: " " * len(m.group(0)), text) def blank_fenced_blocks(text: str) -> str: @@ -413,12 +462,19 @@ def check_leaks(root: Path, report: Report) -> None: continue rel = path.relative_to(root).as_posix() text = path.read_text(encoding="utf-8", errors="ignore") + prose = blank_fenced_blocks(text) ctx = FileContext( url_spans=[(m.start(), m.end()) for m in URL_TOKEN.finditer(text)], - prose=blank_fenced_blocks(text), + prose=prose, + masked=blank_code_spans(prose), ) for pattern, category, label, predicate in LEAK_PATTERNS: for match in pattern.finditer(text): + # A pattern without a predicate describes prose, and a fenced + # block is not prose. The style guide shows each of these + # shapes as an example of what to avoid. + if predicate is None and not ctx.prose[match.start():match.end()].strip(): + continue if predicate is not None and not predicate(text, match, ctx): continue report.add(category, f"{rel}: {label}: {match.group(0)[:40]}") diff --git a/scripts/test_style.py b/scripts/test_style.py index ab691ec..45369d9 100644 --- a/scripts/test_style.py +++ b/scripts/test_style.py @@ -216,6 +216,71 @@ def test_claude_must_be_the_single_line(self): self.assertTrue(any("@AGENTS.md" in m for m in self.messages(report, "agents"))) +class LanguageTest(CheckerTestCase): + """Section 2 — the word list reports usage, not mention.""" + + def test_marketing_word_in_prose_is_reported(self): + self.write("docs/note.md", "# Note\n\nA seamless handover.\n") + report = self.report() + check_style.check_language(self.root, report) + self.assertTrue(any("seamless" in m for m in self.messages(report, "language"))) + + def test_marketing_word_in_a_code_span_is_a_mention(self): + # The guide writes the words it forbids. Reporting them there makes the + # guide the largest offender in the organisation. + self.write("docs/note.md", "# Note\n\nAvoid `seamless` and `world-class`.\n") + report = self.report() + check_style.check_language(self.root, report) + self.assertEqual(self.messages(report, "language"), []) + + def test_marketing_word_in_a_fenced_block_is_a_mention(self): + self.write("docs/note.md", "# Note\n\n```markdown\nA seamless handover.\n```\n") + report = self.report() + check_style.check_language(self.root, report) + self.assertEqual(self.messages(report, "language"), []) + + def test_technical_terms_are_not_marketing(self): + # A robust estimator and statistical power are terms of art; the list + # that flagged them reported twelve non-defects in the research docs. + self.write( + "docs/note.md", + "# Note\n\nThe robust plateau holds, and the test is more powerful " + "than the Reality Check.\n", + ) + report = self.report() + check_style.check_language(self.root, report) + self.assertEqual(self.messages(report, "language"), []) + + def test_dated_section_heading_is_not_a_dated_title(self): + self.write( + "docs/note.md", + "# Contract acceptance\n\n## Bounded run acceptance (2026-10-05)\n", + ) + report = self.report() + check_style.check_language(self.root, report) + self.assertEqual(self.messages(report, "language"), []) + + def test_dated_document_title_is_reported(self): + self.write("docs/note.md", "# Console audit, 2026-10-15\n") + report = self.report() + check_style.check_language(self.root, report) + self.assertTrue(any("dated title" in m for m in self.messages(report, "language"))) + + +class CrossRepositoryTest(CheckerTestCase): + def test_cross_repository_reference_never_blocks(self): + # The scheduled link job resolves these against the sibling's default + # branch. A finding that says "not checked" cannot block a merge. + # Two levels up: one level stays inside the repository, and on macOS + # `/tmp` resolves through a symlink, so a one-level fixture passed here + # and failed on the Linux runner. + self.write("docs/note.md", "# Note\n\n[peer](../../Sibling/docs/x.md)\n") + report = self.report(phase=2) + check_style.check_links(self.root, report) + self.assertTrue(any(c == "xref" for c, _ in report.findings)) + self.assertEqual(report.blocking_findings(), []) + + class PhaseTest(CheckerTestCase): def test_phase_one_does_not_block_naming(self): self.write("docs/hk_equity_runtime.md", "# x\n")