From 884a192a3f4600d0cb9f5baa21fbc88b4a1476e9 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Tue, 6 Oct 2026 15:48:38 -0300 Subject: [PATCH 1/6] feat(scripts): add validate_structure, lint_solutions, run_tests, check_all with tests and git hooks --- scripts/check_all.py | 128 +++++++++ scripts/hooks/pre-commit | 15 ++ scripts/hooks/pre-push | 21 ++ scripts/lint_solutions.py | 320 +++++++++++++++++++++++ scripts/repo_lib.py | 218 +++++++++++++++ scripts/run_tests.py | 262 +++++++++++++++++++ scripts/tests/fixtures.py | 80 ++++++ scripts/tests/test_check_all.py | 108 ++++++++ scripts/tests/test_docs.py | 40 +++ scripts/tests/test_lint_solutions.py | 132 ++++++++++ scripts/tests/test_run_tests.py | 144 ++++++++++ scripts/tests/test_templates.py | 47 ++++ scripts/tests/test_validate_structure.py | 159 +++++++++++ scripts/validate_structure.py | 277 ++++++++++++++++++++ 14 files changed, 1951 insertions(+) create mode 100644 scripts/check_all.py create mode 100644 scripts/hooks/pre-commit create mode 100644 scripts/hooks/pre-push create mode 100644 scripts/lint_solutions.py create mode 100644 scripts/repo_lib.py create mode 100644 scripts/run_tests.py create mode 100644 scripts/tests/fixtures.py create mode 100644 scripts/tests/test_check_all.py create mode 100644 scripts/tests/test_docs.py create mode 100644 scripts/tests/test_lint_solutions.py create mode 100644 scripts/tests/test_run_tests.py create mode 100644 scripts/tests/test_templates.py create mode 100644 scripts/tests/test_validate_structure.py diff --git a/scripts/check_all.py b/scripts/check_all.py new file mode 100644 index 0000000..b754dba --- /dev/null +++ b/scripts/check_all.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Run every repository check in one command (the gate before any Git update). + +Steps, in order: + 1. unit tests of the scripts themselves + 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) + +Scope: + python scripts/check_all.py # whole repository + python scripts/check_all.py --staged # only problems with staged changes + python scripts/check_all.py --since origin/main # only problems changed since a ref + +Strictness (used by the Git hooks and CI): + --sanitize ASan+UBSan for C/C++ --werror warnings fail the build + --strict warnings fail validation and lint + --require-toolchains a missing compiler/interpreter is a failure + --github GitHub Actions annotations + +Exit code: 0 = everything passed, 1 = at least one step failed. +""" + +from __future__ import annotations + +import argparse +import sys +import time +import unittest +from pathlib import Path + +import lint_solutions +import run_tests +import validate_structure +from repo_lib import changed_files, find_problem_dirs, implementations, problem_dirs_for + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS_DIR = Path(__file__).resolve().parent + + +def run_unit_tests() -> int: + suite = unittest.defaultTestLoader.discover(str(SCRIPTS_DIR / "tests"), top_level_dir=str(SCRIPTS_DIR / "tests")) + result = unittest.TextTestRunner(verbosity=0, stream=sys.stdout).run(suite) + return 0 if result.wasSuccessful() else 1 + + +def select_scope(root: Path, staged: bool, since: str | None) -> tuple[list[Path] | None, bool, str]: + """Return (problem dirs or None for all, scripts changed, human description).""" + if not staged and not since: + return None, True, "whole repository" + + files = changed_files(root, staged=staged, since=since) + if files is None: + return None, True, "whole repository (git could not tell what changed)" + + infra_changed = any(name.startswith(("scripts/", ".github/")) for name in files) + dirs = problem_dirs_for(root, files) + label = "staged changes" if staged else f"changes since {since}" + return dirs, infra_changed, f"{label}: {len(dirs)} problem folder(s)" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run all repository checks.") + parser.add_argument("--root", type=Path, default=REPO_ROOT) + parser.add_argument("--staged", action="store_true", help="only problems with staged changes") + parser.add_argument("--since", metavar="REF", help="only problems changed since REF (e.g. origin/main)") + parser.add_argument("--all", action="store_true", help="whole repository (default)") + parser.add_argument("--sanitize", action="store_true") + parser.add_argument("--werror", action="store_true") + parser.add_argument("--strict", action="store_true") + parser.add_argument("--require-toolchains", action="store_true") + parser.add_argument("--github", action="store_true") + parser.add_argument("--skip-unit-tests", action="store_true") + args = parser.parse_args(argv) + + root = args.root.resolve() + if args.all: + dirs, infra_changed, scope = None, True, "whole repository" + else: + dirs, infra_changed, scope = select_scope(root, args.staged, args.since) + print(f"Scope: {scope}") + + path_args = [str(d) for d in dirs] if dirs is not None else [] + nothing_to_check = dirs is not None and not dirs + + steps: list[tuple[str, int | None]] = [] + + def step(name: str, runner) -> None: # noqa: ANN001 + print(f"\n=== {name} ===") + started = time.perf_counter() + code = runner() + print(f"--- {name}: {'ok' if code == 0 else 'FAILED'} ({time.perf_counter() - started:.1f}s)") + steps.append((name, code)) + + if args.skip_unit_tests or not infra_changed: + steps.append(("script unit tests", None)) + else: + step("script unit tests", run_unit_tests) + + 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()] + step("lint", lambda: lint_solutions.main([*common, *flags, *files]) if files else 0) + + test_flags = [flag for flag, on in ( + ("--sanitize", args.sanitize), + ("--werror", args.werror), + ("--require-toolchains", args.require_toolchains), + ) if on] + step("solution tests", lambda: run_tests.main([*common, *test_flags, *path_args])) + + print("\n=== Summary ===") + failed = False + for name, code in steps: + label = "skipped" if code is None else ("ok" if code == 0 else "FAILED") + failed = failed or code not in (None, 0) + print(f"{label:8} {name}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100644 index 0000000..1311577 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,15 @@ +#!/bin/sh +# Fast gate: validate, lint and test only the problems with staged changes. +# Enable once per clone: git config core.hooksPath scripts/hooks +# Bypass in an emergency: git commit --no-verify (CI will still run the full gate). + +cd "$(git rev-parse --show-toplevel)" || exit 1 + +PY=$(command -v python3 || command -v python) || { + echo "pre-commit: Python 3 was not found on PATH" >&2 + exit 1 +} + +# CHECK_ARGS lets you add flags for one commit, e.g. CHECK_ARGS=--strict git commit +# shellcheck disable=SC2086 +exec "$PY" scripts/check_all.py --staged ${CHECK_ARGS:-} diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100644 index 0000000..4561884 --- /dev/null +++ b/scripts/hooks/pre-push @@ -0,0 +1,21 @@ +#!/bin/sh +# Full gate before anything leaves your machine: everything that changed since +# origin/main, built with sanitizers and warnings-as-errors. +# Enable once per clone: git config core.hooksPath scripts/hooks +# Bypass in an emergency: git push --no-verify (CI will still run the full gate). + +cd "$(git rev-parse --show-toplevel)" || exit 1 + +PY=$(command -v python3 || command -v python) || { + echo "pre-push: Python 3 was not found on PATH" >&2 + exit 1 +} + +if git rev-parse --verify --quiet origin/main >/dev/null; then + SCOPE="--since origin/main" +else + SCOPE="--all" +fi + +# shellcheck disable=SC2086 +exec "$PY" scripts/check_all.py $SCOPE --sanitize --werror ${CHECK_ARGS:-} diff --git a/scripts/lint_solutions.py b/scripts/lint_solutions.py new file mode 100644 index 0000000..bea7aba --- /dev/null +++ b/scripts/lint_solutions.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""Mechanical style and safety checks for solution files. + +Only rules that a program can decide are checked here; the reasoning behind each +one lives in `docs/coding-standards.md` and the per-language style guides. + +Usage: + python scripts/lint_solutions.py # every solution + python scripts/lint_solutions.py path/to/main.cpp # specific files + python scripts/lint_solutions.py --strict # warnings fail too + +Suppress one finding deliberately by writing `lint:allow RULE_ID` (comma separated +ids allowed) in a comment on the same line or on the line above, and say why: + + int* raw = new int[3]; // lint:allow CPP004 deliberate raw-pointer exercise + +Exit code: 0 = clean, 1 = errors (or warnings with --strict). +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + +from repo_lib import LANGUAGES, Issue, count_issues, find_problem_dirs, implementations, print_issues + +REPO_ROOT = Path(__file__).resolve().parent.parent +MAX_LINE_LENGTH = 120 +ALLOW_RE = re.compile(r"lint:allow\s+([A-Z0-9_,\s]+)") + + +# --------------------------------------------------------------------------- scanning + + +@dataclass(frozen=True) +class ScanConfig: + line_comment: str + block_comment: tuple[str, str] | None + quotes: str + triple_quotes: tuple[str, ...] = () + multiline_quotes: str = "" + + +SCAN: dict[str, ScanConfig] = { + "cpp": ScanConfig("//", ("/*", "*/"), "\"'"), + "c": ScanConfig("//", ("/*", "*/"), "\"'"), + "javascript": ScanConfig("//", ("/*", "*/"), "\"'`", multiline_quotes="`"), + "rust": ScanConfig("//", ("/*", "*/"), '"'), # ' is ambiguous (lifetimes) + "python": ScanConfig("#", None, "\"'", triple_quotes=('"""', "'''")), + "sql": ScanConfig("--", ("/*", "*/"), "'"), +} + + +def _blank(segment: str) -> str: + return re.sub(r"[^\n]", " ", segment) + + +def split_source(text: str, folder: str) -> tuple[str, str, str]: + """Split a source file into three same-shaped strings (newlines are preserved). + + Returns (code without strings, code with strings, comments). In each one, the + characters that belong elsewhere are replaced by spaces, so line and column + numbers always line up with the original file. + """ + cfg = SCAN[folder] + code_plain: list[str] = [] + code_strings: list[str] = [] + comments: list[str] = [] + + def put(kind: str, segment: str) -> None: + blank = _blank(segment) + if kind == "code": + code_plain.append(segment) + code_strings.append(segment) + comments.append(blank) + elif kind == "string": + code_plain.append(blank) + code_strings.append(segment) + comments.append(blank) + else: + code_plain.append(blank) + code_strings.append(blank) + comments.append(segment) + + i, n = 0, len(text) + while i < n: + if cfg.block_comment and text.startswith(cfg.block_comment[0], i): + end = text.find(cfg.block_comment[1], i + len(cfg.block_comment[0])) + end = n if end == -1 else end + len(cfg.block_comment[1]) + put("comment", text[i:end]) + i = end + continue + if text.startswith(cfg.line_comment, i): + end = text.find("\n", i) + end = n if end == -1 else end + put("comment", text[i:end]) + i = end + continue + triple = next((t for t in cfg.triple_quotes if text.startswith(t, i)), None) + if triple: + end = text.find(triple, i + 3) + end = n if end == -1 else end + 3 + put("string", text[i:end]) + i = end + continue + ch = text[i] + if ch in cfg.quotes: + j = i + 1 + while j < n: + if text[j] == "\\" and folder != "sql": + j += 2 + continue + if text[j] == ch: + j += 1 + break + if text[j] == "\n" and ch not in cfg.multiline_quotes: + break # unterminated: stop at the end of the line + j += 1 + put("string", text[i:j]) + i = j + continue + put("code", ch) + i += 1 + + return "".join(code_plain), "".join(code_strings), "".join(comments) + + +# --------------------------------------------------------------------------- rules + + +@dataclass(frozen=True) +class Rule: + id: str + level: str + langs: tuple[str, ...] + pattern: re.Pattern[str] + message: str + keep_strings: bool = False + + +def rule(id_: str, level: str, langs: str, pattern: str, message: str, *, flags: int = 0, keep_strings: bool = False) -> Rule: + return Rule(id_, level, tuple(langs.split(",")), re.compile(pattern, flags), message, keep_strings) + + +RULES: tuple[Rule, ...] = ( + # C++ + rule("CPP001", "error", "cpp", r"\busing\s+namespace\s+std\b", "do not use 'using namespace std'; write std:: explicitly"), + rule("CPP002", "error", "cpp", r"#\s*include\s*", " is not standard; include what you use"), + rule("CPP003", "warning", "cpp", r"\bstd::endl\b", "prefer '\\n' over std::endl (endl flushes the stream)"), + rule("CPP004", "error", "cpp", r"\bnew\s+[\w:<]|\bdelete\b\s*(?:\[\s*\]\s*)?[\w(*]", "avoid raw new/delete; use std::unique_ptr, std::make_unique or std::vector"), + rule("CPP005", "warning", "cpp", r"\(\s*(?:unsigned\s+)?(?:int|long|long\s+long|short|double|float|char|size_t)\s*\)\s*[\w(]", "use static_cast instead of a C-style cast"), + rule("CPP006", "warning", "cpp", r"\b(?:malloc|calloc|realloc|free)\s*\(", "avoid C memory management in C++; use containers and smart pointers"), + rule("CPP007", "warning", "cpp", r"^\s*#\s*define\s+\w+", "avoid macros unless clearly justified; prefer constexpr"), + # C + rule("C001", "error", "c", r"\bgets\s*\(", "gets() cannot be used safely; use fgets()"), + rule("C002", "error", "c", r"\b[fs]?scanf\s*\([^;]*%\*?[s\[]", "unbounded %s/%[ in scanf; give a width (e.g. %99s) or use fgets()", keep_strings=True), + rule("C003", "error", "c", r"\b(?:strcpy|strcat|sprintf|vsprintf)\s*\(", "unbounded copy/format function; use snprintf or a bounded alternative"), + rule("C004", "warning", "c", r"\b(?:atoi|atol|atoll|atof)\s*\(", "atoi-style functions cannot report errors; prefer strtol"), + rule("C005", "warning", "c", r"^\s*#\s*define\s+[A-Z_0-9]+\s+\(?\d+", "prefer enum or static const over #define for numeric limits"), + # Python + rule("PY001", "error", "python", r"\b(?:eval|exec)\s*\(", "never evaluate dynamic code"), + rule("PY002", "error", "python", r"^\s*from\s+\S+\s+import\s+\*", "wildcard imports hide where names come from"), + rule("PY003", "warning", "python", r"\.pop\(\s*0\s*\)", "list.pop(0) is O(n); use collections.deque"), + rule("PY004", "warning", "python", r"\bdef\s+\w+\([^)]*=\s*(?:\[\s*\]|\{\s*\}|set\(\s*\))", "mutable default argument"), + rule("PY005", "warning", "python", r"\bsetrecursionlimit\s*\(", "deep recursion can crash the interpreter; consider an iterative version"), + # JavaScript + rule("JS001", "error", "javascript", r"\bvar\s+\w", "use const (or let when reassigning), never var"), + rule("JS002", "error", "javascript", r"(?])==(?!=)|!=(?!=)", "use strict equality (=== / !==)"), + rule("JS003", "error", "javascript", r"\beval\s*\(|\bnew\s+Function\s*\(", "never evaluate dynamic code"), + rule("JS004", "warning", "javascript", r"\.sort\(\s*\)", "Array.sort() without a comparator sorts as text; pass (a, b) => a - b"), + rule("JS005", "warning", "javascript", r"\.shift\(\s*\)", "Array.shift() is O(n); use a head index for queues"), + # Rust + rule("RS001", "warning", "rust", r"\.unwrap\(\s*\)", "prefer expect(\"reason\"), ? or an explicit match over unwrap()"), + # SQL + rule("SQL001", "error", "sql", r"\bSELECT\s+(?:DISTINCT\s+)?\*", "list the columns explicitly instead of SELECT *", flags=re.IGNORECASE), + rule("SQL002", "error", "sql", r"\bFROM\s+\w+(?:\s+(?:AS\s+)?\w+)?\s*,\s*\w+", "use an explicit JOIN ... ON instead of a comma join", flags=re.IGNORECASE), + rule("SQL003", "warning", "sql", r"\b(?:select|from|where|join|group\s+by|order\s+by|having|union)\b", "write SQL keywords in UPPERCASE"), +) + +# C++: std symbol -> header that must be included explicitly (portability). +CPP_HEADERS: tuple[tuple[str, str], ...] = ( + (r"\bstd::(?:sort|stable_sort|reverse|max|min|min_element|max_element|lower_bound|upper_bound|binary_search|unique|fill|next_permutation|rotate|count_if|any_of|all_of|none_of)\b", "algorithm"), + (r"\bstd::(?:accumulate|iota|gcd|lcm|partial_sum)\b", "numeric"), + (r"\bstd::(?:cin|cout|cerr|ios|ios_base)\b", "iostream"), + (r"\bstd::(?:setw|setprecision|setfill)\b", "iomanip"), + (r"\bstd::vector\b", "vector"), + (r"\bstd::(?:string|getline|to_string|stoi|stoll)\b", "string"), + (r"\bstd::string_view\b", "string_view"), + (r"\bstd::(?:unique_ptr|make_unique|shared_ptr|make_shared)\b", "memory"), + (r"\bstd::(?:map|multimap)\b", "map"), + (r"\bstd::(?:set|multiset)\b", "set"), + (r"\bstd::unordered_map\b", "unordered_map"), + (r"\bstd::unordered_set\b", "unordered_set"), + (r"\bstd::(?:queue|priority_queue)\b", "queue"), + (r"\bstd::stack\b", "stack"), + (r"\bstd::deque\b", "deque"), + (r"\bstd::array\b", "array"), + (r"\bstd::optional\b", "optional"), + (r"\bstd::function\b", "functional"), + (r"\bstd::(?:sqrt|pow|ceil|floor|fabs|log|log2|exp)\b", "cmath"), + (r"\bstd::numeric_limits\b", "limits"), + (r"\bstd::(?:stringstream|istringstream|ostringstream)\b", "sstream"), + (r"\bstd::memset\b", "cstring"), + (r"\bstd::u?int(?:8|16|32|64)_t\b", "cstdint"), +) + + +# Rules implemented as code (not in RULES) that are still documented by id. +EXTRA_RULE_IDS: tuple[str, ...] = ("CPP008", "JS006", "RS002", "GEN001", "GEN002") + + +def _allowed(comment_lines: list[str], index: int) -> set[str]: + """Rule ids suppressed for line `index` (same line or the line above).""" + allowed: set[str] = set() + for i in (index - 1, index): + if 0 <= i < len(comment_lines): + match = ALLOW_RE.search(comment_lines[i]) + if match: + allowed.update(part.strip() for part in re.split(r"[,\s]+", match.group(1)) if part.strip()) + return allowed + + +def lint_text(text: str, folder: str, rel: str) -> list[Issue]: + """Lint one file's text. `folder` is the language folder name (cpp, c, ...).""" + code_plain, code_strings, comments = split_source(text, folder) + plain_lines = code_plain.splitlines() + string_lines = code_strings.splitlines() + comment_lines = comments.splitlines() + raw_lines = text.splitlines() + issues: list[Issue] = [] + + def report(level: str, line_no: int, rule_id: str, message: str) -> None: + if rule_id not in _allowed(comment_lines, line_no - 1): + issues.append(Issue(level, f"{rel}:{line_no}", f"[{rule_id}] {message}")) + + for r in RULES: + if folder not in r.langs: + continue + lines = string_lines if r.keep_strings else plain_lines + for number, line in enumerate(lines, start=1): + if r.pattern.search(line): + report(r.level, number, r.id, r.message) + + # File-level and multi-line rules. + if folder == "javascript" and not re.search(r"""["']use strict["']""", code_strings): + report("warning", 1, "JS006", "add \"use strict\" at the top of the file") + + if folder == "rust": + for number, line in enumerate(plain_lines, start=1): + if re.search(r"\bunsafe\b", line): + nearby = " ".join(comment_lines[max(0, number - 2):number]) + if "SAFETY:" not in nearby: + report("error", number, "RS002", "unsafe needs a '// SAFETY:' comment explaining why it is sound") + + if folder == "cpp": + included = set(re.findall(r"#\s*include\s*<([^>]+)>", code_plain)) + for pattern, header in CPP_HEADERS: + for number, line in enumerate(plain_lines, start=1): + if re.search(pattern, line) and header not in included: + report("error", number, "CPP008", f"uses std symbols from <{header}> without including it") + break + + for number, line in enumerate(comment_lines, start=1): + if re.search(r"\b(?:TODO|FIXME|XXX)\b", line): + report("warning", number, "GEN001", "unfinished marker left in the code") + for number, line in enumerate(raw_lines, start=1): + if len(line) > MAX_LINE_LENGTH: + report("warning", number, "GEN002", f"line longer than {MAX_LINE_LENGTH} characters") + + return issues + + +# --------------------------------------------------------------------------- cli + + +def lint_file(path: Path, root: Path) -> list[Issue]: + folder = path.parent.name + if folder not in {f for f, _ in LANGUAGES.values()}: + return [Issue("error", path.as_posix(), "not inside a known solutions// folder")] + try: + rel = path.relative_to(root).as_posix() + except ValueError: + rel = path.as_posix() + return lint_text(path.read_text(encoding="utf-8"), folder, rel) + + +def collect_files(root: Path, paths: list[Path]) -> list[Path]: + if paths: + return [p.resolve() for p in paths] + files: list[Path] = [] + for problem_dir in find_problem_dirs(root): + files += list(implementations(problem_dir).values()) + return files + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Lint solution files against the repository standards.") + parser.add_argument("paths", nargs="*", type=Path, help="solution files (default: all)") + parser.add_argument("--root", type=Path, default=REPO_ROOT, help="repository root") + parser.add_argument("--strict", action="store_true", help="treat warnings as errors") + parser.add_argument("--github", action="store_true", help="emit GitHub Actions annotations") + args = parser.parse_args(argv) + + root = args.root.resolve() + files = collect_files(root, args.paths) + issues: list[Issue] = [] + for path in files: + issues += lint_file(path, root) + + print_issues(issues, args.github) + errors, warnings = count_issues(issues) + print(f"\nLinted {len(files)} file(s): {errors} error(s), {warnings} warning(s).") + return 1 if errors or (args.strict and warnings) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/repo_lib.py b/scripts/repo_lib.py new file mode 100644 index 0000000..07d5f79 --- /dev/null +++ b/scripts/repo_lib.py @@ -0,0 +1,218 @@ +"""Shared helpers for the repository maintenance scripts. + +Standard library only, so the scripts run anywhere (locally and in CI) without +installing anything. + +The single source of truth for the repository vocabulary (languages, +categories, statuses...) lives here. If a convention changes, change it in +this file and every script follows. +""" + +from __future__ import annotations + +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + +# Display name used in README metadata -> (folder name, file extension). +# Folder layout: problems//-/solutions//main. +LANGUAGES: dict[str, tuple[str, str]] = { + "C++": ("cpp", "cpp"), + "C": ("c", "c"), + "Python": ("python", "py"), + "JavaScript": ("javascript", "js"), + "Rust": ("rust", "rs"), + "SQL": ("sql", "sql"), +} + +CATEGORIES: tuple[str, ...] = ( + "Beginner", + "Ad-Hoc", + "Strings", + "Data Structures and Libraries", + "Mathematics", + "Paradigms", + "Graphs", + "Computational Geometry", + "SQL", +) + +DOC_LEVELS: tuple[str, ...] = ("L1", "L2", "L3") +PRIORITIES: tuple[str, ...] = ("Low", "Medium", "High") + +# "Solved" must only be used for code that was really accepted by the judge. +STATUSES: tuple[str, ...] = ("Solved", "In Progress", "Improving", "To Review") + +REQUIRED_FIELDS: tuple[str, ...] = ( + "Problem ID", + "Platform", + "Category", + "Difficulty Level", + "Topics", + "Primary Language", + "Documentation Level", + "Status", + "Review Priority", +) + +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +PROBLEM_DIR_RE = re.compile(r"^(\d+)-(.+)$") +RANGE_DIR_RE = re.compile(r"^(\d{4})-(\d{4})$") +_FIELD_RE = re.compile(r"^-\s+([^:]+):\s*(.*)$") +_CHECKBOX_RE = re.compile(r"^-\s+\[([ xX])\]\s+(.+)$") + + +@dataclass(frozen=True) +class Issue: + """One validation finding. `level` is either "error" or "warning".""" + + level: str + path: str + message: str + + +def find_language(value: str) -> str | None: + """Map a metadata value ("C++", "cpp", "python"...) to its display name.""" + wanted = value.strip().lower() + for display, (folder, _ext) in LANGUAGES.items(): + if wanted in (display.lower(), folder): + return display + return None + + +def read_section(text: str, heading: str) -> list[str]: + """Return the lines under `## ` up to the next `## ` heading.""" + lines = text.splitlines() + collected: list[str] = [] + inside = False + for line in lines: + if line.startswith("## "): + if inside: + break + inside = line[3:].strip().lower() == heading.lower() + continue + if inside: + collected.append(line) + return collected + + +def parse_metadata(readme_text: str) -> dict[str, str]: + """Parse the `## Metadata` block (`- Key: value` lines) into a dict.""" + fields: dict[str, str] = {} + for line in read_section(readme_text, "Metadata"): + match = _FIELD_RE.match(line.strip()) + if match: + fields[match.group(1).strip()] = match.group(2).strip() + return fields + + +def parse_status_checkboxes(readme_text: str) -> tuple[dict[str, bool], list[str]]: + """Parse the `## Status` checklist. + + Returns (label -> checked, malformed lines). A malformed line starts like a + checklist item (`- [`) but does not follow the `- [ ] text` / `- [x] text` + syntax. + """ + boxes: dict[str, bool] = {} + malformed: list[str] = [] + for line in read_section(readme_text, "Status"): + stripped = line.strip() + if not stripped.startswith("- ["): + continue + match = _CHECKBOX_RE.match(stripped) + if match: + boxes[match.group(2).strip()] = match.group(1).lower() == "x" + else: + malformed.append(stripped) + return boxes, malformed + + +def parse_other_implementations(value: str) -> list[str]: + """`Python, Rust` -> ["Python", "Rust"]; `None`, `-` or empty -> [].""" + cleaned = value.strip() + if cleaned.lower() in ("", "none", "-", "n/a"): + return [] + return [part.strip() for part in cleaned.split(",") if part.strip()] + + +def find_problem_dirs(root: Path) -> list[Path]: + """List every `problems///` directory, sorted.""" + problems_root = root / "problems" + if not problems_root.is_dir(): + return [] + found: list[Path] = [] + for range_dir in sorted(p for p in problems_root.iterdir() if p.is_dir()): + found.extend(sorted(p for p in range_dir.iterdir() if p.is_dir())) + return found + + +def implementations(problem_dir: Path) -> dict[str, Path]: + """Map language folder name -> main file, for every implementation that exists.""" + found: dict[str, Path] = {} + solutions_dir = problem_dir / "solutions" + if not solutions_dir.is_dir(): + return found + for display, (folder, ext) in LANGUAGES.items(): + main_file = solutions_dir / folder / f"main.{ext}" + if main_file.is_file(): + found[folder] = main_file + return found + + +def test_cases(problem_dir: Path) -> list[tuple[str, Path, Path]]: + """Return (name, input file, expected output file) for every tests/*.in.""" + tests_dir = problem_dir / "tests" + if not tests_dir.is_dir(): + return [] + return [(p.stem, p, p.with_suffix(".out")) for p in sorted(tests_dir.glob("*.in"))] + + +def print_issues(issues: list[Issue], github: bool = False) -> None: + """Print findings as plain text or as GitHub Actions annotations.""" + for issue in issues: + if github: + print(f"::{issue.level} file={issue.path}::{issue.message}") + else: + print(f"{issue.level.upper():7} {issue.path}: {issue.message}") + + +def count_issues(issues: list[Issue]) -> tuple[int, int]: + """Return (errors, warnings).""" + errors = sum(1 for issue in issues if issue.level == "error") + return errors, len(issues) - errors + + +def _git_lines(root: Path, *args: str) -> list[str] | None: + try: + result = subprocess.run( + ["git", *args], cwd=root, capture_output=True, text=True, check=True, timeout=30 + ) + except (OSError, subprocess.SubprocessError): + return None + return [line for line in result.stdout.splitlines() if line] + + +def changed_files(root: Path, staged: bool = False, since: str | None = None) -> list[str] | None: + """Files touched by the staged changes, or by commits since `since` (e.g. origin/main). + + Returns None when git cannot answer (not a repo, unknown ref...), so callers can + fall back to checking everything. + """ + if staged: + return _git_lines(root, "diff", "--cached", "--name-only") + if since: + return _git_lines(root, "diff", "--name-only", f"{since}...HEAD") + return None + + +def problem_dirs_for(root: Path, files: list[str]) -> list[Path]: + """Map changed file paths to the problem folders (that still exist) they belong to.""" + found: set[Path] = set() + for name in files: + parts = Path(name).parts + if len(parts) >= 3 and parts[0] == "problems": + candidate = root / parts[0] / parts[1] / parts[2] + if candidate.is_dir(): + found.add(candidate) + return sorted(found) diff --git a/scripts/run_tests.py b/scripts/run_tests.py new file mode 100644 index 0000000..a0c9ef4 --- /dev/null +++ b/scripts/run_tests.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Build and run every solution against its test cases. + +Each problem keeps its cases in `tests/.in` and `tests/.out`. The same +cases are used for every implementation of that problem. + +Usage: + python scripts/run_tests.py # all problems + python scripts/run_tests.py problems/3400-3499/3484-divided-class + python scripts/run_tests.py --lang cpp,python # only some languages + python scripts/run_tests.py --sanitize --werror # strict local build (C, C++, Rust) + +Nothing is ever written into the repository: binaries live in a temporary folder. + +Exit code: 0 = every executed case passed, 1 = a failure (or, with +--require-toolchains, a missing compiler/interpreter). +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path + +from repo_lib import LANGUAGES, find_problem_dirs, implementations, test_cases + +REPO_ROOT = Path(__file__).resolve().parent.parent +COMPILE_TIMEOUT_SECONDS = 120 + +SANITIZE_FLAGS = ["-fsanitize=address,undefined", "-fno-sanitize-recover=undefined"] + + +@dataclass(frozen=True) +class Options: + timeout: float = 5.0 + sanitize: bool = False + werror: bool = False + lenient: bool = False + + +@dataclass +class Prepared: + """How to run one implementation, or why it cannot be run.""" + + command: list[str] | None = None + skip_reason: str | None = None + missing_tool: bool = False + error: str | None = None + notes: str = "" + + +@dataclass +class Result: + problem: str + folder: str + case: str + status: str # PASS | FAIL | SKIP + detail: str = "" + millis: int = 0 + missing_tool: bool = False + + +def _exe(workdir: Path) -> Path: + return workdir / ("main.exe" if os.name == "nt" else "main") + + +def _compile(command: list[str], exe: Path) -> Prepared: + try: + done = subprocess.run(command, capture_output=True, text=True, timeout=COMPILE_TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + return Prepared(error="compilation timed out") + if done.returncode != 0: + return Prepared(error="compilation failed:\n" + done.stderr.strip()) + return Prepared(command=[str(exe)], notes=done.stderr.strip()) + + +def prepare(folder: str, source: Path, workdir: Path, options: Options) -> Prepared: + """Compile (if needed) and return the command that runs the solution.""" + exe = _exe(workdir) + + if folder in ("cpp", "c"): + compiler = os.environ.get("CXX", "g++") if folder == "cpp" else os.environ.get("CC", "gcc") + if shutil.which(compiler) is None: + return Prepared(skip_reason=f"{compiler} not found", missing_tool=True) + standard = "-std=c++20" if folder == "cpp" else "-std=c17" + flags = [standard, "-O2", "-Wall", "-Wextra", "-Wpedantic"] + if folder == "cpp": + flags.append("-Wshadow") + if options.sanitize: + flags += SANITIZE_FLAGS + if options.werror: + flags.append("-Werror") + return _compile([compiler, *flags, str(source), "-o", str(exe)], exe) + + if folder == "rust": + if shutil.which("rustc") is None: + return Prepared(skip_reason="rustc not found", missing_tool=True) + flags = ["--edition", "2021", "-O"] + if options.werror: + flags += ["-D", "warnings"] + return _compile(["rustc", *flags, str(source), "-o", str(exe)], exe) + + if folder == "python": + command = [sys.executable] + if options.werror: + command += ["-W", "error"] + return Prepared(command=[*command, str(source)]) + + if folder == "javascript": + node = shutil.which("node") or shutil.which("nodejs") + if node is None: + return Prepared(skip_reason="node not found", missing_tool=True) + return Prepared(command=[node, str(source)]) + + if folder == "sql": + return Prepared(skip_reason="no SQL runner yet") + + return Prepared(skip_reason=f"unsupported language folder '{folder}'") + + +def normalize(text: str, lenient: bool) -> list[str]: + """Line list used for comparison. + + Always: CRLF -> LF and trailing blank lines at the very end are ignored. + Lenient: trailing spaces on each line are ignored too. The default is strict, + because judges such as Beecrowd report whitespace differences as errors. + """ + lines = text.replace("\r\n", "\n").rstrip("\n").split("\n") + if lenient: + lines = [line.rstrip() for line in lines] + return lines + + +def first_difference(expected: list[str], got: list[str]) -> str: + def clip(value: str) -> str: + return repr(value if len(value) <= 80 else value[:77] + "...") + + for index in range(max(len(expected), len(got))): + want = expected[index] if index < len(expected) else "" + have = got[index] if index < len(got) else "" + if want != have: + return f"line {index + 1}: expected {clip(want)}, got {clip(have)}" + return "outputs differ" + + +def run_case(command: list[str], input_file: Path, expected_file: Path, options: Options) -> tuple[str, str, int]: + """Run one case. Returns (status, detail, milliseconds).""" + if not expected_file.is_file(): + return "FAIL", f"missing expected output {expected_file.name}", 0 + + started = time.perf_counter() + try: + with input_file.open("rb") as stdin: + done = subprocess.run(command, stdin=stdin, capture_output=True, timeout=options.timeout) + except subprocess.TimeoutExpired: + return "FAIL", f"time limit exceeded ({options.timeout:g}s)", int(options.timeout * 1000) + millis = int((time.perf_counter() - started) * 1000) + + if done.returncode != 0: + tail = done.stderr.decode("utf-8", "replace").strip().splitlines()[-3:] + return "FAIL", f"runtime error (exit code {done.returncode}) " + " | ".join(tail), millis + + got = normalize(done.stdout.decode("utf-8", "replace"), options.lenient) + want = normalize(expected_file.read_text(encoding="utf-8"), options.lenient) + if got != want: + return "FAIL", first_difference(want, got), millis + return "PASS", "", millis + + +def run_problem(problem_dir: Path, languages: set[str] | None, options: Options) -> list[Result]: + problem = problem_dir.name + cases = test_cases(problem_dir) + results: list[Result] = [] + + # ignore_cleanup_errors: on Windows, a just-run .exe can still be briefly + # locked (commonly by antivirus scanning it) when cleanup tries to delete + # it; best-effort cleanup avoids crashing the whole run over a temp file. + with tempfile.TemporaryDirectory(prefix="beecrowd-tests-", ignore_cleanup_errors=True) as tmp: + for folder, source in implementations(problem_dir).items(): + if languages is not None and folder not in languages: + continue + if not cases: + results.append(Result(problem, folder, "-", "SKIP", "no test cases")) + continue + + workdir = Path(tmp) / folder + workdir.mkdir() + prepared = prepare(folder, source, workdir, options) + + if prepared.error: + results.append(Result(problem, folder, "build", "FAIL", prepared.error)) + continue + if prepared.skip_reason or prepared.command is None: + results.append(Result(problem, folder, "-", "SKIP", prepared.skip_reason or "", missing_tool=prepared.missing_tool)) + continue + if prepared.notes: + results.append(Result(problem, folder, "build", "PASS", "compiler output:\n" + prepared.notes)) + + for name, input_file, expected_file in cases: + status, detail, millis = run_case(prepared.command, input_file, expected_file, options) + results.append(Result(problem, folder, name, status, detail, millis)) + return results + + +def print_results(results: list[Result]) -> None: + for r in results: + timing = f" ({r.millis} ms)" if r.status == "PASS" and r.millis else "" + print(f"{r.status:5} {r.problem} {r.folder} {r.case}{timing}") + if r.detail and r.status != "PASS": + for line in r.detail.splitlines(): + print(f" {line}") + elif r.detail and r.case == "build": + print(f" {r.detail}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build and run solutions against tests/*.in|out.") + parser.add_argument("paths", nargs="*", type=Path, help="problem folders (default: all)") + parser.add_argument("--root", type=Path, default=REPO_ROOT, help="repository root") + parser.add_argument("--lang", help="comma separated language folders (e.g. cpp,python)") + parser.add_argument("--timeout", type=float, default=5.0, help="seconds per case (default 5)") + parser.add_argument("--sanitize", action="store_true", help="ASan+UBSan for C and C++") + parser.add_argument("--werror", action="store_true", help="treat compiler/interpreter warnings as errors") + parser.add_argument("--lenient", action="store_true", help="ignore trailing spaces on each line") + parser.add_argument("--require-toolchains", action="store_true", help="fail when a compiler/interpreter is missing") + args = parser.parse_args(argv) + + root = args.root.resolve() + problem_dirs = [p.resolve() for p in args.paths] if args.paths else find_problem_dirs(root) + + languages: set[str] | None = None + if args.lang: + languages = {part.strip().lower() for part in args.lang.split(",") if part.strip()} + known = {folder for folder, _ext in LANGUAGES.values()} + unknown = languages - known + if unknown: + parser.error(f"unknown language(s): {', '.join(sorted(unknown))}") + + options = Options(args.timeout, args.sanitize, args.werror, args.lenient) + results: list[Result] = [] + for problem_dir in problem_dirs: + results += run_problem(problem_dir, languages, options) + + print_results(results) + cases = [r for r in results if r.case not in ("build", "-")] + passed = sum(1 for r in cases if r.status == "PASS") + failed = sum(1 for r in results if r.status == "FAIL") + skipped = sum(1 for r in results if r.status == "SKIP") + print(f"\n{passed} passed, {failed} failed, {skipped} skipped.") + + missing = any(r.missing_tool for r in results) + return 1 if failed or (args.require_toolchains and missing) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/fixtures.py b/scripts/tests/fixtures.py new file mode 100644 index 0000000..228e9d3 --- /dev/null +++ b/scripts/tests/fixtures.py @@ -0,0 +1,80 @@ +"""Helpers shared by the script tests: build tiny throw-away problem folders.""" + +from __future__ import annotations + +import contextlib +import io +import sys +from pathlib import Path +from typing import Callable + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from repo_lib import LANGUAGES # noqa: E402 + +README = """# {id} — Fixture + +## Link +https://example.invalid/{id} + +## Metadata + +- Problem ID: {id} +- Platform: Beecrowd +- Category: {category} +- Difficulty Level: 1 +- Topics: fixture +- Primary Language: {language} +- Other Implementations: None +- Documentation Level: L1 +- Status: {status} +- Review Priority: Low + +## Status + +- [{box}] Solved +- [ ] Revisit later +""" + + +def make_problem( + root: Path, + folder: str = "python", + source: str = "print(input())\n", + cases: dict[str, tuple[str, str]] | None = None, + problem_id: int = 1001, + slug: str = "fixture", + status: str = "Solved", + category: str = "Beginner", +) -> Path: + """Create problems//-/ with one implementation and its cases.""" + display = next(name for name, (f, _e) in LANGUAGES.items() if f == folder) + ext = LANGUAGES[display][1] + start = problem_id // 100 * 100 + problem = root / "problems" / f"{start}-{start + 99}" / f"{problem_id}-{slug}" + (problem / "solutions" / folder).mkdir(parents=True) + (problem / "solutions" / folder / f"main.{ext}").write_text(source, encoding="utf-8") + (problem / "README.md").write_text( + README.format( + id=problem_id, + category=category, + language=display, + status=status, + box="x" if status == "Solved" else " ", + ), + encoding="utf-8", + ) + for name, (given, expected) in (cases if cases is not None else {"sample": ("hello\n", "hello\n")}).items(): + (problem / "tests").mkdir(exist_ok=True) + # newline="" disables universal-newline translation on write: `given`/`expected` + # may already contain literal "\r\n" to simulate a CRLF file, and on Windows the + # default write_text() would double it to "\r\r\n". + (problem / "tests" / f"{name}.in").write_text(given, encoding="utf-8", newline="") + (problem / "tests" / f"{name}.out").write_text(expected, encoding="utf-8", newline="") + return problem + + +def quiet(function: Callable[[], int]) -> int: + """Call a script's main() without letting its report clutter the test output.""" + with contextlib.redirect_stdout(io.StringIO()): + return function() diff --git a/scripts/tests/test_check_all.py b/scripts/tests/test_check_all.py new file mode 100644 index 0000000..e654ec0 --- /dev/null +++ b/scripts/tests/test_check_all.py @@ -0,0 +1,108 @@ +"""Tests for scripts/check_all.py and the git helpers in repo_lib.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fixtures import make_problem, quiet # noqa: E402 + +import check_all # noqa: E402 +import repo_lib # noqa: E402 + + +class ProblemDirsForTest(unittest.TestCase): + def test_maps_files_to_existing_problem_folders(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + problem = make_problem(root) + files = [ + "problems/1000-1099/1001-fixture/solutions/python/main.py", + "problems/1000-1099/1001-fixture/README.md", + "problems/1000-1099/1002-deleted/README.md", + "docs/workflow.md", + "problems/README.md", + ] + self.assertEqual(repo_lib.problem_dirs_for(root, files), [problem]) + + +@unittest.skipUnless(shutil.which("git"), "git not installed") +class ScopeTest(unittest.TestCase): + def git(self, *args: str) -> None: + subprocess.run(["git", *args], cwd=self.root, check=True, capture_output=True) + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.git("init", "-q", "-b", "main") + self.git("config", "user.email", "test@example.invalid") + self.git("config", "user.name", "Test") + make_problem(self.root, problem_id=1001, slug="first") + make_problem(self.root, problem_id=1002, slug="second") + self.git("add", "-A") + self.git("commit", "-q", "-m", "base") + + def tearDown(self) -> None: + self._tmp.cleanup() + + def test_since_selects_only_changed_problems(self) -> None: + main_file = self.root / "problems/1000-1099/1002-second/solutions/python/main.py" + main_file.write_text("print(input()) # changed\n", encoding="utf-8") + self.git("commit", "-q", "-am", "change second") + dirs, infra, label = check_all.select_scope(self.root, staged=False, since="HEAD~1") + self.assertEqual([d.name for d in dirs or []], ["1002-second"]) + self.assertFalse(infra) + self.assertIn("1 problem folder", label) + + def test_staged_scope_and_infra_detection(self) -> None: + (self.root / "scripts").mkdir() + (self.root / "scripts" / "tool.py").write_text("x = 1\n", encoding="utf-8") + self.git("add", "scripts/tool.py") + dirs, infra, _ = check_all.select_scope(self.root, staged=True, since=None) + self.assertEqual(dirs, []) + self.assertTrue(infra) + + def test_unknown_ref_falls_back_to_everything(self) -> None: + dirs, infra, label = check_all.select_scope(self.root, staged=False, since="no-such-ref") + self.assertIsNone(dirs) + self.assertTrue(infra) + self.assertIn("whole repository", label) + + def test_end_to_end_pass_and_fail(self) -> None: + self.assertEqual(quiet(lambda: check_all.main(["--root", str(self.root), "--skip-unit-tests"])), 0) + (self.root / "problems/1000-1099/1001-first/solutions/python/main.py").write_text("print('wrong')\n", encoding="utf-8") + self.assertEqual(quiet(lambda: check_all.main(["--root", str(self.root), "--skip-unit-tests"])), 1) + + def test_nothing_changed_means_nothing_to_check(self) -> None: + self.assertEqual(quiet(lambda: check_all.main(["--root", str(self.root), "--since", "HEAD", "--skip-unit-tests"])), 0) + + +@unittest.skipUnless(shutil.which("git"), "git not installed") +class GitignoreTest(unittest.TestCase): + def test_expected_outputs_are_not_ignored(self) -> None: + """`*.out` is ignored for compiler output; tests/*.out must stay trackable.""" + repo_root = Path(__file__).resolve().parent.parent.parent + gitignore = repo_root / ".gitignore" + if not gitignore.is_file(): + self.skipTest(".gitignore not found") + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=root, check=True, capture_output=True) + shutil.copy(gitignore, root / ".gitignore") + case = root / "problems" / "1000-1099" / "1001-x" / "tests" + case.mkdir(parents=True) + (case / "sample.out").write_text("1\n", encoding="utf-8") + (root / "a.out").write_text("binary", encoding="utf-8") + kept = subprocess.run(["git", "check-ignore", "-q", "problems/1000-1099/1001-x/tests/sample.out"], cwd=root) + ignored = subprocess.run(["git", "check-ignore", "-q", "a.out"], cwd=root) + self.assertEqual(kept.returncode, 1, "tests/*.out must not be ignored") + self.assertEqual(ignored.returncode, 0, "a.out should stay ignored") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_docs.py b/scripts/tests/test_docs.py new file mode 100644 index 0000000..056edef --- /dev/null +++ b/scripts/tests/test_docs.py @@ -0,0 +1,40 @@ +"""Documentation must stay in sync with the tooling.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import lint_solutions as lint # noqa: E402 + +DOCS = Path(__file__).resolve().parent.parent.parent / "docs" + + +@unittest.skipUnless(DOCS.is_dir(), "docs/ not found") +class DocsTest(unittest.TestCase): + def test_every_lint_rule_is_documented(self) -> None: + text = "\n".join(p.read_text(encoding="utf-8") for p in DOCS.rglob("*.md")) + rule_ids = [r.id for r in lint.RULES] + list(lint.EXTRA_RULE_IDS) + missing = [rule_id for rule_id in rule_ids if f"`{rule_id}`" not in text] + self.assertEqual(missing, [], f"rules not documented in docs/: {missing}") + + def test_every_language_has_a_style_guide(self) -> None: + guides = {p.stem for p in (DOCS / "style-guides").glob("*.md")} + self.assertEqual(guides, {"cpp", "c", "python", "javascript", "rust", "sql"}) + + def test_relative_links_resolve(self) -> None: + import re + + broken: list[str] = [] + for page in DOCS.rglob("*.md"): + for target in re.findall(r"\]\(([^)#]+\.md)\)", page.read_text(encoding="utf-8")): + if not target.startswith("http") and not (page.parent / target).resolve().is_file(): + broken.append(f"{page.relative_to(DOCS)} -> {target}") + self.assertEqual(broken, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_lint_solutions.py b/scripts/tests/test_lint_solutions.py new file mode 100644 index 0000000..231119e --- /dev/null +++ b/scripts/tests/test_lint_solutions.py @@ -0,0 +1,132 @@ +"""Tests for scripts/lint_solutions.py.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import lint_solutions as lint # noqa: E402 + + +def ids(source: str, folder: str, level: str | None = None) -> list[str]: + """Rule ids reported for `source` (e.g. ['CPP001']).""" + found = lint.lint_text(source, folder, "x") + return [i.message.split("]")[0].lstrip("[") for i in found if level is None or i.level == level] + + +class SplitSourceTest(unittest.TestCase): + def test_strings_and_comments_are_separated(self) -> None: + plain, with_strings, comments = lint.split_source('int a; // note\nchar* s = "gets(";\n', "c") + self.assertNotIn("gets", plain) + self.assertIn("gets", with_strings) + self.assertIn("note", comments) + self.assertEqual(len(plain), len('int a; // note\nchar* s = "gets(";\n')) + + def test_python_docstring_is_a_string(self) -> None: + plain, _, _ = lint.split_source('"""eval(x)"""\nvalue = 1\n', "python") + self.assertNotIn("eval", plain) + + def test_rust_lifetime_does_not_swallow_code(self) -> None: + plain, _, _ = lint.split_source("fn f<'a>(x: &'a str) { x.unwrap(); }\n", "rust") + self.assertIn("unwrap", plain) + + +class CppRulesTest(unittest.TestCase): + GOOD = "#include \nint main() {\n std::cout << 1 << '\\n';\n}\n" + + def test_clean_file(self) -> None: + self.assertEqual(ids(self.GOOD, "cpp"), []) + + def test_banned_constructs(self) -> None: + source = "#include \nusing namespace std;\nint main() { int* p = new int[2]; delete[] p; }\n" + self.assertEqual(sorted(set(ids(source, "cpp", "error"))), ["CPP001", "CPP002", "CPP004"]) + + def test_missing_include_is_caught(self) -> None: + source = "#include \nint main() { std::vector v; return std::max(1, 2); }\n" + self.assertEqual(ids(source, "cpp", "error"), ["CPP008"]) + + def test_include_present_is_fine(self) -> None: + source = "#include \nint main() { return std::max(1, 2); }\n" + self.assertEqual(ids(source, "cpp"), []) + + def test_mentions_in_comments_and_strings_are_ignored(self) -> None: + source = '#include \n// using namespace std; new int\nint main() { std::cout << "using namespace std"; }\n' + self.assertEqual(ids(source, "cpp"), []) + + def test_deleted_function_is_not_raw_delete(self) -> None: + self.assertEqual(ids("struct A { A(const A&) = delete; };\n", "cpp"), []) + + def test_allow_comment_suppresses_on_same_or_previous_line(self) -> None: + same = "int main() { int* p = new int; } // lint:allow CPP004 deliberate\n" + above = "// lint:allow CPP004 deliberate exercise\nint main() { int* p = new int; }\n" + self.assertEqual(ids(same, "cpp"), []) + self.assertEqual(ids(above, "cpp"), []) + + def test_endl_cast_and_todo_are_warnings(self) -> None: + source = "#include \nint main() { std::cout << (int)2.5 << std::endl; } // TODO\n" + self.assertEqual(sorted(ids(source, "cpp", "warning")), ["CPP003", "CPP005", "GEN001"]) + + +class OtherLanguagesTest(unittest.TestCase): + def test_c_unbounded_input(self) -> None: + self.assertEqual(ids('int main(void) { char b[8]; scanf("%s", b); gets(b); }\n', "c", "error"), ["C001", "C002"]) + self.assertEqual(ids('int main(void) { char b[8]; scanf("%7s", b); }\n', "c", "error"), []) + + def test_c_unsafe_copies(self) -> None: + self.assertEqual(ids("void f(char* a, char* b) { strcpy(a, b); }\n", "c", "error"), ["C003"]) + + def test_python(self) -> None: + source = "from os import *\nx = eval('1')\nq.pop(0)\ndef f(a=[]):\n pass\n" + self.assertEqual(sorted(ids(source, "python")), ["PY001", "PY002", "PY003", "PY004"]) + + def test_javascript(self) -> None: + source = '"use strict";\nvar a = 1;\nif (a == 2) {}\nxs.sort();\nxs.shift();\n' + self.assertEqual(sorted(ids(source, "javascript")), ["JS001", "JS002", "JS004", "JS005"]) + + def test_javascript_needs_use_strict(self) -> None: + self.assertEqual(ids("const a = 1;\n", "javascript"), ["JS006"]) + + def test_javascript_strict_equality_is_fine(self) -> None: + self.assertEqual(ids('"use strict";\nif (a === 1 && b !== 2 && c <= 3 && d >= 4) {}\n', "javascript"), []) + + def test_rust_unsafe_needs_safety_comment(self) -> None: + self.assertEqual(ids("fn f() { unsafe { g() } }\n", "rust", "error"), ["RS002"]) + documented = "fn f() {\n // SAFETY: g has no preconditions\n unsafe { g() }\n}\n" + self.assertEqual(ids(documented, "rust", "error"), []) + + def test_rust_unwrap_is_a_warning(self) -> None: + self.assertEqual(ids("fn main() { let x: i32 = \"1\".parse().unwrap(); }\n", "rust"), ["RS001"]) + + def test_sql(self) -> None: + source = "select * from a, b where a.id = b.id;\n" + self.assertEqual(sorted(set(ids(source, "sql"))), ["SQL001", "SQL002", "SQL003"]) + good = "SELECT a.id, b.name\nFROM a\nJOIN b ON b.a_id = a.id;\n" + self.assertEqual(ids(good, "sql"), []) + + def test_sql_count_star_is_fine(self) -> None: + self.assertEqual(ids("SELECT COUNT(*) FROM a;\n", "sql"), []) + + def test_long_lines_warn(self) -> None: + self.assertEqual(ids("x = '" + "a" * 130 + "'\n", "python"), ["GEN002"]) + + +class CliTest(unittest.TestCase): + def test_exit_codes(self) -> None: + import tempfile + + from fixtures import make_problem, quiet + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + problem = make_problem(root, folder="python", source="value = eval('1')\nprint(value)\n") + self.assertEqual(quiet(lambda: lint.main(["--root", str(root)])), 1) + (problem / "solutions" / "python" / "main.py").write_text("print(1) # TODO\n", encoding="utf-8") + self.assertEqual(quiet(lambda: lint.main(["--root", str(root)])), 0) + self.assertEqual(quiet(lambda: lint.main(["--root", str(root), "--strict"])), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_run_tests.py b/scripts/tests/test_run_tests.py new file mode 100644 index 0000000..05b126d --- /dev/null +++ b/scripts/tests/test_run_tests.py @@ -0,0 +1,144 @@ +"""Tests for scripts/run_tests.py (toolchain-dependent cases are skipped when absent).""" + +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fixtures import make_problem, quiet # noqa: E402 + +import run_tests as rt # noqa: E402 + + +def have(tool: str) -> bool: + return shutil.which(tool) is not None + + +class RunTestsTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + + def tearDown(self) -> None: + self._tmp.cleanup() + + def run_problem(self, problem: Path, **options: object) -> list[rt.Result]: + return rt.run_problem(problem, None, rt.Options(**options)) # type: ignore[arg-type] + + def statuses(self, results: list[rt.Result]) -> list[str]: + return [r.status for r in results if r.case != "build"] + + def test_python_pass(self) -> None: + problem = make_problem(self.root) + self.assertEqual(self.statuses(self.run_problem(problem)), ["PASS"]) + + def test_wrong_answer_reports_first_difference(self) -> None: + problem = make_problem(self.root, cases={"c": ("hello\n", "bye\n")}) + (result,) = self.run_problem(problem) + self.assertEqual(result.status, "FAIL") + self.assertIn("line 1: expected 'bye', got 'hello'", result.detail) + + def test_runtime_error_is_a_failure(self) -> None: + problem = make_problem(self.root, source="import sys\nsys.exit(3)\n") + (result,) = self.run_problem(problem) + self.assertEqual(result.status, "FAIL") + self.assertIn("exit code 3", result.detail) + + def test_timeout_is_a_failure(self) -> None: + problem = make_problem(self.root, source="import time\ntime.sleep(5)\n") + (result,) = self.run_problem(problem, timeout=0.5) + self.assertEqual(result.status, "FAIL") + self.assertIn("time limit", result.detail) + + def test_trailing_space_is_strict_by_default_and_ignored_when_lenient(self) -> None: + problem = make_problem(self.root, source="print('1 ')\n", cases={"c": ("", "1\n")}) + self.assertEqual(self.statuses(self.run_problem(problem)), ["FAIL"]) + self.assertEqual(self.statuses(self.run_problem(problem, lenient=True)), ["PASS"]) + + def test_crlf_and_final_newlines_are_normalized(self) -> None: + problem = make_problem(self.root, source="print('a')\nprint('b')\n", cases={"c": ("", "a\r\nb\r\n\r\n")}) + self.assertEqual(self.statuses(self.run_problem(problem)), ["PASS"]) + + def test_missing_expected_output_fails(self) -> None: + problem = make_problem(self.root) + (problem / "tests" / "sample.out").unlink() + (result,) = self.run_problem(problem) + self.assertEqual(result.status, "FAIL") + self.assertIn("missing expected output", result.detail) + + def test_no_cases_is_a_skip(self) -> None: + problem = make_problem(self.root, cases={}) + (result,) = self.run_problem(problem) + self.assertEqual((result.status, result.detail), ("SKIP", "no test cases")) + + def test_sql_is_skipped_without_counting_as_missing_toolchain(self) -> None: + problem = make_problem(self.root, folder="sql", source="SELECT 1;\n", category="SQL") + (result,) = self.run_problem(problem) + self.assertEqual(result.status, "SKIP") + self.assertFalse(result.missing_tool) + + @unittest.skipUnless(have("g++"), "g++ not installed") + def test_cpp_pass_compile_error_and_werror(self) -> None: + echo = "#include \nint main() { int x; std::cin >> x; std::cout << x << '\\n'; }\n" + ok = make_problem(self.root, folder="cpp", source=echo, cases={"c": ("7\n", "7\n")}, problem_id=1001) + self.assertEqual(self.statuses(self.run_problem(ok)), ["PASS"]) + + broken = make_problem(self.root, folder="cpp", source="int main() { return }\n", problem_id=1002, slug="broken") + (result,) = self.run_problem(broken) + self.assertEqual(result.status, "FAIL") + self.assertIn("compilation failed", result.detail) + + noisy_source = "#include \nint main() { int unused = 0; std::cout << 1 << '\\n'; }\n" + noisy = make_problem(self.root, folder="cpp", source=noisy_source, cases={"c": ("", "1\n")}, problem_id=1003, slug="noisy") + self.assertEqual(self.statuses(self.run_problem(noisy)), ["PASS"]) + (strict_result,) = self.run_problem(noisy, werror=True) + self.assertEqual(strict_result.status, "FAIL") + + @unittest.skipUnless(have("g++"), "g++ not installed") + def test_sanitizer_catches_out_of_bounds(self) -> None: + source = "#include \n#include \nint main() { std::vector v(3); std::cout << v[std::cin.get() == 'x' ? 3 : 10] << '\\n'; }\n" + problem = make_problem(self.root, folder="cpp", source=source, cases={"c": ("x", "0\n")}) + (result,) = self.run_problem(problem, sanitize=True) + self.assertEqual(result.status, "FAIL") + + @unittest.skipUnless(have("gcc"), "gcc not installed") + def test_c_pass(self) -> None: + source = '#include \nint main(void) { int x; if (scanf("%d", &x) == 1) printf("%d\\n", x); return 0; }\n' + problem = make_problem(self.root, folder="c", source=source, cases={"c": ("5\n", "5\n")}) + self.assertEqual(self.statuses(self.run_problem(problem)), ["PASS"]) + + @unittest.skipUnless(have("node"), "node not installed") + def test_javascript_pass(self) -> None: + source = '"use strict";\nconsole.log(require("fs").readFileSync(0, "utf8").trim());\n' + problem = make_problem(self.root, folder="javascript", source=source) + self.assertEqual(self.statuses(self.run_problem(problem)), ["PASS"]) + + @unittest.skipUnless(have("rustc"), "rustc not installed") + def test_rust_pass(self) -> None: + source = "use std::io::Read;\nfn main() { let mut s = String::new(); std::io::stdin().read_to_string(&mut s).unwrap(); print!(\"{}\", s); }\n" + problem = make_problem(self.root, folder="rust", source=source) + self.assertEqual(self.statuses(self.run_problem(problem)), ["PASS"]) + + def test_missing_toolchain_is_skipped_and_can_fail_the_run(self) -> None: + problem = make_problem(self.root, folder="cpp", source="int main() {}\n") + with mock.patch.dict(os.environ, {"CXX": "definitely-not-a-compiler"}): + (result,) = self.run_problem(problem) + self.assertEqual((result.status, result.missing_tool), ("SKIP", True)) + self.assertEqual(quiet(lambda: rt.main(["--root", str(self.root)])), 0) + self.assertEqual(quiet(lambda: rt.main(["--root", str(self.root), "--require-toolchains"])), 1) + + def test_main_exit_codes(self) -> None: + make_problem(self.root) + self.assertEqual(quiet(lambda: rt.main(["--root", str(self.root)])), 0) + make_problem(self.root, cases={"c": ("x\n", "y\n")}, problem_id=1002, slug="wrong") + self.assertEqual(quiet(lambda: rt.main(["--root", str(self.root)])), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_templates.py b/scripts/tests/test_templates.py new file mode 100644 index 0000000..5a34336 --- /dev/null +++ b/scripts/tests/test_templates.py @@ -0,0 +1,47 @@ +"""The solution templates must themselves satisfy the standards they teach.""" + +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 lint_solutions as lint # noqa: E402 +import run_tests as rt # noqa: E402 + +TEMPLATES = Path(__file__).resolve().parent.parent.parent / "templates" / "solutions" +FOLDERS = {"cpp": "cpp", "c": "c", "py": "python", "js": "javascript", "rs": "rust", "sql": "sql"} + + +@unittest.skipUnless(TEMPLATES.is_dir(), "templates/solutions not found") +class TemplatesTest(unittest.TestCase): + def test_every_language_has_a_template(self) -> None: + self.assertEqual({p.suffix.lstrip(".") for p in TEMPLATES.glob("main.*")}, set(FOLDERS)) + + def test_templates_are_lint_clean(self) -> None: + for path in TEMPLATES.glob("main.*"): + folder = FOLDERS[path.suffix.lstrip(".")] + issues = lint.lint_text(path.read_text(encoding="utf-8"), folder, path.name) + self.assertEqual(issues, [], f"{path.name}: {issues}") + + def test_templates_build_and_echo_their_input(self) -> None: + options = rt.Options(sanitize=True, werror=True) + for path in TEMPLATES.glob("main.*"): + folder = FOLDERS[path.suffix.lstrip(".")] + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp: + prepared = rt.prepare(folder, path, Path(tmp), options) + if prepared.skip_reason: + continue # toolchain not installed here, or no SQL runner + self.assertIsNone(prepared.error, f"{path.name}: {prepared.error}") + given, expected = Path(tmp) / "in", Path(tmp) / "out" + given.write_text("42\n", encoding="utf-8") + expected.write_text("42\n", encoding="utf-8") + status, detail, _ = rt.run_case(prepared.command or [], given, expected, options) + self.assertEqual((status, detail), ("PASS", ""), path.name) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_validate_structure.py b/scripts/tests/test_validate_structure.py new file mode 100644 index 0000000..ad486df --- /dev/null +++ b/scripts/tests/test_validate_structure.py @@ -0,0 +1,159 @@ +"""Tests for scripts/validate_structure.py (run: python -m unittest discover -s scripts/tests).""" + +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 validate_structure as vs # noqa: E402 +from fixtures import quiet # noqa: E402 + +README = """# 1001 — Hello World + +## Link +https://example.invalid/1001 + +## Metadata + +- Problem ID: 1001 +- Platform: Beecrowd +- Category: Beginner +- Difficulty Level: 1 +- Topics: output +- Primary Language: C++ +- Other Implementations: None +- Documentation Level: L1 +- Status: Solved +- Review Priority: Low + +## Status + +- [x] Solved +- [ ] Revisit later +""" + + +class ValidateStructureTest(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.problem = self.root / "problems" / "1000-1099" / "1001-hello-world" + (self.problem / "solutions" / "cpp").mkdir(parents=True) + (self.problem / "README.md").write_text(README, encoding="utf-8") + (self.problem / "solutions" / "cpp" / "main.cpp").write_text("int main() {}\n", encoding="utf-8") + (self.problem / "tests").mkdir() + (self.problem / "tests" / "sample.in").write_text("1\n", encoding="utf-8") + (self.problem / "tests" / "sample.out").write_text("1\n", encoding="utf-8") + + def tearDown(self) -> None: + self._tmp.cleanup() + + def messages(self, level: str | None = None) -> list[str]: + issues = vs.validate_repository(self.root) + return [i.message for i in issues if level is None or i.level == level] + + def readme_replace(self, old: str, new: str) -> None: + readme = self.problem / "README.md" + readme.write_text(readme.read_text(encoding="utf-8").replace(old, new), encoding="utf-8") + + def assert_error_contains(self, fragment: str) -> None: + errors = self.messages("error") + self.assertTrue(any(fragment in m for m in errors), f"no error containing {fragment!r} in {errors}") + + def test_valid_problem_is_clean(self) -> None: + self.assertEqual(vs.validate_repository(self.root), []) + + def test_empty_solution_is_one_error(self) -> None: + (self.problem / "solutions" / "cpp" / "main.cpp").write_text(" \n", encoding="utf-8") + self.assertEqual(self.messages("error"), ["solution file is empty"]) + + def test_status_must_match_checkbox(self) -> None: + self.readme_replace("- [x] Solved", "- [ ] Solved") + self.assert_error_contains("checkbox is unchecked") + + def test_in_progress_with_checked_box_fails(self) -> None: + self.readme_replace("Status: Solved", "Status: In Progress") + self.assert_error_contains("checkbox is checked") + + def test_missing_metadata_field(self) -> None: + self.readme_replace("- Review Priority: Low\n", "") + self.assert_error_contains("'Review Priority' is missing") + + def test_invalid_category_and_difficulty(self) -> None: + self.readme_replace("Category: Beginner", "Category: Magic") + self.readme_replace("Difficulty Level: 1", "Difficulty Level: 11") + self.assert_error_contains("Category 'Magic'") + self.assert_error_contains("Difficulty Level '11'") + + def test_malformed_checkbox(self) -> None: + self.readme_replace("- [ ] Revisit later", "- [] Revisit later") + self.assert_error_contains("malformed checkbox") + + def test_unknown_language_folder(self) -> None: + (self.problem / "solutions" / "cobol").mkdir() + self.assert_error_contains("unknown language folder") + + def test_undeclared_implementation_is_only_a_warning(self) -> None: + (self.problem / "solutions" / "python").mkdir() + (self.problem / "solutions" / "python" / "main.py").write_text("print(1)\n", encoding="utf-8") + self.assertEqual(self.messages("error"), []) + self.assertTrue(any("not declared" in m for m in self.messages("warning"))) + + def test_declared_other_implementation_must_exist(self) -> None: + self.readme_replace("Other Implementations: None", "Other Implementations: Rust") + self.assert_error_contains("Rust is declared") + + def test_bad_slug_and_wrong_range(self) -> None: + bad = self.root / "problems" / "1000-1099" / "1002-Bad_Slug" + (bad / "solutions").mkdir(parents=True) + wrong_range = self.root / "problems" / "2000-2099" / "1003-ok-slug" + (wrong_range / "solutions").mkdir(parents=True) + errors = self.messages("error") + self.assertTrue(any("slug 'Bad_Slug'" in m for m in errors)) + self.assertTrue(any("does not belong in range" in m for m in errors)) + + def test_problem_id_must_match_folder(self) -> None: + self.readme_replace("Problem ID: 1001", "Problem ID: 1002") + self.assert_error_contains("does not match folder id") + + def test_sql_is_reserved_for_sql_category(self) -> None: + self.readme_replace("Primary Language: C++", "Primary Language: SQL") + sql_dir = self.problem / "solutions" / "sql" + sql_dir.mkdir() + (sql_dir / "main.sql").write_text("SELECT 1;\n", encoding="utf-8") + (self.problem / "solutions" / "cpp" / "main.cpp").unlink() + (self.problem / "solutions" / "cpp").rmdir() + self.assert_error_contains("SQL is reserved") + + def test_solved_problem_needs_test_cases(self) -> None: + for leftover in (self.problem / "tests").iterdir(): + leftover.unlink() + self.assert_error_contains("no test cases") + + def test_unsolved_problem_without_tests_is_only_a_warning(self) -> None: + for leftover in (self.problem / "tests").iterdir(): + leftover.unlink() + self.readme_replace("Status: Solved", "Status: In Progress") + self.readme_replace("- [x] Solved", "- [ ] Solved") + self.assertEqual(self.messages("error"), []) + self.assertTrue(any("no test cases" in m for m in self.messages("warning"))) + + def test_test_cases_must_come_in_pairs(self) -> None: + (self.problem / "tests" / "lonely.in").write_text("1\n", encoding="utf-8") + (self.problem / "tests" / "orphan.out").write_text("1\n", encoding="utf-8") + self.assert_error_contains("lonely.in has no matching lonely.out") + self.assert_error_contains("orphan.out has no matching orphan.in") + + 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") + self.assertEqual(quiet(lambda: vs.main(["--root", str(self.root)])), 0) + self.assertEqual(quiet(lambda: vs.main(["--root", str(self.root), "--strict"])), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate_structure.py b/scripts/validate_structure.py index e69de29..11acf20 100644 --- a/scripts/validate_structure.py +++ b/scripts/validate_structure.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Validate the structure and metadata of every problem folder. + +Usage: + python scripts/validate_structure.py # whole repository + python scripts/validate_structure.py problems/3400-3499/3484-divided-class + python scripts/validate_structure.py --strict # warnings fail too + python scripts/validate_structure.py --github # GitHub Actions annotations + +Exit code: 0 = clean, 1 = errors found (or warnings with --strict). + +Errors block a merge. Warnings are advice: things worth a look, but legitimate +in some cases. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from repo_lib import ( + CATEGORIES, + DOC_LEVELS, + LANGUAGES, + PRIORITIES, + PROBLEM_DIR_RE, + RANGE_DIR_RE, + REQUIRED_FIELDS, + SLUG_RE, + STATUSES, + Issue, + count_issues, + find_language, + find_problem_dirs, + parse_metadata, + parse_other_implementations, + parse_status_checkboxes, + print_issues, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def _rel(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() + + +def check_location(problem_dir: Path, root: Path) -> list[Issue]: + """Folder naming: problems//-/.""" + issues: list[Issue] = [] + rel = _rel(problem_dir, root) + + range_match = RANGE_DIR_RE.match(problem_dir.parent.name) + name_match = PROBLEM_DIR_RE.match(problem_dir.name) + + if not range_match: + issues.append(Issue("error", rel, "range folder must look like 3400-3499")) + else: + start, end = int(range_match.group(1)), int(range_match.group(2)) + if start % 100 != 0 or end != start + 99: + issues.append(Issue("error", rel, "range folder must cover exactly 100 ids (e.g. 3400-3499)")) + + if not name_match: + issues.append(Issue("error", rel, "folder name must be -")) + return issues + + problem_id, slug = int(name_match.group(1)), name_match.group(2) + if not SLUG_RE.match(slug): + issues.append(Issue("error", rel, f"slug '{slug}' must be lowercase english words separated by hyphens")) + if range_match and not int(range_match.group(1)) <= problem_id <= int(range_match.group(2)): + issues.append(Issue("error", rel, f"id {problem_id} does not belong in range {problem_dir.parent.name}")) + return issues + + +def check_metadata(problem_dir: Path, root: Path) -> tuple[list[Issue], dict[str, str]]: + """README exists and its metadata block is complete and coherent.""" + readme = problem_dir / "README.md" + rel = _rel(readme, root) + issues: list[Issue] = [] + + if not readme.is_file(): + return [Issue("error", rel, "README.md is missing")], {} + + text = readme.read_text(encoding="utf-8") + meta = parse_metadata(text) + + for field in REQUIRED_FIELDS: + if not meta.get(field): + issues.append(Issue("error", rel, f"metadata field '{field}' is missing or empty")) + + name_match = PROBLEM_DIR_RE.match(problem_dir.name) + if name_match and meta.get("Problem ID") and meta["Problem ID"] != name_match.group(1): + issues.append(Issue("error", rel, f"Problem ID {meta['Problem ID']} does not match folder id {name_match.group(1)}")) + + def check_choice(field: str, allowed: tuple[str, ...]) -> None: + value = meta.get(field) + if value and value.lower() not in (item.lower() for item in allowed): + issues.append(Issue("error", rel, f"{field} '{value}' must be one of: {', '.join(allowed)}")) + + check_choice("Category", CATEGORIES) + check_choice("Documentation Level", DOC_LEVELS) + check_choice("Review Priority", PRIORITIES) + check_choice("Status", STATUSES) + + level = meta.get("Difficulty Level", "") + 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")) + + 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)}'")) + + # Status field and the checklist at the bottom must tell the same story. + boxes, malformed = parse_status_checkboxes(text) + for line in malformed: + issues.append(Issue("error", rel, f"malformed checkbox: '{line}' (use '- [ ] text' or '- [x] text')")) + if "Solved" not in boxes: + issues.append(Issue("error", rel, "## Status checklist must contain a 'Solved' checkbox")) + elif meta.get("Status"): + claims_solved = meta["Status"].lower() == "solved" + if claims_solved != boxes["Solved"]: + state = "checked" if boxes["Solved"] else "unchecked" + issues.append(Issue("error", rel, f"Status is '{meta['Status']}' but the 'Solved' checkbox is {state}")) + + return issues, meta + + +def check_solutions(problem_dir: Path, root: Path, meta: dict[str, str]) -> list[Issue]: + """Every declared implementation exists, is non-empty and is in a known language.""" + issues: list[Issue] = [] + solutions_dir = problem_dir / "solutions" + rel_solutions = _rel(solutions_dir, root) + + if not solutions_dir.is_dir(): + return [Issue("error", rel_solutions, "solutions/ folder is missing")] + + folder_to_display = {folder: display for display, (folder, _ext) in LANGUAGES.items()} + present: set[str] = set() + reported: set[str] = set() # languages that already got an error below + + for lang_dir in sorted(p for p in solutions_dir.iterdir() if p.is_dir()): + display = folder_to_display.get(lang_dir.name) + rel_dir = _rel(lang_dir, root) + if display is None: + allowed = ", ".join(sorted(folder_to_display)) + issues.append(Issue("error", rel_dir, f"unknown language folder (allowed: {allowed})")) + continue + + _folder, ext = LANGUAGES[display] + main_file = lang_dir / f"main.{ext}" + rel_main = _rel(main_file, root) + if not main_file.is_file(): + issues.append(Issue("error", rel_main, f"main.{ext} is missing")) + reported.add(display) + elif not main_file.read_text(encoding="utf-8").strip(): + issues.append(Issue("error", rel_main, "solution file is empty")) + reported.add(display) + else: + present.add(display) + + rel_readme = _rel(problem_dir / "README.md", root) + primary_raw = meta.get("Primary Language", "") + primary = find_language(primary_raw) if primary_raw else None + if primary_raw and primary is None: + issues.append(Issue("error", rel_readme, f"Primary Language '{primary_raw}' is not supported")) + elif primary and primary not in present and primary not in reported: + issues.append(Issue("error", rel_readme, f"Primary Language {primary} has no valid solutions/{LANGUAGES[primary][0]}/main.{LANGUAGES[primary][1]}")) + + declared = {primary} if primary else set() + for name in parse_other_implementations(meta.get("Other Implementations", "")): + display = find_language(name) + if display is None: + issues.append(Issue("error", rel_readme, f"Other Implementations lists unsupported language '{name}'")) + continue + declared.add(display) + if display not in present and display not in reported: + issues.append(Issue("error", rel_readme, f"{display} is declared in Other Implementations but has no valid solution file")) + + for display in sorted(present - declared): + issues.append(Issue("warning", rel_readme, f"{display} solution exists but is not declared in the metadata")) + + # Policy: SQL is only for the SQL category (and the SQL category only uses SQL). + category = meta.get("Category", "") + if primary == "SQL" and category and category != "SQL": + issues.append(Issue("error", rel_readme, "SQL is reserved for the SQL category")) + if category == "SQL" and primary and primary != "SQL": + issues.append(Issue("error", rel_readme, "the SQL category must use SQL as Primary Language")) + + if meta.get("Documentation Level") == "L3" and not (problem_dir / "deep-dive.md").is_file(): + issues.append(Issue("warning", _rel(problem_dir, root), "Documentation Level L3 usually has a deep-dive.md")) + + return issues + + +def check_tests(problem_dir: Path, root: Path, meta: dict[str, str]) -> list[Issue]: + """tests/.in + tests/.out pairs, shared by all implementations.""" + issues: list[Issue] = [] + rel = _rel(problem_dir / "tests", root) + primary = find_language(meta.get("Primary Language", "")) or "" + if primary == "SQL": + return issues # no SQL runner yet; nothing to execute + + tests_dir = problem_dir / "tests" + inputs = {p.stem for p in tests_dir.glob("*.in")} if tests_dir.is_dir() else set() + outputs = {p.stem for p in tests_dir.glob("*.out")} if tests_dir.is_dir() else set() + + if not inputs and not outputs: + solved = meta.get("Status", "").lower() == "solved" + level = "error" if solved else "warning" + issues.append(Issue(level, rel, "no test cases (add tests/.in and tests/.out)")) + return issues + + for stem in sorted(inputs - outputs): + issues.append(Issue("error", rel, f"{stem}.in has no matching {stem}.out")) + for stem in sorted(outputs - inputs): + issues.append(Issue("error", rel, f"{stem}.out has no matching {stem}.in")) + return issues + + +def validate_problem(problem_dir: Path, root: Path) -> list[Issue]: + issues = check_location(problem_dir, root) + meta_issues, meta = check_metadata(problem_dir, root) + issues += meta_issues + issues += check_solutions(problem_dir, root, meta) + issues += check_tests(problem_dir, root, meta) + return issues + + +def validate_repository(root: Path, only: list[Path] | None = None) -> list[Issue]: + issues: list[Issue] = [] + problems_root = root / "problems" + + if only is None: + # Stray files/folders directly under problems/ that are not range folders. + if problems_root.is_dir(): + for entry in sorted(problems_root.iterdir()): + if entry.is_dir() and not RANGE_DIR_RE.match(entry.name): + issues.append(Issue("error", _rel(entry, root), "folders directly under problems/ must be id ranges like 3400-3499")) + targets = find_problem_dirs(root) + else: + targets = only + + for problem_dir in targets: + issues += validate_problem(problem_dir, root) + return issues + + +def print_report(issues: list[Issue], checked: int, github: bool) -> None: + print_issues(issues, github) + errors, warnings = count_issues(issues) + print(f"\nChecked {checked} problem folder(s): {errors} error(s), {warnings} warning(s).") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate problem folder structure and metadata.") + parser.add_argument("paths", nargs="*", type=Path, help="problem folders to check (default: all)") + parser.add_argument("--root", type=Path, default=REPO_ROOT, help="repository root") + parser.add_argument("--strict", action="store_true", help="treat warnings as errors") + parser.add_argument("--github", action="store_true", help="emit GitHub Actions annotations") + args = parser.parse_args(argv) + + root = args.root.resolve() + only = [p.resolve() for p in args.paths] if args.paths else None + issues = validate_repository(root, only) + checked = len(only) if only is not None else len(find_problem_dirs(root)) + print_report(issues, checked, args.github) + + errors, warnings = count_issues(issues) + return 1 if errors or (args.strict and warnings) else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 6bf81320ecf5236a844a65a2e38712ea6c8a5464 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Tue, 6 Oct 2026 15:52:23 -0300 Subject: [PATCH 2/6] docs(repo): add coding standards, language policy, testing guide, and rust/sql style guides --- docs/coding-standards.md | 87 +++++++++++++++++++++++++++++++++ docs/indexes/by-language.md | 8 +++ docs/language-policy.md | 58 ++++++++++++++++++++++ docs/references.md | 14 ++++++ docs/repository-conventions.md | 19 ++++++- docs/style-guides/c.md | 45 ++++++++++++++++- docs/style-guides/cpp.md | 68 +++++++++++++++++++++++--- docs/style-guides/javascript.md | 42 ++++++++++++++-- docs/style-guides/python.md | 37 +++++++++++++- docs/style-guides/rust.md | 71 +++++++++++++++++++++++++++ docs/style-guides/sql.md | 62 +++++++++++++++++++++++ docs/testing.md | 82 +++++++++++++++++++++++++++++++ docs/workflow.md | 6 ++- 13 files changed, 582 insertions(+), 17 deletions(-) create mode 100644 docs/coding-standards.md create mode 100644 docs/language-policy.md create mode 100644 docs/style-guides/rust.md create mode 100644 docs/style-guides/sql.md create mode 100644 docs/testing.md diff --git a/docs/coding-standards.md b/docs/coding-standards.md new file mode 100644 index 0000000..a7299c4 --- /dev/null +++ b/docs/coding-standards.md @@ -0,0 +1,87 @@ +# Coding Standards + +These rules apply to **every language** in this repository. Each language adds its own details in `docs/style-guides/`: + +[C++](style-guides/cpp.md) · [C](style-guides/c.md) · [Python](style-guides/python.md) · [JavaScript](style-guides/javascript.md) · [Rust](style-guides/rust.md) · [SQL](style-guides/sql.md) + +The rules that a program can decide are checked automatically by `scripts/lint_solutions.py`. The rest is checked by review, using the checklist at the end of this page. + +## 1. Think before typing + +1. **Estimate complexity from the constraints before coding.** Choosing the right algorithm is the largest optimization there is. `N <= 250` allows `O(N^2)` or even `O(N^3)`; `N = 10^5` calls for `O(N log N)`. +2. **Correct first, fast second.** Optimize only after the samples pass and a measurement or a complexity estimate says it is necessary. +3. **Pick the data structure for the operations you need**, not for habit: lookups, ordered traversal, a queue, a priority queue. +4. **Prefer the simplest correct approach.** Clever code is a liability; clear code is an asset. + +## 2. Input and output contract + +1. Read **exactly** what the statement describes. Handle end-of-file when the statement says the input ends that way. +2. Never assume a particular whitespace layout; read tokens, not lines, unless lines matter. +3. Match the output format **character for character**: separators, decimals, trailing spaces, and the final newline. Judges report formatting differences as errors. +4. Read the whole input efficiently when it is large, and write the output in as few calls as practical. + +## 3. Safety is the default + +These rules are cheap in a puzzle and essential in real code, which is why they are practiced here. + +1. **Every index needs an argument.** Either a bounds check, or a short comment that explains why it cannot go out of range. +2. **Know the largest value before choosing a numeric type.** Compute the worst case. Signed overflow is undefined behavior in C and C++, wraps silently in release Rust, and loses precision in JavaScript numbers above 2^53. +3. **Never trust sizes or counts from input** when allocating or indexing. Validate them against the statement's limits. +4. **Initialize everything.** No variable is read before it has a value. +5. **Ownership is explicit.** Prefer containers and smart pointers (or Rust's ownership) to manual memory management. A non-owning cursor is fine; a second owner is a bug. +6. **No dynamic code execution** (`eval`, `exec`, `new Function`) and no building queries by string concatenation. +7. **Treat compiler and linter warnings as defects.** Fix them or justify them. + +## 4. Performance rules + +1. Measure or estimate; do not guess. +2. Avoid work in the inner loop that can be done once outside it. +3. Prefer the standard library: it is tested, documented, and fast. +4. Fast input/output matters only when the input is large. Add it deliberately and say why in a comment. +5. Know the cost of the operations you call (`list.pop(0)`, `Array.shift()`, string concatenation in a loop, copying large values). +6. Beware of adversarial inputs for hash tables when the input is not trusted. + +## 5. Structure, naming, and comments + +1. One file per implementation: `solutions//main.`. Standard library only, no external dependencies. +2. Small functions with one clear purpose; `main` reads, calls, and prints. +3. Meaningful names. One-letter names only in very local scopes (`i`, `j`, `n`). +4. Comments explain **why**, never what. A good comment records a non-obvious reason: a bound, a trick, an invariant. +5. Follow the naming rules of each language (see the style guides) and `.editorconfig` (UTF-8, LF, spaces, 4-space indent for code). +6. English only for code, comments, and documentation. +7. No leftover `TODO`, debugging output, or commented-out code. + +## 6. Testing expectations + +Every solved problem has test cases in `tests/` (see [Testing](testing.md)): the public samples, the edge cases, and, when a faster solution replaced a simpler one, cases cross-checked against a brute-force oracle. + +## 7. What is checked automatically + +`scripts/lint_solutions.py` reports findings with a stable rule id. Errors block a commit; warnings are advice. The full list per language is in the style guides. + +| Area | Examples of what it catches | +|------|-----------------------------| +| Portability | ``, std symbols used without their `#include` | +| Memory safety | raw `new`/`delete`, `gets`, unbounded `%s`, `strcpy`, `unsafe` without a `SAFETY` note | +| Correctness traps | `var` and `==` in JavaScript, default `Array.sort()`, mutable default arguments | +| Security | `eval`/`exec`, wildcard imports, `SELECT *` and comma joins in SQL | +| Hygiene | leftover `TODO`/`FIXME` (`GEN001`), lines over 120 characters (`GEN002`) | + +### Deliberate exceptions + +A rule can be waived on purpose with a comment on the same line or the line above, naming the rule and giving the reason: + +```cpp +int* raw = new int[3]; // lint:allow CPP004 deliberate raw-pointer exercise +``` + +An exception without a reason does not pass review. + +## 8. Review checklist before submitting + +- [ ] I can explain the algorithm and its complexity in my own words. +- [ ] Every index and every numeric range has an argument. +- [ ] The samples pass, plus edge cases (smallest and largest input, sorted, reversed, duplicates when allowed). +- [ ] The output format matches the statement exactly. +- [ ] No warnings, no leftover markers, no unused code. +- [ ] `python scripts/check_all.py` passes. diff --git a/docs/indexes/by-language.md b/docs/indexes/by-language.md index 265301a..95784f2 100644 --- a/docs/indexes/by-language.md +++ b/docs/indexes/by-language.md @@ -16,4 +16,12 @@ _No entries yet._ ## C +_No entries yet._ + +## Rust + +_No entries yet._ + +## SQL + _No entries yet._ \ No newline at end of file diff --git a/docs/language-policy.md b/docs/language-policy.md new file mode 100644 index 0000000..77a639f --- /dev/null +++ b/docs/language-policy.md @@ -0,0 +1,58 @@ +# Language Policy + +This repository supports six languages: **C++, C, Python, JavaScript, Rust, and SQL**. + +Supporting many languages does not mean solving every problem in all of them. Rewriting the same solution five times adds repetition, not evidence of reasoning. The rule is simple: + +> **Each problem has one primary language, chosen for what the problem trains.** + +## Choosing the primary language + +| Language | Choose it when the problem is mostly about... | +|----------|-----------------------------------------------| +| **C++** (default) | data structures, graphs, paradigms, mathematics, anything that benefits from the standard library and predictable performance | +| **C** | bit manipulation, memory layout, manual buffers, C-style strings and input parsing | +| **Rust** | ownership, lifetimes, and memory safety as the point of the exercise; the natural counterpart to C | +| **Python** | strings, ad-hoc simulation, big integers, quick prototypes | +| **JavaScript** | parsing and text processing close to my professional stack | +| **SQL** | the SQL category, and only the SQL category | + +```mermaid +flowchart TD + A[New problem] --> B{SQL category?} + B -- yes --> S[SQL] + B -- no --> C{Trains memory layout, bits or manual buffers?} + C -- yes --> D[C - add Rust only to contrast ownership] + C -- no --> E{Mostly parsing, strings or a quick prototype?} + E -- yes --> F[Python or JavaScript] + E -- no --> G[C++ - the default] +``` + +Any problem that does not use C++ states the reason in one line of its `README.md`, so the variety is visibly intentional. + +## Additional implementations + +`Other Implementations` stays empty by default. A second implementation is added only when it teaches something specific: + +- a **C versus Rust** comparison of manual memory management and ownership; +- a short **Python oracle** (brute force) used to cross-check a faster solution; +- a deliberate exercise in the idioms of another language. + +The reason is written in the problem `README.md`. Documentation stays centralized: one explanation file per problem. + +## Status integrity + +`Status: Solved` is only valid for code that was **submitted to the judge and accepted**. + +- When migrating a legacy solution, keep the language in which it was accepted. +- Rewriting a solution in another language is a new submission and has its own status. +- Use `In Progress`, `Improving`, or `To Review` until the judge confirms the result. + +## Open points + +- Judge support for **Rust** is not confirmed. Until it is, Rust solutions are local study material and are never marked `Solved`. +- The **SQL** engine used by the judge, and a local SQL test runner, are still to be defined. + +## How the policy is enforced + +`scripts/validate_structure.py` rejects unsupported language folders, empty solution files, a primary language without code, SQL outside the SQL category, and a `Solved` status without test cases. See [Testing](testing.md). diff --git a/docs/references.md b/docs/references.md index c504744..3068a6a 100644 --- a/docs/references.md +++ b/docs/references.md @@ -18,6 +18,20 @@ This repository is influenced by principles commonly associated with the followi - The Algorithm Design Manual - Programming Pearls +## Language and safe-coding references + +- Competitive Programmer's Handbook (Antti Laaksonen) +- CP-Algorithms (online) +- C++ Core Guidelines (Bjarne Stroustrup and Herb Sutter) +- Effective Modern C++ (Scott Meyers) +- SEI CERT C Coding Standard +- Secure Coding in C and C++ (Robert C. Seacord) +- The Rust Programming Language ("the book") and The Rustonomicon +- PEP 8 and PEP 20 (Python) +- MDN Web Docs (JavaScript) +- SQL Antipatterns (Bill Karwin) +- OWASP Cheat Sheet Series + ## Practical interpretation for this repository These references inspire the following standards: diff --git a/docs/repository-conventions.md b/docs/repository-conventions.md index e7b6934..3c93716 100644 --- a/docs/repository-conventions.md +++ b/docs/repository-conventions.md @@ -50,7 +50,24 @@ This keeps the filesystem stable and scalable. A problem has one main conceptual documentation file. -Multiple implementations may exist inside `solutions/`, but the explanation should remain centralized. +A problem has one primary language. Extra implementations may exist inside `solutions/` when there is a stated reason (see `language-policy.md`), but the explanation remains centralized. + +## Problem folder layout + +```text +-/ +├── README.md one explanation, with the metadata block +├── notes.md optional +├── deep-dive.md optional, for L3 +├── solutions//main. +└── tests/.in, .out +``` + +Supported language folders: `cpp`, `c`, `python`, `javascript`, `rust`, `sql`. Test cases are shared by every implementation (see `testing.md`). + +## Code standards + +All code follows `coding-standards.md` and the style guide of its language. The rules are checked by `scripts/lint_solutions.py`. ## Statements diff --git a/docs/style-guides/c.md b/docs/style-guides/c.md index a8fc956..54e088f 100644 --- a/docs/style-guides/c.md +++ b/docs/style-guides/c.md @@ -1,5 +1,15 @@ # C Style Guide +Read [Coding Standards](../coding-standards.md) first; this page adds what is specific to C. C is chosen for problems about memory layout, bits, and manual buffers (see [Language Policy](../language-policy.md)). + +Local build: + +```bash +gcc -std=c17 -O2 -Wall -Wextra -Wpedantic -fsanitize=address,undefined main.c +``` + +A starting point is in `templates/solutions/main.c`. + ## Principles - keep code simple and disciplined @@ -13,6 +23,27 @@ - functions: `snake_case` - constants: `UPPER_CASE` +## Input: never read more than you can hold + +- Never use `gets`. Use `fgets` with the buffer size, or `scanf` with a width: `scanf("%99s", buffer)` for a 100-byte buffer. +- Always check the return value of `scanf` (it is the number of fields read), and of `fgets`. +- Convert numbers with `strtol` and check `errno` and the end pointer. `atoi` cannot report errors. +- Store the result of `getchar` in an `int`, not a `char`, or end-of-file is lost. + +## Memory and strings + +- Avoid variable-length arrays: a large size overflows the stack. Use a static array sized by the problem's limits, or `malloc`/`calloc` and check for `NULL`. +- Every `malloc` has one owner and one `free`. +- Use `snprintf` instead of `sprintf`, and never `strcpy` or `strcat`. Make sure strings end with `'\0'`. +- Initialize arrays and variables (`int counts[26] = {0};`). + +## Numbers + +- Use `size_t` for sizes and indexes of objects, and take care with subtraction: a `size_t` never goes below zero. Count down with `for (size_t i = n; i-- > 0;)`. +- Use `` and `long long` when values can exceed `int`. Signed overflow is undefined behavior. +- Use the right `printf` conversions: `%d`, `%lld`, `%zu`, `%f`. +- Prefer `enum` or `static const` to `#define` for numeric limits. + ## Style - be careful with indexes, memory, and boundaries @@ -21,8 +52,18 @@ ## Comments -Use comments when they clarify reasoning, edge cases, or implementation choices. +Use comments when they clarify reasoning, edge cases, or implementation choices. In C, explaining why an index or a buffer size is safe is expected. + +## Automated checks + +| Rule | Level | Meaning | +|------|-------|---------| +| `C001` | error | `gets` | +| `C002` | error | `scanf` `%s` or `%[` without a width | +| `C003` | error | `strcpy`, `strcat`, `sprintf`, `vsprintf` | +| `C004` | warning | `atoi`, `atol`, `atoll`, `atof` | +| `C005` | warning | `#define` used for a numeric constant | ## Goal -C solutions should reflect solid fundamentals, precision, and disciplined implementation. \ No newline at end of file +C solutions should reflect solid fundamentals, precision, and disciplined implementation. diff --git a/docs/style-guides/cpp.md b/docs/style-guides/cpp.md index b8fe958..588bf8d 100644 --- a/docs/style-guides/cpp.md +++ b/docs/style-guides/cpp.md @@ -1,10 +1,16 @@ # C++ Style Guide +Read [Coding Standards](../coding-standards.md) first; this page adds what is specific to C++. + ## Standard -Prefer modern C++ with clarity and restraint. +Prefer modern C++ with clarity and restraint. Use C++17 or C++20 when appropriate, but avoid unnecessary complexity. The judge compiles with a C++20 option; local builds use: + +```bash +g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -Wshadow -fsanitize=address,undefined main.cpp +``` -Use C++17 or C++20 when appropriate, but avoid unnecessary complexity. +A starting point is in `templates/solutions/main.cpp`. ## Principles @@ -19,22 +25,68 @@ Use C++17 or C++20 when appropriate, but avoid unnecessary complexity. - variables: `camelCase` - constants: `kConstantName` or `UPPER_CASE` when strongly justified - functions: `camelCase` +- types: `PascalCase` + +## The `std` namespace + +Always write `std::`. Never write `using namespace std;`. + +It saves a few characters and costs clarity: the standard library has many short names (`left`, `right`, `count`, `distance`, `size`, `rank`, `hash`, `next`) that collide with your own variables and functions, producing errors such as `reference to 'left' is ambiguous`. Explicit qualification also shows at a glance what comes from the library. If a type name is long, give it an alias: + +```cpp +using Graph = std::vector>; +``` ## Includes -Include only what is needed. +- Include exactly what you use, in alphabetical order. Never rely on another header pulling it in: that works on one compiler and fails on another. +- Never use ``. It is an internal GCC header, not standard C++, and it hides missing includes. +- Avoid oversized competitive programming templates when the problem does not justify them. -Avoid oversized competitive programming templates when the problem does not justify them. +## Ownership and memory -## Comments +- Own heap objects with `std::unique_ptr` and create them with `std::make_unique`. The destructor releases the memory, so leaks, double frees, and use-after-free through that pointer are impossible by construction. +- A raw pointer (`Node*`) is allowed only as a **non-owning cursor** used to walk a structure. +- No `new`/`delete`, `malloc`/`free`. +- Prefer `std::vector` to C arrays and variable-length arrays; `std::array` for fixed sizes. +- A recursively destroyed structure (a degenerate tree or list) can overflow the stack when it holds hundreds of thousands of nodes. Fine for small limits; mention it when it is not. -Use comments only when they add reasoning that is not obvious from the code. +## Types and conversions -Do not comment every line. +- Use `long long` (or `std::int64_t`) when a value can exceed `int`. Decide after computing the worst case. +- Use `std::size_t` for container sizes and mind signed/unsigned comparisons. +- Convert with `static_cast`, never with a C-style cast. +- Initialize every variable. Mark single-argument constructors `explicit`. Use `const` by default and `constexpr` for constants. Prefer `enum class`. + +## Performance + +- Pass large objects by `const&`; use `std::string_view` for read-only text. +- `reserve()` a vector when the size is known. +- Write `'\n'`, not `std::endl` (`endl` flushes on every call). +- For large inputs only: `std::ios::sync_with_stdio(false); std::cin.tie(nullptr);` with a comment explaining why. Afterwards, never mix `cin`/`cout` with `scanf`/`printf`. +- Prefer `std::sort`, `std::lower_bound`, `std::accumulate` and friends to hand-written loops when they read better. +- `std::unordered_map` can be driven to its worst case by adversarial input; use `std::map` when the input is not trusted. + +## Comments + +Use comments only when they add reasoning that is not obvious from the code. Do not comment every line. ## Preferred style - clear control flow - explicit logic - minimal hidden behavior -- no cryptic one-letter variable abuse unless used in very local contexts \ No newline at end of file +- no cryptic one-letter variable abuse unless used in very local contexts + +## Automated checks + +| Rule | Level | Meaning | +|------|-------|---------| +| `CPP001` | error | `using namespace std` | +| `CPP002` | error | `` | +| `CPP003` | warning | `std::endl` | +| `CPP004` | error | raw `new`/`delete` | +| `CPP005` | warning | C-style cast | +| `CPP006` | warning | `malloc`/`calloc`/`realloc`/`free` | +| `CPP007` | warning | `#define` macro | +| `CPP008` | error | std symbol used without its `#include` | diff --git a/docs/style-guides/javascript.md b/docs/style-guides/javascript.md index 571112e..a478946 100644 --- a/docs/style-guides/javascript.md +++ b/docs/style-guides/javascript.md @@ -1,5 +1,7 @@ # JavaScript Style Guide +Read [Coding Standards](../coding-standards.md) first; this page adds what is specific to JavaScript (Node.js). A starting point is in `templates/solutions/main.js`. + ## Principles - write modern, readable JavaScript @@ -12,16 +14,50 @@ - functions: `camelCase` - constants: `UPPER_CASE` +## Declarations + +- Start the file with `"use strict";`. +- Use `const` by default and `let` only when reassignment is necessary. Never use `var`. +- Compare with `===` and `!==`, never `==` or `!=`. + +## Input and output + +- Read everything at once and split it: `require("fs").readFileSync(0, "utf8")`. Check the judge's own template for the accepted way to read standard input (some judges expect `/dev/stdin`). +- Collect the output in an array and print it once with `join("\n")`; one `console.log` per line is slow for large outputs. + +## Numbers: the traps + +- `Number` is a double: integers above `Number.MAX_SAFE_INTEGER` (2^53 - 1) lose precision. Use `BigInt` for them. +- Integer division is `Math.trunc(a / b)`. +- Bitwise operators work on 32 bits (`| 0`, `>>> 0`). +- `Array.prototype.sort()` without a comparator sorts as **text**. Always pass `(a, b) => a - b` for numbers. + +## Performance and data structures + +- `Array.shift()` is O(n); implement a queue with a head index. +- Use typed arrays (`Int32Array`, `Float64Array`) for large numeric data. +- Use `Map` and `Set` as dictionaries. Plain objects inherit from `Object.prototype`, so keys such as `__proto__` can corrupt them (prototype pollution); if an object is needed, create it with `Object.create(null)`. +- Never use `eval` or `new Function`. + ## Style -- prefer `const` by default -- use `let` only when reassignment is necessary - avoid deeply nested logic when simpler structures exist ## Comments Comment intent, not syntax. +## Automated checks + +| Rule | Level | Meaning | +|------|-------|---------| +| `JS001` | error | `var` | +| `JS002` | error | loose equality (`==`, `!=`) | +| `JS003` | error | `eval` / `new Function` | +| `JS004` | warning | `sort()` without a comparator | +| `JS005` | warning | `shift()` | +| `JS006` | warning | missing `"use strict"` | + ## Goal -JavaScript solutions should reinforce professional consistency with a frontend engineering background while still fitting algorithmic problem-solving needs. \ No newline at end of file +JavaScript solutions should reinforce professional consistency with a frontend engineering background while still fitting algorithmic problem-solving needs. diff --git a/docs/style-guides/python.md b/docs/style-guides/python.md index e37fecd..fbf89dd 100644 --- a/docs/style-guides/python.md +++ b/docs/style-guides/python.md @@ -1,5 +1,7 @@ # Python Style Guide +Read [Coding Standards](../coding-standards.md) first; this page adds what is specific to Python. The language follows PEP 8. A starting point is in `templates/solutions/main.py`. + ## Principles - clarity first @@ -13,6 +15,29 @@ - functions: `snake_case` - constants: `UPPER_CASE` +## Structure + +- Put the logic in `main()` and call it under `if __name__ == "__main__":`. Local variables are also faster than globals. +- Add type hints to function signatures. +- Never use a mutable default argument (`def f(items=[])`). + +## Input and output + +- Read all input at once: `sys.stdin.read().split()`, or `sys.stdin.readline` when lines matter. A loop of `input()` calls is slow for large inputs. +- Build the output and print it once: `print("\n".join(lines))`. Never grow a string with `+=` in a loop. + +## Standard library and complexity + +- `collections.deque` for queues (`list.pop(0)` is O(n)); `set` and `dict` for membership; `heapq` for priority queues; `bisect` for sorted lists; `collections.Counter` for counting. +- `math.isqrt` and `math.gcd` instead of floating-point tricks. +- Avoid copying slices inside loops. + +## Numbers and recursion + +- Integers never overflow, but very large ones are slow, and converting integers with thousands of digits to or from `str` is limited by default in recent Python versions. +- `round()` uses banker's rounding. Format with `f"{value:.2f}"` when the output needs fixed decimals, and use `decimal.Decimal` when exact decimal arithmetic matters. +- Deep recursion is slow and limited. Prefer an iterative version; raising `sys.setrecursionlimit` can crash the interpreter. + ## Implementation - avoid unnecessary shortcuts @@ -23,6 +48,16 @@ Use comments sparingly and only where they improve understanding. +## Automated checks + +| Rule | Level | Meaning | +|------|-------|---------| +| `PY001` | error | `eval` / `exec` | +| `PY002` | error | wildcard import | +| `PY003` | warning | `list.pop(0)` | +| `PY004` | warning | mutable default argument | +| `PY005` | warning | `sys.setrecursionlimit` | + ## Goal -Python solutions should feel elegant, clear, and trustworthy. \ No newline at end of file +Python solutions should feel elegant, clear, and trustworthy. diff --git a/docs/style-guides/rust.md b/docs/style-guides/rust.md new file mode 100644 index 0000000..7dcba0e --- /dev/null +++ b/docs/style-guides/rust.md @@ -0,0 +1,71 @@ +# Rust Style Guide + +Read [Coding Standards](../coding-standards.md) first; this page adds what is specific to Rust. Rust is chosen when ownership, lifetimes, and memory safety are the point of the exercise, often as the counterpart of a C solution (see [Language Policy](../language-policy.md)). + +Judge support for Rust is not confirmed yet, so Rust solutions are local study material until it is. A starting point is in `templates/solutions/main.rs`. + +Local build and checks: + +```bash +rustc --edition 2021 -O -D warnings main.rs +rustfmt --check main.rs +``` + +When a Cargo project is used for study, also run `cargo clippy -- -D warnings`. + +## Principles + +- let the type system carry the invariants +- prefer clarity to cleverness; iterators when they read better than indexes +- standard library only: no external crates + +## Naming + +- functions, variables, modules: `snake_case` +- types and traits: `PascalCase` +- constants: `UPPER_CASE` + +Formatting is whatever `rustfmt` produces (4 spaces). + +## Input and output + +- Read all input with `read_to_string` and parse with `split_ascii_whitespace()`. +- Write through a `BufWriter` on a locked stdout and `writeln!`. A `println!` per line locks and flushes repeatedly in a hot loop. + +## Errors + +- Do not leave a bare `unwrap()`. Use `expect("reason")`, the `?` operator, or an explicit `match`. When the judge guarantees the input, an `expect` with a message is acceptable. +- Model absence with `Option` and failure with `Result`. + +## Numbers + +- Arithmetic overflow panics in debug builds and **wraps silently in release builds**. Choose deliberately: `checked_*`, `wrapping_*`, `saturating_*`. +- `as` casts truncate silently. Prefer `From`/`TryFrom` when the range is not obviously safe. +- Use `usize` for indexes and `i64`/`u64` for values that can be large. + +## Ownership and performance + +- Borrow (`&[T]`, `&str`) instead of cloning; clone only with a reason. +- `Vec::with_capacity` when the size is known; `sort_unstable` when stability is not needed. +- `HashMap` uses a randomly keyed, DoS-resistant hasher by default and is slower than a plain one; `BTreeMap` is a good ordered alternative. + +## `unsafe` + +`unsafe` is allowed only with a `// SAFETY:` comment, on the line above or the same line, explaining why the operation is sound. Prefer a safe design when one exists. + +## Comments + +Comment the reason, not the syntax. + +## Automated checks + +| Rule | Level | Meaning | +|------|-------|---------| +| `RS001` | warning | `unwrap()` | +| `RS002` | error | `unsafe` without a `// SAFETY:` comment | + +`rustfmt` and `clippy` complement these checks and are run manually. + +## Goal + +Rust solutions should show how ownership and types make a whole class of memory bugs impossible. diff --git a/docs/style-guides/sql.md b/docs/style-guides/sql.md new file mode 100644 index 0000000..255f578 --- /dev/null +++ b/docs/style-guides/sql.md @@ -0,0 +1,62 @@ +# SQL Style Guide + +Read [Coding Standards](../coding-standards.md) first; this page adds what is specific to SQL. SQL is used only for problems in the SQL category (see [Language Policy](../language-policy.md)). A starting point is in `templates/solutions/main.sql`. + +The database engine used by the judge is still to be confirmed. Until it is, stay with standard SQL and note any engine-specific feature in the problem `README.md`. + +## Principles + +- readable queries first, clever ones never +- the result must match the requested columns, names, and order exactly + +## Formatting + +- Keywords in `UPPERCASE`; table and column names in `snake_case`. +- One clause per line, indented consistently: + +```sql +SELECT c.name, + COUNT(*) AS orders +FROM customers AS c +JOIN orders AS o ON o.customer_id = c.id +GROUP BY c.name +ORDER BY orders DESC, c.name; +``` + +## Correctness + +- Always use explicit `JOIN ... ON`, never comma joins. Qualify columns with table aliases. +- Name the columns you select. Never use `SELECT *`: it breaks when the table changes and hides what the query returns. +- Order is only guaranteed by `ORDER BY`. Without it, the row order is undefined. +- `NULL` is not equal to anything, including itself: compare with `IS NULL` and handle it with `COALESCE`. +- `COUNT(*)` counts rows; `COUNT(column)` skips `NULL`s. +- `WHERE` filters rows before grouping; `HAVING` filters groups after it. Every selected column that is not aggregated belongs in `GROUP BY`. + +## Readability and performance + +- Prefer common table expressions (`WITH`) to deeply nested subqueries. +- Prefer a `JOIN` to a correlated subquery when both express the same thing. +- Use `UNION ALL` unless duplicates must be removed (`UNION` pays for deduplication). +- Do not wrap an indexed column in a function inside `WHERE`; it prevents the index from being used. + +## Security + +Outside this repository, never build SQL by concatenating strings with user input: use parameterized queries. That is what prevents SQL injection. + +## Comments + +Use `--` comments for the reason behind a non-obvious condition. + +## Automated checks + +| Rule | Level | Meaning | +|------|-------|---------| +| `SQL001` | error | `SELECT *` | +| `SQL002` | error | comma join | +| `SQL003` | warning | lowercase keywords | + +SQL solutions are linted and structurally validated but not executed yet: there is no SQL test runner. + +## Goal + +SQL solutions should be precise, readable, and independent of accidental row order. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..377e31c --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,82 @@ +# Testing and Verification + +Nothing should reach Git without passing the same checks that CI runs. One command does it: + +```bash +python scripts/check_all.py +``` + +Requirements: Python 3.9+ and, for the languages you use, `g++`, `gcc`, `node`, and `rustc`. A missing toolchain is reported as a skip, never silently ignored. + +## What the gate runs + +| Step | Script | What it verifies | +|------|--------|------------------| +| 1. Script unit tests | `scripts/tests/` | the tooling itself, the templates, and that every lint rule is documented | +| 2. Structure | `scripts/validate_structure.py` | folder naming, metadata fields and values, status coherence, language policy, test-case pairs | +| 3. Lint | `scripts/lint_solutions.py` | the mechanical rules of [Coding Standards](coding-standards.md) | +| 4. Solutions | `scripts/run_tests.py` | builds every implementation and runs it against its test cases | + +## Test case convention + +Test cases belong to the **problem**, not to a language, so every implementation runs the same ones: + +```text +problems/3400-3499/3484-divided-class/ +└── tests/ + ├── sample-1.in input given to the program + ├── sample-1.out exact expected output + ├── edge-single.in + └── edge-single.out +``` + +- Every `.in` has a matching `.out`, and the other way around. +- A problem with `Status: Solved` needs at least one case (SQL excepted for now). +- Naming: `sample-N` for the examples shown by the judge, `edge-` for boundaries (smallest and largest input, sorted, reversed, duplicates), `random-` for cross-checked generated cases. +- Keep cases small. The judge's examples are tiny and may be kept; prefer your own cases for everything else. +- Expected outputs for larger cases should come from an independent brute-force oracle, not from the solution under test. Keep the oracle outside the repository, unless it is a documented secondary implementation (see [Language Policy](language-policy.md)). + +## Comparison rules + +`run_tests.py` compares output the way a judge does: + +- line endings are normalized and trailing blank lines at the very end are ignored; +- everything else is exact, including trailing spaces inside a line (`--lenient` relaxes only that); +- a non-zero exit code is a runtime error, and exceeding `--timeout` (5 s by default) is a time-limit failure. + +Binaries are built in a temporary folder; nothing is written into the repository. + +## Running parts of the gate + +```bash +python scripts/check_all.py --staged # only problems with staged changes +python scripts/check_all.py --since origin/main # only problems changed on this branch +python scripts/check_all.py --all --strict # everything, warnings fail too + +python scripts/validate_structure.py [problem-folder] +python scripts/lint_solutions.py [file ...] +python scripts/run_tests.py [problem-folder] --lang cpp --sanitize --werror +``` + +`--sanitize` builds C and C++ with AddressSanitizer and UndefinedBehaviorSanitizer. `--werror` turns compiler and interpreter warnings into failures. `--require-toolchains` makes a missing compiler fail the run (CI uses it). + +## Git hooks + +Enable the hooks once per clone: + +```bash +git config core.hooksPath scripts/hooks +``` + +- **pre-commit** runs the gate for the problems with staged changes (fast). +- **pre-push** runs it for everything changed since `origin/main`, with sanitizers and warnings-as-errors. + +On Windows, mark the hooks executable in the index once: `git update-index --chmod=+x scripts/hooks/pre-commit scripts/hooks/pre-push`. + +A hook can be bypassed with `--no-verify`, which is why CI repeats the full gate on every push and pull request (`.github/workflows/ci.yml`). Extra flags for one command: `CHECK_ARGS=--strict git commit`. + +## Before opening a pull request + +1. `python scripts/check_all.py` is green. +2. Trackers and indexes are updated (`trackers/solved-problems.md`, `docs/indexes/`). +3. The pull request follows the template and the commit messages follow the conventions in [Git Workflow](git-workflow.md). diff --git a/docs/workflow.md b/docs/workflow.md index 037c6df..f9dad83 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -8,7 +8,7 @@ 4. Think about the brute-force baseline 5. Search for the cleanest acceptable approach 6. Implement with clarity -7. Test with small custom cases +7. Test with the samples, edge cases, and a brute-force cross-check (see `testing.md`), then run `python scripts/check_all.py` 8. Submit to the platform 9. Document the solution at the correct level 10. Register the problem in the trackers @@ -37,7 +37,9 @@ After solving a problem, choose one documentation level: A problem should have one main documentation file. -Multiple implementations may exist inside `solutions/`, but the conceptual explanation remains centralized. +Each problem has one primary language, chosen for what it trains (see `language-policy.md`). Extra implementations exist only with a stated reason, and the conceptual explanation remains centralized. + +`Status: Solved` is used only after the judge accepts the submission. ## Review Rule From 6c72614ca8d42cdfb940dbaaeba93fbaa0d035dc Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Tue, 6 Oct 2026 15:53:27 -0300 Subject: [PATCH 3/6] feat(problem-3484): add verified C++ solution with test cases, mark In Progress --- .../3400-3499/3484-divided-class/README.md | 5 +- .../3484-divided-class/solutions/cpp/main.cpp | 57 +++++++++++++++++++ .../solutions/python/main.py | 0 .../3484-divided-class/tests/edge-balanced.in | 2 + .../tests/edge-balanced.out | 3 + .../tests/edge-decreasing.in | 2 + .../tests/edge-decreasing.out | 5 ++ .../tests/edge-increasing.in | 2 + .../tests/edge-increasing.out | 5 ++ .../tests/edge-max-n-random.in | 2 + .../tests/edge-max-n-random.out | 16 ++++++ .../3484-divided-class/tests/edge-single.in | 2 + .../3484-divided-class/tests/edge-single.out | 1 + .../3484-divided-class/tests/sample-1.in | 2 + .../3484-divided-class/tests/sample-1.out | 4 ++ .../3484-divided-class/tests/sample-2.in | 2 + .../3484-divided-class/tests/sample-2.out | 3 + .../3484-divided-class/tests/sample-3.in | 2 + .../3484-divided-class/tests/sample-3.out | 2 + 19 files changed, 114 insertions(+), 3 deletions(-) delete mode 100644 problems/3400-3499/3484-divided-class/solutions/python/main.py create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-balanced.in create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-balanced.out create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-decreasing.in create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-decreasing.out create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-increasing.in create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-increasing.out create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-max-n-random.in create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-max-n-random.out create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-single.in create mode 100644 problems/3400-3499/3484-divided-class/tests/edge-single.out create mode 100644 problems/3400-3499/3484-divided-class/tests/sample-1.in create mode 100644 problems/3400-3499/3484-divided-class/tests/sample-1.out create mode 100644 problems/3400-3499/3484-divided-class/tests/sample-2.in create mode 100644 problems/3400-3499/3484-divided-class/tests/sample-2.out create mode 100644 problems/3400-3499/3484-divided-class/tests/sample-3.in create mode 100644 problems/3400-3499/3484-divided-class/tests/sample-3.out diff --git a/problems/3400-3499/3484-divided-class/README.md b/problems/3400-3499/3484-divided-class/README.md index 8b449f3..9756791 100644 --- a/problems/3400-3499/3484-divided-class/README.md +++ b/problems/3400-3499/3484-divided-class/README.md @@ -11,9 +11,9 @@ https://judge.beecrowd.com/pt/problems/view/3484 - Difficulty Level: 2 - Topics: binary search tree, tree depth, simulation - Primary Language: C++ -- Other Implementations: Python +- Other Implementations: None - Documentation Level: L2 -- Status: Solved +- Status: In Progress - Review Priority: Medium ## Summary @@ -75,7 +75,6 @@ Given that `N < 250`, this is more than enough. ## Implementations - C++: `solutions/cpp/main.cpp` -- Python: `solutions/python/main.py` ## Status diff --git a/problems/3400-3499/3484-divided-class/solutions/cpp/main.cpp b/problems/3400-3499/3484-divided-class/solutions/cpp/main.cpp index e69de29..709b759 100644 --- a/problems/3400-3499/3484-divided-class/solutions/cpp/main.cpp +++ b/problems/3400-3499/3484-divided-class/solutions/cpp/main.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include + +struct Node { + int height; + std::unique_ptr left; + std::unique_ptr right; + + explicit Node(int h) : height(h) {} +}; + +// Walks down from the root following the problem's rule (shorter goes left, +// taller goes right) until it finds an empty slot, places the new node there +// and returns the depth of that slot. +int insertStudent(std::unique_ptr& root, int height) { + std::unique_ptr* slot = &root; + int level = 0; + + while (*slot) { + slot = (height < (*slot)->height) ? &(*slot)->left : &(*slot)->right; + ++level; + } + + *slot = std::make_unique(height); + return level; +} + +int main() { + int n; + std::cin >> n; + + std::unique_ptr root; + std::vector minAtLevel(n, -1); // -1 = level not reached yet + int levelCount = 0; + + for (int i = 0; i < n; ++i) { + int height; + std::cin >> height; + + int level = insertStudent(root, height); + + // level <= i, because every insertion adds exactly one node, so the + // index is always inside the vector of size n. + if (minAtLevel[level] == -1 || height < minAtLevel[level]) { + minAtLevel[level] = height; + } + levelCount = std::max(levelCount, level + 1); + } + + for (int level = 0; level < levelCount; ++level) { + std::cout << level << ' ' << minAtLevel[level] << '\n'; + } + + return 0; +} diff --git a/problems/3400-3499/3484-divided-class/solutions/python/main.py b/problems/3400-3499/3484-divided-class/solutions/python/main.py deleted file mode 100644 index e69de29..0000000 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-balanced.in b/problems/3400-3499/3484-divided-class/tests/edge-balanced.in new file mode 100644 index 0000000..cc778f5 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-balanced.in @@ -0,0 +1,2 @@ +7 +100 50 150 25 75 125 175 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-balanced.out b/problems/3400-3499/3484-divided-class/tests/edge-balanced.out new file mode 100644 index 0000000..54eade5 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-balanced.out @@ -0,0 +1,3 @@ +0 100 +1 50 +2 25 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-decreasing.in b/problems/3400-3499/3484-divided-class/tests/edge-decreasing.in new file mode 100644 index 0000000..07b574c --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-decreasing.in @@ -0,0 +1,2 @@ +5 +100 90 80 70 60 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-decreasing.out b/problems/3400-3499/3484-divided-class/tests/edge-decreasing.out new file mode 100644 index 0000000..6ffb8be --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-decreasing.out @@ -0,0 +1,5 @@ +0 100 +1 90 +2 80 +3 70 +4 60 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-increasing.in b/problems/3400-3499/3484-divided-class/tests/edge-increasing.in new file mode 100644 index 0000000..f21ba8e --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-increasing.in @@ -0,0 +1,2 @@ +5 +60 70 80 90 100 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-increasing.out b/problems/3400-3499/3484-divided-class/tests/edge-increasing.out new file mode 100644 index 0000000..2ef71ce --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-increasing.out @@ -0,0 +1,5 @@ +0 60 +1 70 +2 80 +3 90 +4 100 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-max-n-random.in b/problems/3400-3499/3484-divided-class/tests/edge-max-n-random.in new file mode 100644 index 0000000..b16f13f --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-max-n-random.in @@ -0,0 +1,2 @@ +249 +221 202 148 252 291 198 203 75 82 261 155 201 98 127 107 243 106 246 62 268 298 145 157 288 274 116 175 133 66 128 76 196 295 300 293 282 90 208 250 251 167 141 284 166 192 71 70 170 262 123 151 249 124 87 52 245 187 276 260 158 215 100 172 160 126 111 117 108 218 165 78 99 299 122 204 222 54 104 181 73 51 154 59 227 95 248 240 74 207 142 180 217 199 290 110 235 103 189 209 81 105 185 216 219 255 279 135 292 232 58 177 159 247 65 270 206 229 264 259 188 112 149 80 212 115 269 174 225 102 84 289 162 63 121 136 132 96 214 152 140 257 120 271 294 183 86 297 57 194 236 280 263 55 283 89 254 241 210 277 153 191 79 150 213 186 77 233 147 265 119 230 273 285 64 69 97 173 53 220 281 92 253 72 223 226 184 144 256 182 179 234 242 239 266 200 113 101 109 224 178 56 296 94 118 267 67 287 125 130 228 171 161 138 286 163 139 258 211 91 134 278 88 146 238 275 68 205 231 272 164 83 156 193 143 197 176 131 195 60 168 190 114 129 85 61 137 244 50 237 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-max-n-random.out b/problems/3400-3499/3484-divided-class/tests/edge-max-n-random.out new file mode 100644 index 0000000..69ec944 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-max-n-random.out @@ -0,0 +1,16 @@ +0 221 +1 202 +2 148 +3 75 +4 62 +5 52 +6 51 +7 50 +8 58 +9 57 +10 55 +11 56 +12 102 +13 101 +14 119 +15 118 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-single.in b/problems/3400-3499/3484-divided-class/tests/edge-single.in new file mode 100644 index 0000000..890e480 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-single.in @@ -0,0 +1,2 @@ +1 +77 diff --git a/problems/3400-3499/3484-divided-class/tests/edge-single.out b/problems/3400-3499/3484-divided-class/tests/edge-single.out new file mode 100644 index 0000000..dbb7f85 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/edge-single.out @@ -0,0 +1 @@ +0 77 diff --git a/problems/3400-3499/3484-divided-class/tests/sample-1.in b/problems/3400-3499/3484-divided-class/tests/sample-1.in new file mode 100644 index 0000000..248dcdd --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/sample-1.in @@ -0,0 +1,2 @@ +9 +130 120 140 115 125 135 145 142 138 diff --git a/problems/3400-3499/3484-divided-class/tests/sample-1.out b/problems/3400-3499/3484-divided-class/tests/sample-1.out new file mode 100644 index 0000000..3536a3f --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/sample-1.out @@ -0,0 +1,4 @@ +0 130 +1 120 +2 115 +3 138 diff --git a/problems/3400-3499/3484-divided-class/tests/sample-2.in b/problems/3400-3499/3484-divided-class/tests/sample-2.in new file mode 100644 index 0000000..659ca59 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/sample-2.in @@ -0,0 +1,2 @@ +3 +100 120 115 diff --git a/problems/3400-3499/3484-divided-class/tests/sample-2.out b/problems/3400-3499/3484-divided-class/tests/sample-2.out new file mode 100644 index 0000000..daa06b7 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/sample-2.out @@ -0,0 +1,3 @@ +0 100 +1 120 +2 115 diff --git a/problems/3400-3499/3484-divided-class/tests/sample-3.in b/problems/3400-3499/3484-divided-class/tests/sample-3.in new file mode 100644 index 0000000..7472d48 --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/sample-3.in @@ -0,0 +1,2 @@ +3 +115 120 110 diff --git a/problems/3400-3499/3484-divided-class/tests/sample-3.out b/problems/3400-3499/3484-divided-class/tests/sample-3.out new file mode 100644 index 0000000..9a74a8c --- /dev/null +++ b/problems/3400-3499/3484-divided-class/tests/sample-3.out @@ -0,0 +1,2 @@ +0 115 +1 110 From 4ed1787a57dd2e347cbc871c5477eea58e74ef78 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Tue, 6 Oct 2026 15:55:00 -0300 Subject: [PATCH 4/6] chore(repo): wire CI, dependabot, and update root docs/templates for the six-language policy --- .editorconfig | 2 +- .github/README-maintenance.md | 7 ++++- .github/dependabot.yml | 9 ++++++ .github/pull_request_template.md | 2 ++ .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++ .gitignore | 4 +++ CONTRIBUTING.md | 2 ++ README.md | 26 ++++++++++++---- templates/problem-metadata-template.md | 11 ++++++- templates/problem-readme-l1.md | 2 +- templates/problem-readme-l2.md | 2 +- templates/solutions/main.c | 11 +++++++ templates/solutions/main.cpp | 15 +++++++++ templates/solutions/main.js | 6 ++++ templates/solutions/main.py | 11 +++++++ templates/solutions/main.rs | 20 ++++++++++++ templates/solutions/main.sql | 1 + trackers/backlog.md | 18 ++++++++--- 18 files changed, 176 insertions(+), 16 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 templates/solutions/main.c create mode 100644 templates/solutions/main.cpp create mode 100644 templates/solutions/main.js create mode 100644 templates/solutions/main.py create mode 100644 templates/solutions/main.rs create mode 100644 templates/solutions/main.sql diff --git a/.editorconfig b/.editorconfig index 51ec3ed..34db96c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,5 +11,5 @@ indent_size = 2 [*.md] trim_trailing_whitespace = false -[*.{cpp,c,h,hpp,py,js}] +[*.{cpp,c,h,hpp,py,js,rs}] indent_size = 4 \ No newline at end of file diff --git a/.github/README-maintenance.md b/.github/README-maintenance.md index f9d3c0e..e254b46 100644 --- a/.github/README-maintenance.md +++ b/.github/README-maintenance.md @@ -90,10 +90,15 @@ Each problem should follow the established repository conventions: - physical organization by ID range - one main documentation file per problem -- multiple implementations under `solutions/` +- one primary language per problem; extra implementations only with a stated reason +- test cases under `tests/`, shared by all implementations - optional `notes.md` - optional `deep-dive.md` +## Verification Before Any Git Update + +Run `python scripts/check_all.py` (see `docs/testing.md`). Enable the Git hooks once per clone with `git config core.hooksPath scripts/hooks`. CI repeats the full gate on every push and pull request. + ## Index and Tracker Maintenance Whenever relevant, update: diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7aaa94b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# Keeps the GitHub Actions used by CI up to date (supply-chain hygiene). +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "chore(ci)" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3c520f9..e660aee 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -38,6 +38,8 @@ Explain why this change improves the repository. - [ ] Public-facing content is written in English - [ ] Naming follows the established conventions - [ ] Documentation is aligned with the correct level (L1, L2, or L3) +- [ ] `python scripts/check_all.py` passes (structure, lint, and test cases) +- [ ] `Status: Solved` is used only for submissions accepted by the judge - [ ] Links and paths were checked - [ ] Trackers/indexes were updated when necessary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..658d0c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Least privilege: the checks only need to read the repository. +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Validate, lint and test + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Toolchain versions + run: | + g++ --version | head -n 1 + gcc --version | head -n 1 + rustc --version + node --version + python --version + + - name: Run every check + run: python scripts/check_all.py --all --sanitize --werror --require-toolchains --github diff --git a/.gitignore b/.gitignore index 81ee4e3..ddf8952 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ venv/ # C / C++ *.out +!problems/**/tests/*.out *.exe *.o *.obj @@ -22,6 +23,9 @@ venv/ *.dll build/ +# Rust +target/ + # JavaScript node_modules/ dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68c786d..8b55210 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,8 @@ Please read: - `docs/repository-conventions.md` - `docs/git-workflow.md` - `docs/documentation-standard.md` +- `docs/coding-standards.md` +- `docs/testing.md` ## Main principle diff --git a/README.md b/README.md index 5879c34..f004a36 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,14 @@ This repository is part of my long-term technical growth and public portfolio. I ## Main Languages -- C++17 / C++20 as the primary problem-solving language -- Python as a secondary language for clarity and speed -- JavaScript as a complementary language aligned with my professional stack -- C for fundamentals and low-level reasoning +Each problem has **one primary language**, chosen for what the problem trains (see `docs/language-policy.md`): + +- C++17 / C++20 as the default problem-solving language +- C for memory, bits, and low-level reasoning +- Rust for ownership and memory safety, as the counterpart of C +- Python for clarity, strings, and quick prototypes +- JavaScript for parsing and text processing, aligned with my professional stack +- SQL for the SQL category ## Repository Structure @@ -25,7 +29,7 @@ This repository is part of my long-term technical growth and public portfolio. I - `docs/` → philosophy, workflow, standards, references, and language guides - `trackers/` → progress tracking and reviews - `templates/` → reusable documentation templates -- `scripts/` → automation helpers for repository maintenance +- `scripts/` → automation helpers: validation, linting, test runner, Git hooks ## Documentation Levels @@ -33,7 +37,7 @@ This repository uses a layered documentation model: - **L1 — Quick Log**: short documentation for straightforward problems - **L2 — Standard**: structured explanation for relevant problems -- **L2 — Deep Dive**: deeper analysis for hard, elegant, or high-value problems +- **L3 — Deep Dive**: deeper analysis for hard, elegant, or high-value problems Not every problem needs the same depth. The goal is not bureaucratic documentation – the goal is sustainable excellence. @@ -55,6 +59,16 @@ The absence of a license file is intentional. Public visibility does not mean th Problem statements, titles, and platform references belong to their respective owners. This repository contains my own implementations, notes, and interpretations for educational and portfolio purposes. +## Quality Gate + +Every change is verified before it reaches Git, locally and in CI: + +```bash +python scripts/check_all.py +``` + +See `docs/testing.md` for what it checks and how to enable the Git hooks. + # Status This repository is being built incrementally, with careful structure, documentation, and selective migration from older problem-solving repositories. \ No newline at end of file diff --git a/templates/problem-metadata-template.md b/templates/problem-metadata-template.md index db9fb5f..4263a05 100644 --- a/templates/problem-metadata-template.md +++ b/templates/problem-metadata-template.md @@ -9,4 +9,13 @@ - Other Implementations: - Documentation Level: - Status: -- Review Priority: \ No newline at end of file +- Review Priority: + +Allowed values (checked by `scripts/validate_structure.py`): + +- Category: Beginner, Ad-Hoc, Strings, Data Structures and Libraries, Mathematics, Paradigms, Graphs, Computational Geometry, SQL +- Difficulty Level: integer from 1 to 10 +- Primary Language / Other Implementations: C++, C, Python, JavaScript, Rust, SQL (`None` when there is no other implementation) +- Documentation Level: L1, L2, L3 +- Status: Solved (accepted by the judge), In Progress, Improving, To Review +- Review Priority: Low, Medium, High \ No newline at end of file diff --git a/templates/problem-readme-l1.md b/templates/problem-readme-l1.md index c18e2c5..d3a24f0 100644 --- a/templates/problem-readme-l1.md +++ b/templates/problem-readme-l1.md @@ -11,7 +11,7 @@ - Difficulty Level: {difficulty-level} - Topics: {topic-1}, {topic-2} - Primary Language: {primary-language} -- Other Implementations: {other-implementations} +- Other Implementations: None - Documentation Level: L1 - Status: Solved - Review Priority: {low-medium-high} diff --git a/templates/problem-readme-l2.md b/templates/problem-readme-l2.md index c871ba5..fc0f41b 100644 --- a/templates/problem-readme-l2.md +++ b/templates/problem-readme-l2.md @@ -11,7 +11,7 @@ - Difficulty Level: {difficulty-level} - Topics: {topic-1}, {topic-2} - Primary Language: {primary-language} -- Other Implementations: {other-implementations} +- Other Implementations: None - Documentation Level: L2 - Status: Solved - Review Priority: {low-medium-high} diff --git a/templates/solutions/main.c b/templates/solutions/main.c new file mode 100644 index 0000000..1099fc2 --- /dev/null +++ b/templates/solutions/main.c @@ -0,0 +1,11 @@ +#include + +int main(void) { + int n; + if (scanf("%d", &n) != 1) { + return 0; /* empty or malformed input */ + } + + printf("%d\n", n); + return 0; +} diff --git a/templates/solutions/main.cpp b/templates/solutions/main.cpp new file mode 100644 index 0000000..dd0d99a --- /dev/null +++ b/templates/solutions/main.cpp @@ -0,0 +1,15 @@ +#include + +int main() { + // Only for large inputs (and say why in a comment): + // std::ios::sync_with_stdio(false); + // std::cin.tie(nullptr); + + int n; + if (!(std::cin >> n)) { + return 0; // empty input + } + + std::cout << n << '\n'; + return 0; +} diff --git a/templates/solutions/main.js b/templates/solutions/main.js new file mode 100644 index 0000000..4a8313d --- /dev/null +++ b/templates/solutions/main.js @@ -0,0 +1,6 @@ +"use strict"; + +const tokens = require("fs").readFileSync(0, "utf8").split(/\s+/).filter(Boolean); +const n = Number(tokens[0]); + +console.log(n); diff --git a/templates/solutions/main.py b/templates/solutions/main.py new file mode 100644 index 0000000..83bddf2 --- /dev/null +++ b/templates/solutions/main.py @@ -0,0 +1,11 @@ +import sys + + +def main() -> None: + data = sys.stdin.read().split() + n = int(data[0]) + print(n) + + +if __name__ == "__main__": + main() diff --git a/templates/solutions/main.rs b/templates/solutions/main.rs new file mode 100644 index 0000000..c267b18 --- /dev/null +++ b/templates/solutions/main.rs @@ -0,0 +1,20 @@ +use std::io::{self, BufWriter, Read, Write}; + +fn main() { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .expect("failed to read stdin"); + let mut tokens = input.split_ascii_whitespace(); + + let stdout = io::stdout(); + let mut out = BufWriter::new(stdout.lock()); + + let n: i64 = tokens + .next() + .expect("missing n") + .parse() + .expect("n is not an integer"); + + writeln!(out, "{n}").expect("failed to write stdout"); +} diff --git a/templates/solutions/main.sql b/templates/solutions/main.sql new file mode 100644 index 0000000..475d028 --- /dev/null +++ b/templates/solutions/main.sql @@ -0,0 +1 @@ +SELECT 1 AS answer; diff --git a/trackers/backlog.md b/trackers/backlog.md index dc1f2c2..400b5b1 100644 --- a/trackers/backlog.md +++ b/trackers/backlog.md @@ -8,21 +8,29 @@ This file is the single prioritized TODO for the repository. Sections are ordere - [x] finalize style guides - [x] finalize templates - [x] create first problem-model folder (3484) -- [ ] resolve the 3484 inconsistency: `solutions/cpp/main.cpp` is empty and `solutions/python/main.py` is incomplete while the README claims `Status: Solved`. Either finish both implementations or change the status to reflect reality +- [x] resolve the 3484 inconsistency: C++ solution written and verified locally, Python implementation dropped (one primary language per problem, see `docs/language-policy.md`), status set to `In Progress` until the judge accepts the submission - [ ] register 3484 in `trackers/solved-problems.md` and the four `docs/indexes/*.md` files once it is genuinely solved ## 2. Automation scripts (empty stubs today, needed before scaling migration) - [ ] implement `scripts/new_problem.py`: scaffolds a problem folder (README from the right L1/L2/L3 template, `notes.md`, `solutions//main.`) from the problem ID, title, and category -- [ ] implement `scripts/validate_structure.py`: fails if a `solutions/*/main.*` file is empty, a metadata field is missing, checkbox syntax is malformed, or a problem folder doesn't match `-` naming +- [x] implement `scripts/validate_structure.py`: fails if a `solutions/*/main.*` file is empty, a metadata field is missing, checkbox syntax is malformed, or a problem folder doesn't match `-` naming - [ ] implement `scripts/sync_index.py`: regenerates `trackers/solved-problems.md` and `docs/indexes/*.md` from each problem's metadata block, so indexes can never drift from reality again - [ ] implement `scripts/generate_views.py`: optional dashboard/summary view (counts by language, category, difficulty) +## 2b. Test tooling and Git gates + +- [x] `scripts/run_tests.py`: builds and runs every solution against `tests/*.in|out` +- [x] `scripts/lint_solutions.py`: mechanical style and safety rules from `docs/coding-standards.md` +- [x] `scripts/check_all.py` plus `scripts/hooks/pre-commit` and `pre-push`: one command before any Git update +- [ ] confirm Rust and SQL availability on the judge, then add Rust to the supported submission languages +- [ ] define the SQL engine and a SQL test runner + ## 3. CI and repository security posture (AppSec-aligned, high signal for the portfolio goal) -- [ ] add a GitHub Actions workflow that compiles every `main.cpp`/`main.c` and runs every `main.py`/`main.js` on push/PR -- [ ] decide and document a convention for sample input/output per problem (e.g. `solutions//sample.in` + `sample.out`) so CI can assert actual correctness, not just "it compiles" -- [ ] wire `scripts/validate_structure.py` into the same CI workflow as a required check +- [x] add a GitHub Actions workflow that compiles every `main.cpp`/`main.c`/`main.rs` and runs every `main.py`/`main.js` on push/PR (`.github/workflows/ci.yml`) +- [x] decide and document a convention for sample input/output per problem: `tests/.in` + `tests/.out`, shared by every implementation (see `docs/testing.md`) +- [x] wire `scripts/validate_structure.py` into the same CI workflow as a required check (via `scripts/check_all.py`) - [ ] enable branch protection on `main`: require PRs, require the CI check to pass before merge - [ ] enable GitHub secret scanning + push protection (free on public repos) - [ ] add a CodeQL analysis workflow (free on public repos, covers C/C++/Python/JS), directly reinforcing the AppSec specialization narrative From 733bff55efae87ce5606c8256569fd5338343b92 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Tue, 6 Oct 2026 16:05:31 -0300 Subject: [PATCH 5/6] fix(scripts): skip sanitizer builds and tolerate exe cleanup races on Windows --- scripts/hooks/pre-commit | 0 scripts/hooks/pre-push | 0 scripts/run_tests.py | 16 +++++++++++++++- scripts/tests/test_run_tests.py | 2 ++ 4 files changed, 17 insertions(+), 1 deletion(-) mode change 100644 => 100755 scripts/hooks/pre-commit mode change 100644 => 100755 scripts/hooks/pre-push diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit old mode 100644 new mode 100755 diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push old mode 100644 new mode 100755 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a0c9ef4..67a9823 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -70,6 +70,17 @@ def _exe(workdir: Path) -> Path: return workdir / ("main.exe" if os.name == "nt" else "main") +def _missing_sanitizer_runtime(stderr: str) -> bool: + """True when the linker failed only because libasan/libubsan are not installed. + + Some MinGW-w64 builds on Windows ship the compiler flags but not the + sanitizer runtime libraries. That is an environment gap, not a defect in + the program being compiled, so it should be a skip, like a missing + compiler, rather than a build failure. + """ + return "cannot find -lasan" in stderr or "cannot find -lubsan" in stderr + + def _compile(command: list[str], exe: Path) -> Prepared: try: done = subprocess.run(command, capture_output=True, text=True, timeout=COMPILE_TIMEOUT_SECONDS) @@ -96,7 +107,10 @@ def prepare(folder: str, source: Path, workdir: Path, options: Options) -> Prepa flags += SANITIZE_FLAGS if options.werror: flags.append("-Werror") - return _compile([compiler, *flags, str(source), "-o", str(exe)], exe) + result = _compile([compiler, *flags, str(source), "-o", str(exe)], exe) + if options.sanitize and result.error and _missing_sanitizer_runtime(result.error): + return Prepared(skip_reason="ASan/UBSan runtime not installed for this compiler", missing_tool=True) + return result if folder == "rust": if shutil.which("rustc") is None: diff --git a/scripts/tests/test_run_tests.py b/scripts/tests/test_run_tests.py index 05b126d..d32fc6f 100644 --- a/scripts/tests/test_run_tests.py +++ b/scripts/tests/test_run_tests.py @@ -105,6 +105,8 @@ def test_sanitizer_catches_out_of_bounds(self) -> None: source = "#include \n#include \nint main() { std::vector v(3); std::cout << v[std::cin.get() == 'x' ? 3 : 10] << '\\n'; }\n" problem = make_problem(self.root, folder="cpp", source=source, cases={"c": ("x", "0\n")}) (result,) = self.run_problem(problem, sanitize=True) + if result.missing_tool: + self.skipTest("ASan/UBSan runtime not installed for this compiler") self.assertEqual(result.status, "FAIL") @unittest.skipUnless(have("gcc"), "gcc not installed") From 126e26997bf4dec7a05074cb5e19c2bb5ff271d3 Mon Sep 17 00:00:00 2001 From: Felipe Sousa Date: Tue, 6 Oct 2026 16:32:02 -0300 Subject: [PATCH 6/6] docs(repo): add development setup guide for Windows and Linux --- docs/setup.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/setup.md diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..997707e --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,43 @@ +# Development Setup + +Verify everything works after setup: + +```bash +python scripts/check_all.py --all +``` + +## Required tools + +- Python 3.9+ +- g++ / gcc (C++17/20 and C17) +- Node.js (JavaScript solutions) +- Rust via `rustup` (optional, local study only; see [Language Policy](language-policy.md)) +- Git and the [GitHub CLI](https://cli.github.com/) (`gh`) + +## Windows + +```powershell +choco install mingw -y +winget install -e --id Python.Python.3.12 +winget install -e --id OpenJS.NodeJS.LTS +winget install -e --id Rustlang.Rustup +``` + +Open a **new** terminal window afterward: PATH changes do not reach windows already open. + +## Linux (Debian/Ubuntu) + +``` +sudo apt update +sudo apt install -y build-essential python3 nodejs npm +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +## One-time per clone + +``` +git config core.hooksPath scripts/hooks +git update-index --chmod=+x scripts/hooks/pre-commit scripts/hooks/pre-push +``` + +See [Testing](testing.md) for what each check does, and [Coding Standards](coding-standards.md) for the rules they enforce. \ No newline at end of file