From bae69049eb8fd0b29304802818adae214795a700 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Wed, 14 Oct 2026 14:21:29 -0300 Subject: [PATCH 1/3] feat(scripts): add closed Topics vocabulary, enforce it, and add sync_index.py --- scripts/check_all.py | 11 +- scripts/new_problem.py | 16 +- scripts/repo_lib.py | 33 ++++ scripts/sync_index.py | 236 +++++++++++++++++++++++ scripts/tests/fixtures.py | 4 +- scripts/tests/test_check_all.py | 2 + scripts/tests/test_new_problem.py | 33 ++-- scripts/tests/test_sync_index.py | 179 +++++++++++++++++ scripts/tests/test_validate_structure.py | 12 +- scripts/validate_structure.py | 7 + 10 files changed, 510 insertions(+), 23 deletions(-) create mode 100644 scripts/tests/test_sync_index.py diff --git a/scripts/check_all.py b/scripts/check_all.py index b754dba..1113f13 100644 --- a/scripts/check_all.py +++ b/scripts/check_all.py @@ -6,6 +6,7 @@ 2. structure and metadata validation (validate_structure.py) 3. style and safety lint (lint_solutions.py) 4. build and run all test cases (run_tests.py) + 5. tracker/index drift (sync_index.py --check) Scope: python scripts/check_all.py # whole repository @@ -31,6 +32,7 @@ import lint_solutions import run_tests +import sync_index import validate_structure from repo_lib import changed_files, find_problem_dirs, implementations, problem_dirs_for @@ -97,12 +99,13 @@ def step(name: str, runner) -> None: # noqa: ANN001 else: step("script unit tests", run_unit_tests) + common = ["--root", str(root)] + flags = (["--strict"] if args.strict else []) + (["--github"] if args.github else []) + if nothing_to_check: print("\nNo problem folders changed: structure, lint and tests skipped.") steps += [("structure validation", None), ("lint", None), ("solution tests", None)] else: - common = ["--root", str(root)] - flags = (["--strict"] if args.strict else []) + (["--github"] if args.github else []) step("structure validation", lambda: validate_structure.main([*common, *flags, *path_args])) files = [str(f) for d in (dirs if dirs is not None else find_problem_dirs(root)) for f in implementations(d).values()] @@ -115,6 +118,10 @@ def step(name: str, runner) -> None: # noqa: ANN001 ) if on] step("solution tests", lambda: run_tests.main([*common, *test_flags, *path_args])) + # Index drift is a whole-repository concern (sync_index.py has no concept + # of scope), so it always runs, regardless of --staged/--since. + step("index sync", lambda: sync_index.main([*common, "--check"])) + print("\n=== Summary ===") failed = False for name, code in steps: diff --git a/scripts/new_problem.py b/scripts/new_problem.py index 3ef86f3..6ff1190 100644 --- a/scripts/new_problem.py +++ b/scripts/new_problem.py @@ -4,10 +4,10 @@ Usage: python scripts/new_problem.py 3485 "Some Title" \\ --category "Data Structures and Libraries" --difficulty 3 \\ - --topics "binary search tree,recursion" --lang cpp + --topics "Data Structures,Recursion" --lang cpp python scripts/new_problem.py 2602 "Total Spent" --category SQL \\ - --difficulty 2 --topics aggregation --lang sql --slug total-spent + --difficulty 2 --topics Math --lang sql --slug total-spent Creates problems//-/ with a README.md built from the right L1/L2 template (L3 also gets deep-dive.md), a solutions//main. @@ -28,7 +28,7 @@ import sys from pathlib import Path -from repo_lib import CATEGORIES, DOC_LEVELS, LANGUAGES, PRIORITIES, SLUG_RE, find_language +from repo_lib import CATEGORIES, DOC_LEVELS, LANGUAGES, PRIORITIES, SLUG_RE, TOPICS, find_language, find_topic REPO_ROOT = Path(__file__).resolve().parent.parent TEMPLATES = REPO_ROOT / "templates" @@ -132,7 +132,15 @@ def main(argv: list[str] | None = None) -> int: print(f"error: {problem_dir.relative_to(root)} already exists", file=sys.stderr) return 1 - topics = [t.strip() for t in args.topics.split(",") if t.strip()] + raw_topics = [t.strip() for t in args.topics.split(",") if t.strip()] + topics: list[str] = [] + for raw in raw_topics: + resolved = find_topic(raw) + if resolved is None: + print(f"error: Topics entry '{raw}' must be one of: {', '.join(TOPICS)}", file=sys.stderr) + return 1 + topics.append(resolved) + link = args.link or f"https://judge.beecrowd.com/en/problems/view/{args.id}" values = { "id": str(args.id), diff --git a/scripts/repo_lib.py b/scripts/repo_lib.py index 07d5f79..3d945ba 100644 --- a/scripts/repo_lib.py +++ b/scripts/repo_lib.py @@ -41,6 +41,25 @@ DOC_LEVELS: tuple[str, ...] = ("L1", "L2", "L3") PRIORITIES: tuple[str, ...] = ("Low", "Medium", "High") +# Closed vocabulary for the "Topics" metadata field, same spirit as CATEGORIES: +# a fixed, curated list so docs/indexes/by-topic.md never drifts into one +# heading per slightly-different phrase. Add to this list deliberately. +TOPICS: tuple[str, ...] = ( + "Input / Output", + "Arrays", + "Strings", + "Simulation", + "Math", + "Sorting", + "Binary Search", + "Greedy", + "Recursion", + "Backtracking", + "Data Structures", + "Graphs", + "Dynamic Programming", +) + # "Solved" must only be used for code that was really accepted by the judge. STATUSES: tuple[str, ...] = ("Solved", "In Progress", "Improving", "To Review") @@ -136,6 +155,20 @@ def parse_other_implementations(value: str) -> list[str]: return [part.strip() for part in cleaned.split(",") if part.strip()] +def find_topic(value: str) -> str | None: + """Map a Topics entry, case-insensitively, to its canonical spelling in TOPICS.""" + wanted = value.strip().lower() + for topic in TOPICS: + if wanted == topic.lower(): + return topic + return None + + +def parse_topics(value: str) -> list[str]: + """`binary search, Math` -> ["binary search", "Math"], raw (not yet validated).""" + return [part.strip() for part in value.split(",") if part.strip()] + + def find_problem_dirs(root: Path) -> list[Path]: """List every `problems///` directory, sorted.""" problems_root = root / "problems" diff --git a/scripts/sync_index.py b/scripts/sync_index.py index e69de29..82c3b69 100644 --- a/scripts/sync_index.py +++ b/scripts/sync_index.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Regenerate trackers/solved-problems.md and docs/indexes/*.md from problem metadata. + +Usage: + python scripts/sync_index.py # rewrite every generated file + python scripts/sync_index.py --check # exit 1 if any file is out of date, write nothing + +Only problems with Status: Solved are listed, so these files always reflect +what is genuinely accepted by the judge, not work in progress. The source of +truth is each problem's README.md metadata block; these five files are fully +generated and should never be hand-edited, any change would just be +overwritten on the next run. + +Exit code: 0 = up to date (or rewritten), 1 = --check found drift. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from repo_lib import ( + CATEGORIES, + TOPICS, + find_language, + find_problem_dirs, + find_topic, + parse_metadata, + parse_other_implementations, + parse_topics, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# docs/indexes/by-language.md lists languages in this order; distinct from +# repo_lib.LANGUAGES, whose dict order is about (folder, ext) convenience. +LANGUAGE_INDEX_ORDER = ("C++", "Python", "JavaScript", "C", "Rust", "SQL") + +TITLE_RE = re.compile(r"^#\s*\d+:\s*(.+)$") + + +@dataclass(frozen=True) +class Entry: + id: int + title: str + category: str + difficulty: int + doc_level: str + languages: tuple[str, ...] + topics: tuple[str, ...] + review: str + readme: Path + + +def read_title(readme_text: str) -> str: + first_line = readme_text.splitlines()[0] if readme_text.strip() else "" + match = TITLE_RE.match(first_line.strip()) + return match.group(1).strip() if match else "(untitled)" + + +def collect_entries(root: Path) -> list[Entry]: + """Every Status: Solved problem, as a flat list sorted by id.""" + entries: list[Entry] = [] + for problem_dir in find_problem_dirs(root): + readme = problem_dir / "README.md" + if not readme.is_file(): + continue + text = readme.read_text(encoding="utf-8") + meta = parse_metadata(text) + if meta.get("Status", "").strip().lower() != "solved": + continue + + primary = find_language(meta.get("Primary Language", "")) + others = (find_language(name) for name in parse_other_implementations(meta.get("Other Implementations", ""))) + languages = tuple(dict.fromkeys(lang for lang in (primary, *others) if lang)) + + topics = tuple(dict.fromkeys( + resolved for t in parse_topics(meta.get("Topics", "")) if (resolved := find_topic(t)) + )) + + try: + difficulty = int(meta.get("Difficulty Level", "")) + except ValueError: + difficulty = 0 + + problem_id_raw = meta.get("Problem ID") or problem_dir.name.split("-", 1)[0] + try: + problem_id = int(problem_id_raw) + except ValueError: + continue + + entries.append(Entry( + id=problem_id, + title=read_title(text), + category=meta.get("Category", ""), + difficulty=difficulty, + doc_level=meta.get("Documentation Level", ""), + languages=languages, + topics=topics, + review=meta.get("Review Priority", ""), + readme=readme, + )) + return sorted(entries, key=lambda e: e.id) + + +def link(entry: Entry, from_dir: Path) -> str: + return Path(os.path.relpath(entry.readme, start=from_dir)).as_posix() + + +def render_tracker(entries: list[Entry], out_path: Path) -> str: + lines = [ + "# Solved Problems", + "", + "| ID | Title | Category | Difficulty | Doc Level | Languages | Topics | Review |", + "|----|-------|----------|------------|-----------|-----------|--------|--------|", + ] + for e in entries: + lines.append( + f"| {e.id} | [{e.title}]({link(e, out_path.parent)}) | {e.category} | {e.difficulty} | " + f"{e.doc_level} | {', '.join(e.languages)} | {', '.join(e.topics)} | {e.review} |" + ) + return "\n".join(lines) + "\n" + + +def render_grouped( + title: str, + intro: str, + groups: list[str], + bucket_of: Callable[[Entry], list[str]], + entries: list[Entry], + out_path: Path, +) -> str: + """One `## ` heading per item in `groups`, in that order.""" + by_group: dict[str, list[Entry]] = {g: [] for g in groups} + for entry in entries: + for g in bucket_of(entry): + if g in by_group: + by_group[g].append(entry) + + lines = [f"# {title}", "", intro] + for g in groups: + lines += ["", f"## {g}", ""] + items = by_group[g] + lines.append("_No entries yet._" if not items else "\n".join( + f"- [{e.id}: {e.title}]({link(e, out_path.parent)})" for e in items + )) + return "\n".join(lines) + "\n" + + +def build_files(root: Path, entries: list[Entry]) -> dict[Path, str]: + tracker_path = root / "trackers" / "solved-problems.md" + category_path = root / "docs" / "indexes" / "by-category.md" + difficulty_path = root / "docs" / "indexes" / "by-difficulty.md" + language_path = root / "docs" / "indexes" / "by-language.md" + topic_path = root / "docs" / "indexes" / "by-topic.md" + + return { + tracker_path: render_tracker(entries, tracker_path), + category_path: render_grouped( + "Index by Category", + "This file organizes solved problems by Beecrowd category.", + list(CATEGORIES), + lambda e: [e.category] if e.category in CATEGORIES else [], + entries, + category_path, + ), + difficulty_path: render_grouped( + "Index by Difficulty", + "This file organizes solved problems by Beecrowd difficulty level.", + [f"Level {n}" for n in range(1, 11)], + lambda e: [f"Level {e.difficulty}"] if 1 <= e.difficulty <= 10 else [], + entries, + difficulty_path, + ), + language_path: render_grouped( + "Index by Language", + "This file organizes solved problems by implementation language.", + list(LANGUAGE_INDEX_ORDER), + lambda e: list(e.languages), + entries, + language_path, + ), + topic_path: render_grouped( + "Index by Topic", + "This file organizes solved problems by reusable topics and patterns.", + list(TOPICS), + lambda e: list(e.topics), + entries, + topic_path, + ), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Regenerate the tracker and docs indexes from problem metadata.") + parser.add_argument("--root", type=Path, default=REPO_ROOT) + parser.add_argument("--check", action="store_true", help="exit 1 on drift instead of writing") + args = parser.parse_args(argv) + + root = args.root.resolve() + entries = collect_entries(root) + files = build_files(root, entries) + + drift: list[Path] = [] + for path, content in files.items(): + current = path.read_text(encoding="utf-8") if path.is_file() else None + if current != content: + drift.append(path) + if not args.check: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + if args.check: + for path in drift: + print(f"out of date: {path.relative_to(root).as_posix()}") + if drift: + print(f"\n{len(drift)} file(s) out of date. Run: python scripts/sync_index.py") + return 1 + print(f"Indexes up to date ({len(entries)} solved problem(s)).") + return 0 + + for path in drift: + print(f"updated: {path.relative_to(root).as_posix()}") + if not drift: + print("Already up to date.") + print(f"{len(entries)} solved problem(s) indexed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/fixtures.py b/scripts/tests/fixtures.py index 228e9d3..5e0d5cf 100644 --- a/scripts/tests/fixtures.py +++ b/scripts/tests/fixtures.py @@ -12,7 +12,7 @@ from repo_lib import LANGUAGES # noqa: E402 -README = """# {id} — Fixture +README = """# {id}: Fixture ## Link https://example.invalid/{id} @@ -23,7 +23,7 @@ - Platform: Beecrowd - Category: {category} - Difficulty Level: 1 -- Topics: fixture +- Topics: Input / Output - Primary Language: {language} - Other Implementations: None - Documentation Level: L1 diff --git a/scripts/tests/test_check_all.py b/scripts/tests/test_check_all.py index e654ec0..eec8d41 100644 --- a/scripts/tests/test_check_all.py +++ b/scripts/tests/test_check_all.py @@ -14,6 +14,7 @@ import check_all # noqa: E402 import repo_lib # noqa: E402 +import sync_index # noqa: E402 class ProblemDirsForTest(unittest.TestCase): @@ -44,6 +45,7 @@ def setUp(self) -> None: self.git("config", "user.name", "Test") make_problem(self.root, problem_id=1001, slug="first") make_problem(self.root, problem_id=1002, slug="second") + quiet(lambda: sync_index.main(["--root", str(self.root)])) # start with the indexes already in sync self.git("add", "-A") self.git("commit", "-q", "-m", "base") diff --git a/scripts/tests/test_new_problem.py b/scripts/tests/test_new_problem.py index e41fc78..85e79b4 100644 --- a/scripts/tests/test_new_problem.py +++ b/scripts/tests/test_new_problem.py @@ -32,7 +32,7 @@ def test_creates_expected_structure(self) -> None: "3485", "Divided Class Two", "--category", "Data Structures and Libraries", "--difficulty", "3", - "--topics", "binary search tree,recursion", + "--topics", "Data Structures,Recursion", "--lang", "cpp", ) self.assertEqual(code, 0) @@ -45,7 +45,7 @@ def test_creates_expected_structure(self) -> None: self.assertIn("# 3485: Divided Class Two", readme) self.assertIn("- Category: Data Structures and Libraries", readme) self.assertIn("- Difficulty Level: 3", readme) - self.assertIn("- Topics: binary search tree, recursion", readme) + self.assertIn("- Topics: Data Structures, Recursion", readme) self.assertIn("- Primary Language: C++", readme) self.assertIn("- Status: In Progress", readme) self.assertIn("- [ ] Solved", readme) @@ -55,14 +55,14 @@ def test_creates_expected_structure(self) -> None: def test_range_folder_computed_correctly(self) -> None: self.create( "1050", "Example", "--category", "Beginner", "--difficulty", "1", - "--topics", "io", "--lang", "python", + "--topics", "Input / Output", "--lang", "python", ) self.assertTrue(self.problem_dir("1000-1099", "1050-example").is_dir()) def test_refuses_to_overwrite_existing_folder(self) -> None: args = ( "3485", "Divided Class Two", "--category", "Data Structures and Libraries", - "--difficulty", "3", "--topics", "trees", "--lang", "cpp", + "--difficulty", "3", "--topics", "Data Structures", "--lang", "cpp", ) self.assertEqual(self.create(*args), 0) self.assertEqual(self.create(*args), 1) @@ -70,44 +70,51 @@ def test_refuses_to_overwrite_existing_folder(self) -> None: def test_sql_requires_sql_category(self) -> None: code = self.create( "2700", "Some Query", "--category", "Beginner", "--difficulty", "2", - "--topics", "aggregation", "--lang", "sql", + "--topics", "Math", "--lang", "sql", ) self.assertEqual(code, 1) def test_sql_category_requires_sql_language(self) -> None: code = self.create( "2700", "Some Query", "--category", "SQL", "--difficulty", "2", - "--topics", "aggregation", "--lang", "cpp", + "--topics", "Math", "--lang", "cpp", ) self.assertEqual(code, 1) def test_sql_problem_created_correctly(self) -> None: code = self.create( "2700", "Some Query", "--category", "SQL", "--difficulty", "2", - "--topics", "aggregation", "--lang", "sql", + "--topics", "Math", "--lang", "sql", ) self.assertEqual(code, 0) problem = self.problem_dir("2700-2799", "2700-some-query") self.assertTrue((problem / "solutions" / "sql" / "main.sql").is_file()) + def test_unknown_topic_rejected(self) -> None: + code = self.create( + "3491", "Odd One", "--category", "Beginner", "--difficulty", "1", + "--topics", "tree depth", "--lang", "cpp", + ) + self.assertEqual(code, 1) + def test_unknown_language_rejected(self) -> None: code = self.create( "3486", "Another One", "--category", "Beginner", "--difficulty", "1", - "--topics", "io", "--lang", "cobol", + "--topics", "Input / Output", "--lang", "cobol", ) self.assertEqual(code, 1) def test_non_ascii_title_requires_explicit_slug(self) -> None: code = self.create( "3487", "Divisão de Turma", "--category", "Beginner", "--difficulty", "1", - "--topics", "io", "--lang", "cpp", + "--topics", "Input / Output", "--lang", "cpp", ) self.assertEqual(code, 1) def test_explicit_slug_overrides_derivation(self) -> None: code = self.create( "3487", "Divisão de Turma", "--category", "Beginner", "--difficulty", "1", - "--topics", "io", "--lang", "cpp", "--slug", "class-split", + "--topics", "Input / Output", "--lang", "cpp", "--slug", "class-split", ) self.assertEqual(code, 0) self.assertTrue(self.problem_dir("3400-3499", "3487-class-split").is_dir()) @@ -115,7 +122,7 @@ def test_explicit_slug_overrides_derivation(self) -> None: def test_level_l3_also_creates_deep_dive(self) -> None: self.create( "3488", "Hard One", "--category", "Graphs", "--difficulty", "8", - "--topics", "flood fill", "--lang", "cpp", "--level", "L3", + "--topics", "Graphs", "--lang", "cpp", "--level", "L3", ) problem = self.problem_dir("3400-3499", "3488-hard-one") self.assertIn("- Documentation Level: L3", (problem / "README.md").read_text(encoding="utf-8")) @@ -126,14 +133,14 @@ def test_level_l3_also_creates_deep_dive(self) -> None: def test_notes_flag_creates_notes_file(self) -> None: self.create( "3489", "Needs Notes", "--category", "Beginner", "--difficulty", "1", - "--topics", "io", "--lang", "cpp", "--notes", + "--topics", "Input / Output", "--lang", "cpp", "--notes", ) self.assertTrue(self.problem_dir("3400-3499", "3489-needs-notes").joinpath("notes.md").is_file()) def test_output_validates_with_only_a_warning(self) -> None: self.create( "3490", "Fresh Scaffold", "--category", "Beginner", "--difficulty", "1", - "--topics", "io", "--lang", "cpp", + "--topics", "Input / Output", "--lang", "cpp", ) issues = vs.validate_repository(self.root) errors = [i for i in issues if i.level == "error"] diff --git a/scripts/tests/test_sync_index.py b/scripts/tests/test_sync_index.py new file mode 100644 index 0000000..1d633f9 --- /dev/null +++ b/scripts/tests/test_sync_index.py @@ -0,0 +1,179 @@ +"""Tests for scripts/sync_index.py.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import sync_index as si # noqa: E402 + +README = """# {id}: {title} + +## Link +https://example.invalid/{id} + +## Metadata + +- Problem ID: {id} +- Platform: Beecrowd +- Category: {category} +- Difficulty Level: {difficulty} +- Topics: {topics} +- Primary Language: {language} +- Other Implementations: {others} +- Documentation Level: L1 +- Status: {status} +- Review Priority: Medium + +## Status + +- [{box}] Solved +- [ ] Revisit later +""" + + +class SyncIndexTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + for name in ("trackers", "docs/indexes"): + (self.root / name).mkdir(parents=True) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def add_problem( + self, + problem_id: int, + title: str = "Example", + category: str = "Beginner", + difficulty: int = 1, + topics: str = "Input / Output", + language: str = "C++", + others: str = "None", + status: str = "Solved", + slug: str | None = None, + ) -> None: + slug = slug or title.lower().replace(" ", "-") + start = problem_id // 100 * 100 + problem = self.root / "problems" / f"{start:04d}-{start + 99:04d}" / f"{problem_id}-{slug}" + (problem / "solutions" / "cpp").mkdir(parents=True) + (problem / "README.md").write_text( + README.format( + id=problem_id, title=title, category=category, difficulty=difficulty, + topics=topics, language=language, others=others, status=status, + box="x" if status == "Solved" else " ", + ), + encoding="utf-8", + ) + + def read(self, relative: str) -> str: + return (self.root / relative).read_text(encoding="utf-8") + + def test_no_solved_problems_is_the_empty_state(self) -> None: + self.add_problem(1001, status="In Progress") + si.main(["--root", str(self.root)]) + + tracker = self.read("trackers/solved-problems.md") + self.assertNotIn("1001", tracker) + + category = self.read("docs/indexes/by-category.md") + self.assertIn("_No entries yet._", category) + self.assertNotIn("1001", category) + + def test_solved_problem_appears_everywhere_it_should(self) -> None: + self.add_problem( + 3485, title="Example Two", category="Graphs", difficulty=6, + topics="Graphs, Simulation", language="C++", + ) + si.main(["--root", str(self.root)]) + + tracker = self.read("trackers/solved-problems.md") + self.assertIn("| 3485 |", tracker) + self.assertIn("[Example Two]", tracker) + self.assertIn("Graphs, Simulation", tracker) + + category = self.read("docs/indexes/by-category.md") + self.assertIn("## Graphs\n\n- [3485: Example Two]", category) + + difficulty = self.read("docs/indexes/by-difficulty.md") + self.assertIn("## Level 6\n\n- [3485: Example Two]", difficulty) + + language = self.read("docs/indexes/by-language.md") + self.assertIn("## C++\n\n- [3485: Example Two]", language) + + topic = self.read("docs/indexes/by-topic.md") + self.assertIn("## Graphs\n\n- [3485: Example Two]", topic) + self.assertIn("## Simulation\n\n- [3485: Example Two]", topic) + + def test_other_implementations_add_extra_language_entries(self) -> None: + self.add_problem(3486, title="Dual", language="C", others="Rust") + si.main(["--root", str(self.root)]) + + language = self.read("docs/indexes/by-language.md") + self.assertIn("## C\n\n- [3486: Dual]", language) + self.assertIn("## Rust\n\n- [3486: Dual]", language) + + def test_links_resolve_relative_to_each_file(self) -> None: + self.add_problem(3487, title="Linked") + si.main(["--root", str(self.root)]) + + tracker = self.read("trackers/solved-problems.md") + self.assertIn("](../problems/3400-3499/3487-linked/README.md)", tracker) + + category = self.read("docs/indexes/by-category.md") + self.assertIn("](../../problems/3400-3499/3487-linked/README.md)", category) + + def test_entries_sorted_by_id(self) -> None: + self.add_problem(3490, title="Second") + self.add_problem(1001, title="First") + si.main(["--root", str(self.root)]) + tracker = self.read("trackers/solved-problems.md") + self.assertLess(tracker.index("1001"), tracker.index("3490")) + + def test_check_mode_reports_drift_without_writing(self) -> None: + self.add_problem(3491, title="Drift") + code = si.main(["--root", str(self.root), "--check"]) + self.assertEqual(code, 1) + self.assertFalse((self.root / "trackers" / "solved-problems.md").is_file()) + + def test_check_mode_is_clean_after_a_real_run(self) -> None: + self.add_problem(3492, title="Settled") + si.main(["--root", str(self.root)]) + self.assertEqual(si.main(["--root", str(self.root), "--check"]), 0) + + def test_creates_missing_parent_directories(self) -> None: + bare_tmp = tempfile.TemporaryDirectory() + try: + bare_root = Path(bare_tmp.name) + start = 3494 // 100 * 100 + problem = bare_root / "problems" / f"{start:04d}-{start + 99:04d}" / "3494-bare" + (problem / "solutions" / "cpp").mkdir(parents=True) + (problem / "README.md").write_text( + README.format( + id=3494, title="Bare", category="Beginner", difficulty=1, + topics="Input / Output", language="C++", others="None", + status="Solved", box="x", + ), + encoding="utf-8", + ) + self.assertEqual(si.main(["--root", str(bare_root)]), 0) + self.assertTrue((bare_root / "trackers" / "solved-problems.md").is_file()) + finally: + bare_tmp.cleanup() + + def test_rerun_is_idempotent(self) -> None: + self.add_problem(3493, title="Stable") + si.main(["--root", str(self.root)]) + first = self.read("docs/indexes/by-topic.md") + si.main(["--root", str(self.root)]) + second = self.read("docs/indexes/by-topic.md") + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_validate_structure.py b/scripts/tests/test_validate_structure.py index ad486df..945d872 100644 --- a/scripts/tests/test_validate_structure.py +++ b/scripts/tests/test_validate_structure.py @@ -12,7 +12,7 @@ import validate_structure as vs # noqa: E402 from fixtures import quiet # noqa: E402 -README = """# 1001 — Hello World +README = """# 1001: Hello World ## Link https://example.invalid/1001 @@ -23,7 +23,7 @@ - Platform: Beecrowd - Category: Beginner - Difficulty Level: 1 -- Topics: output +- Topics: Input / Output - Primary Language: C++ - Other Implementations: None - Documentation Level: L1 @@ -148,6 +148,14 @@ def test_test_cases_must_come_in_pairs(self) -> None: self.assert_error_contains("lonely.in has no matching lonely.out") self.assert_error_contains("orphan.out has no matching orphan.in") + def test_invalid_topic_rejected(self) -> None: + self.readme_replace("Topics: Input / Output", "Topics: Input / Output, tree depth") + self.assert_error_contains("Topics entry 'tree depth' must be one of") + + def test_topic_matching_is_case_insensitive(self) -> None: + self.readme_replace("Topics: Input / Output", "Topics: input / output") + self.assertEqual(self.messages("error"), []) + def test_strict_mode_fails_on_warnings(self) -> None: (self.problem / "solutions" / "python").mkdir() (self.problem / "solutions" / "python" / "main.py").write_text("print(1)\n", encoding="utf-8") diff --git a/scripts/validate_structure.py b/scripts/validate_structure.py index 11acf20..9c3ad41 100644 --- a/scripts/validate_structure.py +++ b/scripts/validate_structure.py @@ -29,13 +29,16 @@ REQUIRED_FIELDS, SLUG_RE, STATUSES, + TOPICS, Issue, count_issues, find_language, find_problem_dirs, + find_topic, parse_metadata, parse_other_implementations, parse_status_checkboxes, + parse_topics, print_issues, ) @@ -110,6 +113,10 @@ def check_choice(field: str, allowed: tuple[str, ...]) -> None: if level and not (level.isdigit() and 1 <= int(level) <= 10): issues.append(Issue("error", rel, f"Difficulty Level '{level}' must be an integer from 1 to 10")) + for topic in parse_topics(meta.get("Topics", "")): + if find_topic(topic) is None: + issues.append(Issue("error", rel, f"Topics entry '{topic}' must be one of: {', '.join(TOPICS)}")) + first_line = text.splitlines()[0] if text.strip() else "" if name_match and not first_line.startswith(f"# {name_match.group(1)}"): issues.append(Issue("warning", rel, f"first heading should start with '# {name_match.group(1)}'")) From 0ddd6ec3363a84ebd7ab24be666d74dbd89dc603 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Wed, 14 Oct 2026 14:22:56 -0300 Subject: [PATCH 2/3] fix(problem-3484): use canonical topics and drop leftover em dash in heading --- problems/3400-3499/3484-divided-class/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problems/3400-3499/3484-divided-class/README.md b/problems/3400-3499/3484-divided-class/README.md index 9756791..cbe58a2 100644 --- a/problems/3400-3499/3484-divided-class/README.md +++ b/problems/3400-3499/3484-divided-class/README.md @@ -1,4 +1,4 @@ -# 3484 — Divided Class +# 3484: Divided Class ## Link https://judge.beecrowd.com/pt/problems/view/3484 @@ -9,7 +9,7 @@ https://judge.beecrowd.com/pt/problems/view/3484 - Platform: Beecrowd - Category: Data Structures and Libraries - Difficulty Level: 2 -- Topics: binary search tree, tree depth, simulation +- Topics: Data Structures, Simulation - Primary Language: C++ - Other Implementations: None - Documentation Level: L2 From 25fe9e53e01215bee324157f0a9473db48b858e0 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Wed, 14 Oct 2026 14:24:50 -0300 Subject: [PATCH 3/3] chore(trackers): regenerate indexes with sync_index.py --- docs/indexes/by-category.md | 2 +- docs/indexes/by-difficulty.md | 2 +- docs/indexes/by-language.md | 2 +- docs/indexes/by-topic.md | 2 +- trackers/solved-problems.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/indexes/by-category.md b/docs/indexes/by-category.md index 148d584..2b71c15 100644 --- a/docs/indexes/by-category.md +++ b/docs/indexes/by-category.md @@ -36,4 +36,4 @@ _No entries yet._ ## SQL -_No entries yet._ \ No newline at end of file +_No entries yet._ diff --git a/docs/indexes/by-difficulty.md b/docs/indexes/by-difficulty.md index ccc7677..c8928f8 100644 --- a/docs/indexes/by-difficulty.md +++ b/docs/indexes/by-difficulty.md @@ -40,4 +40,4 @@ _No entries yet._ ## Level 10 -_No entries yet._ \ No newline at end of file +_No entries yet._ diff --git a/docs/indexes/by-language.md b/docs/indexes/by-language.md index 95784f2..d745ef6 100644 --- a/docs/indexes/by-language.md +++ b/docs/indexes/by-language.md @@ -24,4 +24,4 @@ _No entries yet._ ## SQL -_No entries yet._ \ No newline at end of file +_No entries yet._ diff --git a/docs/indexes/by-topic.md b/docs/indexes/by-topic.md index 4ac239d..cb40eb1 100644 --- a/docs/indexes/by-topic.md +++ b/docs/indexes/by-topic.md @@ -52,4 +52,4 @@ _No entries yet._ ## Dynamic Programming -_No entries yet._ \ No newline at end of file +_No entries yet._ diff --git a/trackers/solved-problems.md b/trackers/solved-problems.md index c0d9f6f..37352e4 100644 --- a/trackers/solved-problems.md +++ b/trackers/solved-problems.md @@ -1,4 +1,4 @@ # Solved Problems | ID | Title | Category | Difficulty | Doc Level | Languages | Topics | Review | -|----|-------|----------|------------|-----------|-----------|--------|--------| \ No newline at end of file +|----|-------|----------|------------|-----------|-----------|--------|--------|