diff --git a/.github/workflows/navigation-links.yml b/.github/workflows/navigation-links.yml index bc918f88d..aaa99e776 100644 --- a/.github/workflows/navigation-links.yml +++ b/.github/workflows/navigation-links.yml @@ -26,15 +26,16 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: python-version: '3.11.16' - - name: Resolve every link from the containing document and check every file-sourced quotation, in both interpreter modes + - name: Resolve every link from the containing document and authenticate every navigation quotation, in both interpreter modes env: PYTHONDONTWRITEBYTECODE: '1' + GITHUB_TOKEN: ${{ github.token }} run: | set -euo pipefail python -B -S tools/check_doc_links.py PROOF_INDEX.md docs/integration/*.md python -B -O -S tools/check_doc_links.py PROOF_INDEX.md docs/integration/*.md - python -B -S tools/check_doc_quotes.py PROOF_INDEX.md docs/integration/2026-09-29-navigation-refresh.md - python -B -O -S tools/check_doc_quotes.py PROOF_INDEX.md docs/integration/2026-09-29-navigation-refresh.md + python -B -S tools/check_doc_quotes.py --github --require-online PROOF_INDEX.md docs/integration/2026-09-29-navigation-refresh.md + python -B -O -S tools/check_doc_quotes.py --github --require-online PROOF_INDEX.md docs/integration/2026-09-29-navigation-refresh.md python -B -S -m unittest -v tests.test_navigation_links tests.test_navigation_quotes python -B -O -S -m unittest -v tests.test_navigation_links tests.test_navigation_quotes git diff --exit-code diff --git a/tests/test_navigation_quotes.py b/tests/test_navigation_quotes.py index 9063797e3..0e0de0141 100644 --- a/tests/test_navigation_quotes.py +++ b/tests/test_navigation_quotes.py @@ -89,6 +89,25 @@ def test_github_link_is_unchecked_offline_not_failed(self): self.assertEqual(report['failures'], []) self.assertEqual((report['checked'], report['unchecked_offline']), (0, 1)) + + def test_require_online_rejects_unchecked_github_link(self): + doc = self.root / 'docs' / 'nested' / 'a.md' + doc.write_text(HEADER + '| #1 | [c](https://github.com/o/r/pull/1#issuecomment-2) — “anything at all” |\n', encoding='utf-8') + rc = check_doc_quotes.main([str(doc), '--root', self.tmp, '--require-online']) + self.assertEqual(rc, 1) + + def test_github_transport_failure_is_not_counted_as_offline(self): + doc = self.root / 'docs' / 'nested' / 'a.md' + doc.write_text(HEADER + '| #1 | [c](https://github.com/o/r/pull/1#issuecomment-2) — “anything at all” |\n', encoding='utf-8') + original = check_doc_quotes.Sources._fetch + try: + check_doc_quotes.Sources._fetch = lambda self, match: (None, 'unreachable') + report = check_doc_quotes.check_document(str(doc), self.tmp, github=True, token='x') + finally: + check_doc_quotes.Sources._fetch = original + self.assertEqual(report['unchecked_offline'], 0) + self.assertEqual([f[0] for f in report['failures']], ['source-unreachable']) + def test_nearest_preceding_link_governs_each_passage(self): (self.root / 'reviews' / 's.md').write_text('Disposition: AMEND the ledger.\n', encoding='utf-8') row = ('| #1 | [r](../../reviews/r.md) — “No defect was found.”' @@ -103,5 +122,101 @@ def test_header_row_quotation_marks_are_ignored(self): self.assertEqual((report['quotes'], report['summaries']), (0, 1)) +class VerdictWordStart(unittest.TestCase): + """Vocabulary membership only: not negation, suffix equality or entailment.""" + + 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 / 'record.md' + self.doc = self.root / 'navigation.md' + + def _report(self, source, passage): + self.source.write_text(source, encoding='utf-8') + self.doc.write_text(HEADER + '| #1 | [r](record.md) — summary: ' + passage + '|\n', + encoding='utf-8') + return check_doc_quotes.check_document(str(self.doc), str(self.root)) + + def test_prefixed_source_words_do_not_supply_verdict_roots(self): + for word in check_doc_quotes.VERDICT: + for prefix in ('UN', 'NON_', 'x', '_', '0', 'é'): + with self.subTest(word=word, prefix=prefix): + report = self._report(prefix + word, word) + self.assertEqual(report['failures'], + [['verdict-word-not-in-source', 'record.md', word + ': ' + word]]) + self.assertEqual((report['checked'], report['unchecked_offline']), (1, 0)) + + def test_scoped_summary_cannot_use_a_prefixed_source_word(self): + report = self._report('UNVERIFIED_SCOPED', 'VERIFIED_SCOPED') + self.assertEqual(report['failures'], + [['verdict-word-not-in-source', 'record.md', 'VERIFIED: VERIFIED_SCOPED']]) + + def test_existing_word_start_families_and_punctuation_remain_supported(self): + for word, family in zip(check_doc_quotes.VERDICT, + ('ACCEPTED', 'AMEND_REQUIRED', 'HOLD_PENDING', 'CONFIRMED_SCOPED', + 'VERIFIED_SCOPED', 'REJECTED')): + for source in (word, family, '**' + word + '**.', '`' + family + '`', + 'Disposition:\n(' + family + ');'): + with self.subTest(word=word, source=source): + self.assertEqual(self._report(source, word)['failures'], []) + self.assertEqual(self._report(source, family)['failures'], []) + + def test_missing_roots_and_multiple_failures_keep_exact_diagnostics(self): + for word in check_doc_quotes.VERDICT: + with self.subTest(word=word): + self.assertEqual(self._report('No disposition.', word)['failures'], + [['verdict-word-not-in-source', 'record.md', word + ': ' + word]]) + passage = 'ACCEPT HOLD VERIFIED' + self.assertEqual(self._report('ACCEPT UNVERIFIED', passage)['failures'], + [['verdict-word-not-in-source', 'record.md', 'HOLD: ' + passage], + ['verdict-word-not-in-source', 'record.md', 'VERIFIED: ' + passage]]) + + def test_embedded_summary_words_are_not_new_verdict_claims(self): + # Preserve the existing left-boundary policy on the summary side. + for word in check_doc_quotes.VERDICT: + with self.subTest(word=word): + self.assertEqual(self._report('No disposition.', 'UN' + word)['failures'], []) + + def test_case_sensitivity_and_semantic_limits_are_explicit(self): + self.assertEqual(self._report('verified', 'VERIFIED')['failures'], + [['verdict-word-not-in-source', 'record.md', 'VERIFIED: VERIFIED']]) + self.assertEqual(self._report('No disposition.', 'verified')['failures'], []) + # Presence does not establish entailment or equality of scoped verdicts. + self.assertEqual(self._report('NOT VERIFIED', 'VERIFIED')['failures'], []) + self.assertEqual(self._report('VERIFIED_A', 'VERIFIED_B')['failures'], []) + + def test_other_source_segments_cannot_supply_the_missing_verdict(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](record.md) summary: VERIFIED' + '
[s](other.md) summary: VERIFIED |\n', encoding='utf-8') + report = check_doc_quotes.check_document(str(self.doc), str(self.root)) + self.assertEqual(report['failures'], + [['verdict-word-not-in-source', 'record.md', 'VERIFIED: VERIFIED']]) + self.assertEqual((report['checked'], report['summaries']), (2, 2)) + + def test_cli_reports_boundary_failure_and_genuine_success(self): + import json + import subprocess + flags = ['-B', '-S'] if not sys.flags.optimize else ['-B', '-O', '-S'] + for source, expected_exit in (('UNVERIFIED', 1), ('VERIFIED_SCOPED', 0)): + with self.subTest(source=source): + self._report(source, 'VERIFIED') + process = subprocess.run( + [sys.executable, *flags, str(ROOT / 'tools/check_doc_quotes.py'), + str(self.doc), '--root', str(self.root), '--require-online'], + capture_output=True, text=True, timeout=15) + self.assertEqual(process.returncode, expected_exit, process.stderr) + self.assertEqual(process.stderr, '') + report = json.loads(process.stdout) + self.assertIs(report['passed'], expected_exit == 0) + result = report['documents'][str(self.doc)] + self.assertEqual((result['checked'], result['unchecked_offline']), (1, 0)) + expected = [] if expected_exit == 0 else [ + ['verdict-word-not-in-source', 'record.md', 'VERIFIED: VERIFIED']] + self.assertEqual(result['failures'], expected) + + if __name__ == '__main__': unittest.main() diff --git a/tools/check_doc_quotes.py b/tools/check_doc_quotes.py index 1e25a80ba..0a3e43909 100644 --- a/tools/check_doc_quotes.py +++ b/tools/check_doc_quotes.py @@ -14,7 +14,10 @@ ``--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 existing case-sensitive word-start rule: ACCEPTED and + AMEND_REQUIRED retain their root, but UNVERIFIED does not supply VERIFIED. + Root presence does not check negation, scoped-suffix equality or entailment; * 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 +180,7 @@ 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: + if re.search(r'\b' + word, passage) and not re.search(r'\b' + word, body): report['failures'].append(['verdict-word-not-in-source', source, word + ': ' + prefix]) return report @@ -187,14 +190,18 @@ def main(argv=None): parser.add_argument('documents', nargs='+') parser.add_argument('--root', default=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) parser.add_argument('--github', action='store_true', help='also check GitHub review/comment links (GITHUB_TOKEN)') + parser.add_argument('--require-online', action='store_true', help='fail if any linked GitHub/HTTP source is left unchecked offline') args = parser.parse_args(argv) token = os.environ.get('GITHUB_TOKEN') - report = {'root': os.path.realpath(args.root), 'github': args.github, 'documents': {}, 'passed': True} + report = {'root': os.path.realpath(args.root), 'github': args.github, 'require_online': args.require_online, 'documents': {}, 'passed': True} for doc in args.documents: result = check_document(doc, args.root, args.github, token) report['documents'][doc] = result if result['failures']: report['passed'] = False + if args.require_online and result['unchecked_offline']: + result['failures'].append(['unchecked-offline-required', '', str(result['unchecked_offline'])]) + report['passed'] = False print(json.dumps(report, indent=2, sort_keys=True)) return 0 if report['passed'] else 1