diff --git a/tests/test_navigation_quotes.py b/tests/test_navigation_quotes.py index 03f6fd8f4..f8b5acb98 100644 --- a/tests/test_navigation_quotes.py +++ b/tests/test_navigation_quotes.py @@ -122,5 +122,90 @@ def test_header_row_quotation_marks_are_ignored(self): self.assertEqual((report['quotes'], report['summaries']), (0, 1)) +class VerdictSourceBoundaries(unittest.TestCase): + """Source verdict prefixes obey the same left boundary as summary prefixes. + + These are lexical checks, not an entailment or negation classifier. Suffixes, + case sensitivity and literal quotation behavior retain the existing policy. + """ + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='verdict-boundary-') + self.addCleanup(self.tmp.cleanup) + self.root = pathlib.Path(self.tmp.name) + self.source = self.root / 'source.md' + self.doc = self.root / 'table.md' + + def report(self, source, passage, kind='summary'): + self.source.write_text(source, encoding='utf-8') + claim = 'summary: ' + passage if kind == 'summary' else '“' + passage + '”' + self.doc.write_text(HEADER + '| #1 | [r](source.md) — ' + claim + ' |\n', encoding='utf-8') + return check_doc_quotes.check_document(str(self.doc), str(self.root)) + + def test_attached_word_prefix_cannot_supply_source_verdict(self): + for word in check_doc_quotes.VERDICT: + for prefix in ('UN', 'PRE', 'NON', '_', '9', 'λ'): + with self.subTest(word=word, prefix=prefix): + result = self.report(prefix + word, word) + self.assertEqual(result['checked'], 1) + self.assertEqual(result['unchecked_offline'], 0) + self.assertEqual(result['failures'], [ + ['verdict-word-not-in-source', 'source.md', word + ': ' + word + ' ']]) + + def test_existing_suffix_and_punctuation_policy_is_preserved(self): + for word in check_doc_quotes.VERDICT: + for left in ('', ' ', '(', ':', '—', '“', '**', '`'): + for right in ('', '_SCOPED', '-WITH-SCOPE', 'ED'): + with self.subTest(word=word, left=left, right=right): + self.assertEqual(self.report(left + word + right, word)['failures'], []) + + def test_later_valid_occurrence_can_supply_source_verdict(self): + for word in check_doc_quotes.VERDICT: + with self.subTest(word=word): + self.assertEqual(self.report('UN' + word + '; later: ' + word, word)['failures'], []) + + def test_verdict_cannot_be_borrowed_from_another_linked_record(self): + self.source.write_text('UNVERIFIED', encoding='utf-8') + (self.root / 'other.md').write_text('VERIFIED', encoding='utf-8') + self.doc.write_text(HEADER + '| #1 | [r](source.md) — summary: VERIFIED' + '
[s](other.md) — summary: VERIFIED |\n', encoding='utf-8') + result = check_doc_quotes.check_document(str(self.doc), str(self.root)) + self.assertEqual((result['checked'], result['summaries']), (2, 2)) + self.assertEqual(len(result['failures']), 1) + self.assertEqual(result['failures'][0][:2], ['verdict-word-not-in-source', 'source.md']) + + def test_summary_word_prefix_and_case_policy_is_unchanged(self): + for word in check_doc_quotes.VERDICT: + for passage in ('UN' + word, '_' + word, word.lower()): + with self.subTest(passage=passage): + self.assertEqual(self.report('no selected vocabulary', passage)['failures'], []) + + def test_literal_quote_semantics_is_unchanged(self): + self.assertEqual(self.report('UNVERIFIED', 'UNVERIFIED', kind='quote')['failures'], []) + # A literal excerpt remains a substring check; this patch changes summaries only. + self.assertEqual(self.report('UNVERIFIED', 'VERIFIED', kind='quote')['failures'], []) + + def test_negation_and_suffixes_are_not_semantic_entailment(self): + for source in ('not VERIFIED', 'VERIFIED only for a different scope', 'VERIFIED_SCOPED'): + with self.subTest(source=source): + self.assertEqual(self.report(source, 'VERIFIED')['failures'], []) + + def test_real_cli_rejects_false_support_and_preserves_valid_exit(self): + import json + import subprocess + flags = ['-B', '-S'] + if sys.flags.optimize: + flags.append('-O') + for source, expected in [('UNVERIFIED', 1), ('VERIFIED_SCOPED', 0)]: + with self.subTest(source=source): + self.report(source, 'VERIFIED') + result = subprocess.run( + [sys.executable, *flags, str(ROOT / 'tools' / 'check_doc_quotes.py'), + str(self.doc), '--root', str(self.root)], + capture_output=True, text=True, timeout=10, check=False) + self.assertEqual(result.returncode, expected) + self.assertEqual(result.stderr, '') + self.assertEqual(json.loads(result.stdout)['passed'], expected == 0) + + if __name__ == '__main__': unittest.main() diff --git a/tools/check_doc_quotes.py b/tools/check_doc_quotes.py index 635505b48..cfb32d720 100644 --- a/tools/check_doc_quotes.py +++ b/tools/check_doc_quotes.py @@ -14,7 +14,9 @@ ``--github`` (token read from ``GITHUB_TOKEN``); without it the passage is reported as ``unchecked_offline`` and does not fail the check; * a ``summary:`` passage must not use a verdict word (ACCEPT, AMEND, HOLD, CONFIRMED, - VERIFIED, REJECT) that the linked record does not use, under the same offline rule; + VERIFIED, REJECT) that the linked record does not use, under the same offline rule. + Both sides use the same case-sensitive left word-boundary prefix check; scoped + suffixes remain allowed. This is not a semantic negation or entailment check; * a passage with no preceding link in its segment fails (``no-source-link``). Header and separator rows are skipped. Exit status 0 only when no checked passage fails; @@ -177,7 +179,9 @@ def check_document(doc_path, root, github=False, token=None): report['failures'].append(['not-verbatim', source, prefix]) else: for word in VERDICT: - if re.search(r'\b' + word, passage) and word not in body: + # Match the same verdict-prefix boundary on BOTH sides. + pattern = r'\b' + word + if re.search(pattern, passage) and not re.search(pattern, body): report['failures'].append(['verdict-word-not-in-source', source, word + ': ' + prefix]) return report