diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ca5668b65 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This repository stores LeetCode solutions, local runners, and code-generation tooling across languages. Problem folders live under `problems/problems_/` and usually contain `problem.md`, `problem_zh.md`, `solution.*`, `Solution.*`, `solution.md`, and testcase files. Shared helpers are in `python/`, `golang/`, `typescript/`, `cpp/`, and `rust/`; algorithm notes are in `algorithm_templates/`; metadata is in `data/`; automated tests are in `tests/`. Multithreading problems use `multi_threading//`. + +## Build, Test, and Development Commands + +- `make verify`: run stable local checks across Python codegen/unit tests, TypeScript smoke tests, and Go helper packages. +- `make health`: scan generated problem folders for missing docs, testcases, links, solutions, and Rust workspace drift. +- `make test`: run the full Python pytest suite with `PYTHONPATH=.`. +- `make test-unit`: run fast unit tests marked `unit`. +- `make test-integration`: run integration tests that may require language runtimes. +- `make test-codegen`: validate code generation behavior. +- `make test-coverage`: run pytest with terminal and HTML coverage reports. +- `npm test`: run the full TypeScript/Jest suite through `ts-jest`. +- `make test-go-libs`: run stable Go helper package tests. +- `python python/scripts/leetcode.py`: launch the interactive LeetCode helper for fetching/submitting problems. + +Use `make help` for the maintained command list. + +## Coding Style & Naming Conventions + +Follow the target language style and existing generated files. Python uses pytest-compatible modules and 4-space indentation. Go code should be `gofmt` formatted. TypeScript uses lowercase filenames in `typescript/` and Jest tests such as `debug.test.ts`. Problem directories use `problems_`, with solution names like `solution.py`, `solution.go`, `solution.ts`, `solution.rs`, `Solution.cpp`, and `Solution.java`. + +## Testing Guidelines + +Pytest discovers `test_*.py` and `*_test.py` under `tests/`; test classes start with `Test`, and test functions start with `test_`. Use markers from `pytest.ini`: `unit`, `integration`, `codegen`, and `slow`. For problem-level validation, run `make test-daily`, `make test-problems`, or the relevant language command. Add focused tests when changing code generation, language writers, shared models, or testcase parsing. + +## Commit & Pull Request Guidelines + +Recent commits use short prefixes such as `test:` and `fix:`; examples include `test: 3742 solution`, `test: [20260430] Add (3742)`, and `fix: 题解链接bug`. Keep messages scoped to one change. Pull requests should summarize changed problems or tooling, list test commands run, mention required environment variables, and link related issues when applicable. + +## Security & Configuration Tips + +Do not commit `.env`, LeetCode cookies, PushDeer keys, or other secrets. Required local values include `COOKIE`, `PROBLEM_FOLDER`, `LANGUAGES`, `LEETCODE_USER`, and `PYTHONPATH=.`; see `README.md` for an example configuration. diff --git a/Cargo.toml b/Cargo.toml index 294afe1fe..841817a7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -419,7 +419,6 @@ members = [ "problems/problems_Interview_16__02", "problems/problems_944", "problems/problems_Interview_08__02", - "problems/problems_Interview_16__03", "problems/problems_955", "problems/problems_Interview_02__01", "problems/problems_Interview_10__01", @@ -547,6 +546,15 @@ members = [ "problems/problems_3225", "problems/problems_3742", "problems/problems_396", + "problems/problems_3655", + "problems/problems_2069", + "problems/problems_3653", + "problems/problems_3661", + "problems/problems_3740", + "problems/problems_874", + "problems/problems_2087", + "problems/problems_3418", + "problems/problems_657", "problems/problems_788", "problems/problems_796", ] @@ -990,7 +998,6 @@ solution_2092 = { path = "problems/problems_2092", features = ["solution_2092"] solution_Interview_16__02 = { path = "problems/problems_Interview_16__02", features = ["solution_Interview_16__02"] } solution_944 = { path = "problems/problems_944", features = ["solution_944"] } solution_Interview_08__02 = { path = "problems/problems_Interview_08__02", features = ["solution_Interview_08__02"] } -solution_Interview_16__03 = { path = "problems/problems_Interview_16__03", features = ["solution_Interview_16__03"] } solution_955 = { path = "problems/problems_955", features = ["solution_955"] } solution_Interview_02__01 = { path = "problems/problems_Interview_02__01", features = ["solution_Interview_02__01"] } solution_Interview_10__01 = { path = "problems/problems_Interview_10__01", features = ["solution_Interview_10__01"] } @@ -1118,5 +1125,14 @@ solution_2033 = { path = "problems/problems_2033", features = ["solution_2033"] solution_3225 = { path = "problems/problems_3225", features = ["solution_3225"] } solution_3742 = { path = "problems/problems_3742", features = ["solution_3742"] } solution_396 = { path = "problems/problems_396", features = ["solution_396"] } +solution_3655 = { path = "problems/problems_3655", features = ["solution_3655"] } +solution_2069 = { path = "problems/problems_2069", features = ["solution_2069"] } +solution_3653 = { path = "problems/problems_3653", features = ["solution_3653"] } +solution_3661 = { path = "problems/problems_3661", features = ["solution_3661"] } +solution_3740 = { path = "problems/problems_3740", features = ["solution_3740"] } +solution_874 = { path = "problems/problems_874", features = ["solution_874"] } +solution_2087 = { path = "problems/problems_2087", features = ["solution_2087"] } +solution_3418 = { path = "problems/problems_3418", features = ["solution_3418"] } +solution_657 = { path = "problems/problems_657", features = ["solution_657"] } solution_788 = { path = "problems/problems_788", features = ["solution_788"] } solution_796 = { path = "problems/problems_796", features = ["solution_796"] } diff --git a/Makefile b/Makefile index 1a6cb020a..55c423b92 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,20 @@ # Makefile for LeetCode project -.PHONY: test test-unit test-integration test-codegen test-coverage clean help +.PHONY: verify health test test-unit test-integration test-codegen test-coverage test-typescript-smoke test-go-libs clean help # Default target help: @echo "Available targets:" + @echo " verify - Run the practical local verification suite" + @echo " health - Check generated problem folder health" @echo " test - Run all tests" @echo " test-unit - Run unit tests only" @echo " test-integration - Run integration tests (requires language runtimes)" @echo " test-codegen - Run code generation tests" @echo " test-coverage - Run tests with coverage report" @echo " test-parallel - Run tests in parallel" + @echo " test-typescript-smoke - Run stable TypeScript smoke tests" + @echo " test-go-libs - Run stable Go helper package tests" @echo " test-daily - Run daily problem test (current problem)" @echo " test-problems - Run multiple problems test" @echo " clean - Clean up temporary files" @@ -20,6 +24,16 @@ help: @echo " add-snippet - Add a problem to snippets (use PROBLEM=id)" @echo " print-snippet - Print original snippet (use PROBLEM=id LANG=lang)" +# Run the practical local verification suite +verify: + PYTHONPATH=. pytest tests/ -m "(unit or codegen) and not slow" -v + npm test -- --runTestsByPath typescript/debug.test.ts + go test ./golang/models ./golang/node_random ./golang/node_neighbours ./golang/tree_next ./golang/double_linked_node_child + +# Check generated problem folder health +health: + PYTHONPATH=. python python/scripts/leetcode.py --health + # Run all unit tests test-unit: PYTHONPATH=. pytest tests/ -m unit -v @@ -44,6 +58,14 @@ test-coverage: test-parallel: PYTHONPATH=. pytest tests/ -v -n auto +# Run stable TypeScript smoke tests +test-typescript-smoke: + npm test -- --runTestsByPath typescript/debug.test.ts + +# Run stable Go helper package tests +test-go-libs: + go test ./golang/models ./golang/node_random ./golang/node_neighbours ./golang/tree_next ./golang/double_linked_node_child + # Run daily problem test test-daily: PYTHONPATH=. python python/test.py diff --git a/python/lc_libs/rust_writer.py b/python/lc_libs/rust_writer.py index f17d2cf78..b9f2ae0b6 100644 --- a/python/lc_libs/rust_writer.py +++ b/python/lc_libs/rust_writer.py @@ -74,7 +74,7 @@ def write_solution( if not RustWriter.is_snake_case(f"{problem_folder}_{problem_id}"): add_title = f"#![allow(non_snake_case)]\n" code = code or code_default - if "object will be instantiated and called as such:" in code: + if "object will be instantiated and called as such:" in code and "impl Solution" not in code: struct_map = RustWriter._parse_rust_structs(code_default) solve_part = RustWriter._generate_solve_function(struct_map) return SOLUTION_TEMPLATE_RUST.format(add_title, "\n".join([]), "", diff --git a/python/scripts/cli/i18n.py b/python/scripts/cli/i18n.py index 2f294eafb..a36abc74c 100644 --- a/python/scripts/cli/i18n.py +++ b/python/scripts/cli/i18n.py @@ -80,9 +80,14 @@ "cookie_continue": "继续使用现有 Cookie...", # Main menu - "main_menu": "请选择功能 [0-9, 默认: 0]:\n0. 退出\n1. 获取题目\n2. 提交代码\n3. 切换测试题目\n4. 比赛\n5. 清理空 Java 文件\n6. 清理错误 Rust 文件\n7. 收藏夹管理\n8. 创建题目链接\n9. 题解中心\n", + "main_menu": "请选择功能 [0-10, 默认: 0]:\n0. 退出\n1. 获取题目\n2. 提交代码\n3. 切换测试题目\n4. 比赛\n5. 清理空 Java 文件\n6. 清理错误 Rust 文件\n7. 收藏夹管理\n8. 创建题目链接\n9. 题解中心\n10. 仓库健康检查\n", "main_exit": "正在退出...", "main_bye": "再见!", + "health_running": "正在检查生成题目目录: {folder}", + "health_fix_header": "可自动修复的问题:", + "health_fix_select": "选择要修复的项目 [例如: 1,2;a 全部;默认跳过]: ", + "health_fix_confirm": "确认执行修复 [{fix}]? [y/n, 默认: n]: ", + "health_fix_skipped": "已跳过", # Get problem "get_menu": "请选择获取题目方式 [0-6, 默认: 0]:\n0. 返回\n1. 每日自动\n2. 指定题目 ID\n3. 随机\n4. 随机未通过\n5. 分类\n6. 比赛\n", @@ -237,9 +242,14 @@ "cookie_continue": "Continuing with existing cookie...", # Main menu - "main_menu": "Please select the main function [0-9, default: 0]:\n0. Exit\n1. Get problem\n2. Submit\n3. Change test problem\n4. Contest\n5. Clean empty java\n6. Clean error rust\n7. Favorite management\n8. Link problems\n9. Solution Center\n", + "main_menu": "Please select the main function [0-10, default: 0]:\n0. Exit\n1. Get problem\n2. Submit\n3. Change test problem\n4. Contest\n5. Clean empty java\n6. Clean error rust\n7. Favorite management\n8. Link problems\n9. Solution Center\n10. Repository health\n", "main_exit": "Exiting...", "main_bye": "Bye!", + "health_running": "Checking generated problem folder: {folder}", + "health_fix_header": "Available automatic fixes:", + "health_fix_select": "Select fixes to apply [e.g. 1,2; a for all; default skips]: ", + "health_fix_confirm": "Apply fix [{fix}]? [y/n, default: n]: ", + "health_fix_skipped": "Skipped", # Get problem "get_menu": "Please select the get problem method [0-6, default: 0]:\n0. Back\n1. Daily auto\n2. Specified problem ID\n3. Random\n4. Random remain\n5. Category\n6. Contest\n", diff --git a/python/scripts/leetcode.py b/python/scripts/leetcode.py index 878b80f6c..311851e46 100644 --- a/python/scripts/leetcode.py +++ b/python/scripts/leetcode.py @@ -50,6 +50,7 @@ from python.scripts.get_problem import main as get_problem_main, get_question_slug_by_id from python.scripts.tools import lucky_main, remain_main, clean_empty_java_main, clean_error_rust_main from python.scripts.fetch_solution_articles import main as fetch_solution_main +from python.scripts.repository_health import scan_repository, format_report, build_fix_suggestions, apply_fix # Constants SEPARATE_LINE = "-" * 50 @@ -837,6 +838,40 @@ def link_problems(problem_folder): print(SEPARATE_LINE) +def repository_health(problem_folder: str): + """Run generated problem repository health checks.""" + print(t("health_running", folder=problem_folder)) + report = scan_repository(root_path, [problem_folder]) + print(format_report(report, root_path)) + fixes = build_fix_suggestions(report, root_path) + if fixes: + print() + print(t("health_fix_header")) + for idx, fix in enumerate(fixes, start=1): + print(f"{idx}. {fix.display(root_path)}") + selection = input_until_valid(t("health_fix_select"), allow_all) + selected_indices: list[int] = [] + if selection.lower() == "a": + selected_indices = list(range(len(fixes))) + else: + for part in selection.split(","): + part = part.strip() + if part.isdigit() and 1 <= int(part) <= len(fixes): + selected_indices.append(int(part) - 1) + for idx in dict.fromkeys(selected_indices): + fix = fixes[idx] + confirm = input_until_valid(t("health_fix_confirm", fix=fix.display(root_path)), allow_all) + if confirm.lower() != "y": + print(t("health_fix_skipped")) + continue + print(apply_fix(fix)) + if selected_indices: + print() + report = scan_repository(root_path, [problem_folder]) + print(format_report(report, root_path)) + print(SEPARATE_LINE) + + def _get_problem_slug_from_id(problem_id: str, cookie: str) -> Optional[str]: """通过题目 ID 获取 problem_slug""" origin_problem_id = back_question_id(problem_id) @@ -1148,12 +1183,24 @@ def main(): parser = argparse.ArgumentParser(description="LeetCode 工具集") parser.add_argument('--en', action='store_true', help='Use English interface') parser.add_argument('--init', action='store_true', help='Force initialization wizard') + parser.add_argument('--health', action='store_true', help='Run repository health checks and exit') + parser.add_argument('--health-folder', default=None, help='Problem folder to scan for --health') args = parser.parse_args() # Set language if args.en: set_language("en") + if args.health: + try: + load_dotenv() + except Exception: + logging.debug("Failed to load .env for health check", exc_info=True) + health_folder = args.health_folder or os.getenv(constant.PROBLEM_FOLDER, "problems") + report = scan_repository(root_path, [health_folder]) + print(format_report(report, root_path)) + return 0 if report.ok else 1 + try: if args.init: languages, problem_folder, cookie, contest_folder = initialize_env() @@ -1190,13 +1237,15 @@ def main(): link_problems(problem_folder) case "9": solution_center(cookie, problem_folder) + case "10": + repository_health(problem_folder) case _: print(t("main_exit")) - break + return 0 except KeyboardInterrupt: print(f"\n{t('main_bye')}") + return 130 if __name__ == '__main__': - main() - sys.exit() + sys.exit(main()) diff --git a/python/scripts/repository_health.py b/python/scripts/repository_health.py new file mode 100644 index 000000000..83aed73b4 --- /dev/null +++ b/python/scripts/repository_health.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +"""Repository health checks for generated LeetCode problem folders.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 fallback is not used in CI. + tomllib = None + + +SOLUTION_FILES = ( + "solution.py", + "solution.go", + "solution.ts", + "solution.rs", + "solution.sql", + "solution.c", + "solution.md", + "Solution.cpp", + "Solution.java", +) + +TESTCASE_FILES = ("testcase.py", "testcase", "input.json") +PROBLEM_DOCS = ("problem.md", "problem_zh.md") +LEGACY_CONTAINER_DIRS = { + "problems": {"Interview", "LCP", "剑指Offer"}, +} +EMPTY_ARTIFACT_DIRS = {"__pycache__", ".pytest_cache"} +EMPTY_ARTIFACT_SUFFIXES = {".pyc", ".pyo"} +EMPTY_ARTIFACT_NAMES = {".DS_Store"} + + +@dataclass(frozen=True) +class HealthIssue: + """A repository health issue found during scanning.""" + + level: str + path: Path + message: str + + def display(self, root_path: Path) -> str: + try: + rel_path = self.path.relative_to(root_path) + except ValueError: + rel_path = self.path + return f"[{self.level.upper()}] {rel_path}: {self.message}" + + +@dataclass(frozen=True) +class HealthReport: + """Aggregated repository health scan result.""" + + scanned: int + issues: tuple[HealthIssue, ...] + + @property + def errors(self) -> tuple[HealthIssue, ...]: + return tuple(issue for issue in self.issues if issue.level == "error") + + @property + def warnings(self) -> tuple[HealthIssue, ...]: + return tuple(issue for issue in self.issues if issue.level == "warning") + + @property + def ok(self) -> bool: + return not self.errors + + +@dataclass(frozen=True) +class HealthFix: + """A conservative fix that can be applied after user confirmation.""" + + action: str + path: Path + message: str + folder: str | None = None + + def display(self, root_path: Path) -> str: + try: + rel_path = self.path.relative_to(root_path) + except ValueError: + rel_path = self.path + return f"{self.message}: {rel_path}" + + +def scan_repository(root_path: Path, folders: Iterable[str] = ("problems",)) -> HealthReport: + """Scan generated problem folders and return a health report.""" + root_path = root_path.resolve() + folder_names = tuple(dict.fromkeys(folders)) + issues: list[HealthIssue] = [] + scanned = 0 + + workspace_members = _load_rust_workspace_members(root_path) + + for folder_name in folder_names: + folder_path = root_path / folder_name + if not folder_path.exists(): + issues.append(HealthIssue("error", folder_path, "folder does not exist")) + continue + if not folder_path.is_dir(): + issues.append(HealthIssue("error", folder_path, "path is not a directory")) + continue + + for problem_path in sorted(p for p in folder_path.iterdir() if p.is_dir()): + if problem_path.name.startswith("."): + continue + if problem_path.name in LEGACY_CONTAINER_DIRS.get(folder_name, set()): + continue + if not _is_generated_problem_dir(problem_path.name, folder_name): + issues.append(HealthIssue( + "warning", + problem_path, + f"directory does not match generated naming pattern {folder_name}_", + )) + continue + scanned += 1 + issues.extend(_scan_problem_dir(root_path, folder_name, problem_path, workspace_members)) + + issues.extend(_scan_missing_rust_workspace_members(root_path, folder_names, workspace_members)) + return HealthReport(scanned=scanned, issues=tuple(issues)) + + +def format_report(report: HealthReport, root_path: Path, limit: int = 50) -> str: + """Format a health report for CLI output.""" + lines = [ + f"Scanned {report.scanned} generated problem directories.", + f"Errors: {len(report.errors)}; warnings: {len(report.warnings)}.", + ] + if not report.issues: + lines.append("No repository health issues found.") + return "\n".join(lines) + + lines.append("") + for issue in report.issues[:limit]: + lines.append(issue.display(root_path)) + remaining = len(report.issues) - limit + if remaining > 0: + lines.append(f"... {remaining} more issue(s) omitted.") + return "\n".join(lines) + + +def build_fix_suggestions(report: HealthReport, root_path: Path) -> tuple[HealthFix, ...]: + """Build conservative fix suggestions from a health report.""" + suggestions: list[HealthFix] = [] + seen_keys: set[tuple[str, str]] = set() + for issue in report.errors: + problem_path = issue.path if issue.path.is_dir() else issue.path.parent + key = ("remove_empty_dir", problem_path.as_posix()) + if key in seen_keys: + continue + if _is_effectively_empty_dir(problem_path): + suggestions.append(HealthFix( + action="remove_empty_dir", + path=problem_path, + message="Remove empty generated problem directory", + )) + seen_keys.add(key) + for issue in report.warnings: + if ( + "Rust solution is not listed in root Cargo.toml" not in issue.message + and "root Cargo.toml workspace member path does not exist" not in issue.message + ): + continue + try: + folder = issue.path.relative_to(root_path).parts[0] + except (IndexError, ValueError): + continue + key = ("sync_rust_cargo", folder) + if key in seen_keys: + continue + suggestions.append(HealthFix( + action="sync_rust_cargo", + path=root_path / "Cargo.toml", + message=f"Sync Rust Cargo workspace entries for {folder}", + folder=folder, + )) + seen_keys.add(key) + return tuple(suggestions) + + +def apply_fix(fix: HealthFix) -> str: + """Apply a confirmed health fix and return a short result message.""" + match fix.action: + case "remove_empty_dir": + if not fix.path.exists(): + return f"Skipped missing path: {fix.path}" + if not fix.path.is_dir(): + return f"Skipped non-directory path: {fix.path}" + if not _is_effectively_empty_dir(fix.path): + return f"Skipped non-empty directory: {fix.path}" + shutil.rmtree(fix.path) + return f"Removed: {fix.path}" + case "sync_rust_cargo": + if not fix.folder: + return "Skipped Rust Cargo sync: missing problem folder" + from python.scripts.tools import sync_rust_cargo_main + result = sync_rust_cargo_main(fix.path.parent, fix.folder) + added = ", ".join(result["added"]) if result["added"] else "none" + removed = ", ".join(result["removed"]) if result["removed"] else "none" + return f"Synced Rust Cargo entries. Added: {added}. Removed stale paths: {removed}." + case _: + return f"Unknown fix action: {fix.action}" + + +def _scan_problem_dir( + root_path: Path, + folder_name: str, + problem_path: Path, + workspace_members: set[str], +) -> list[HealthIssue]: + issues: list[HealthIssue] = [] + + has_link = (problem_path / "link.json").exists() + has_sql_solution = (problem_path / "solution.sql").exists() + + if not any((problem_path / doc).exists() for doc in PROBLEM_DOCS): + issues.append(HealthIssue("error", problem_path, "missing problem.md or problem_zh.md")) + + if not has_link and not has_sql_solution and not any((problem_path / f).exists() for f in TESTCASE_FILES): + issues.append(HealthIssue("error", problem_path, "missing testcase.py, testcase, or input.json")) + + if not has_link and not any((problem_path / f).exists() for f in SOLUTION_FILES): + issues.append(HealthIssue("error", problem_path, "missing solution file")) + + for file_name in SOLUTION_FILES: + file_path = problem_path / file_name + if file_path.exists() and file_path.stat().st_size == 0: + issues.append(HealthIssue("error", file_path, "solution file is empty")) + + if has_link: + issues.extend(_scan_link(root_path, problem_path)) + + has_rust_solution = (problem_path / "solution.rs").exists() + has_cargo = (problem_path / "Cargo.toml").exists() + if has_rust_solution and not has_cargo: + issues.append(HealthIssue("error", problem_path, "solution.rs exists but Cargo.toml is missing")) + if has_cargo and not has_rust_solution: + issues.append(HealthIssue("error", problem_path, "Cargo.toml exists but solution.rs is missing")) + if has_rust_solution and has_cargo: + workspace_member = f"{folder_name}/{problem_path.name}" + if workspace_member not in workspace_members: + issues.append(HealthIssue("warning", problem_path, "Rust solution is not listed in root Cargo.toml workspace members")) + issues.extend(_scan_problem_cargo(problem_path)) + + return issues + + +def _scan_link(root_path: Path, problem_path: Path) -> list[HealthIssue]: + link_path = problem_path / "link.json" + try: + link_data = json.loads(link_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return [HealthIssue("error", link_path, f"invalid JSON: {exc}")] + + issues: list[HealthIssue] = [] + link_to = link_data.get("link_to") + link_folder = link_data.get("link_folder") + if not link_to: + issues.append(HealthIssue("error", link_path, "missing link_to")) + if not link_folder: + issues.append(HealthIssue("error", link_path, "missing link_folder")) + if link_to and link_folder: + target = root_path / str(link_folder) / f"{link_folder}_{link_to}" + if not target.exists(): + issues.append(HealthIssue("error", link_path, f"linked target does not exist: {target.relative_to(root_path)}")) + return issues + + +def _scan_problem_cargo(problem_path: Path) -> list[HealthIssue]: + cargo_path = problem_path / "Cargo.toml" + if tomllib is None: + return [] + try: + data = tomllib.loads(cargo_path.read_text(encoding="utf-8")) + except Exception as exc: + return [HealthIssue("error", cargo_path, f"invalid Cargo.toml: {exc}")] + + expected_name = f"solution_{problem_path.name.split('_', 1)[1]}" + actual_name = data.get("package", {}).get("name") + if actual_name != expected_name: + return [HealthIssue("warning", cargo_path, f"package name is {actual_name!r}, expected {expected_name!r}")] + return [] + + +def _load_rust_workspace_members(root_path: Path) -> set[str]: + cargo_path = root_path / "Cargo.toml" + if not cargo_path.exists() or tomllib is None: + return set() + try: + data = tomllib.loads(cargo_path.read_text(encoding="utf-8")) + except Exception: + return set() + return set(data.get("workspace", {}).get("members", [])) + + +def _scan_missing_rust_workspace_members( + root_path: Path, + folder_names: tuple[str, ...], + workspace_members: set[str], +) -> list[HealthIssue]: + issues: list[HealthIssue] = [] + folder_prefixes = tuple(f"{folder}/" for folder in folder_names) + for member in sorted(workspace_members): + if not member.startswith(folder_prefixes): + continue + member_path = root_path / member + if not member_path.exists(): + issues.append(HealthIssue("warning", member_path, "root Cargo.toml workspace member path does not exist")) + return issues + + +def _is_generated_problem_dir(name: str, folder_name: str) -> bool: + return bool(re.match(rf"^{re.escape(folder_name)}_.+", name)) + + +def _is_effectively_empty_dir(path: Path) -> bool: + """Return True if a directory contains only generated cache artifacts.""" + if not path.is_dir(): + return False + for child in path.iterdir(): + if child.name in EMPTY_ARTIFACT_NAMES: + continue + if child.is_dir() and child.name in EMPTY_ARTIFACT_DIRS and _is_effectively_empty_dir(child): + continue + if child.is_file() and child.suffix in EMPTY_ARTIFACT_SUFFIXES: + continue + return False + return True + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Check generated LeetCode problem folder health.") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument("--folder", action="append", default=None, help="Problem folder to scan. Repeat to scan several.") + parser.add_argument("--limit", type=int, default=50, help="Maximum number of issues to print.") + args = parser.parse_args(argv) + + folders = args.folder or ["problems"] + report = scan_repository(args.root, folders) + print(format_report(report, args.root, args.limit)) + return 0 if report.ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/scripts/tools.py b/python/scripts/tools.py index 26da5912b..7691a8cdf 100644 --- a/python/scripts/tools.py +++ b/python/scripts/tools.py @@ -288,6 +288,69 @@ def remove_rust_file(_problem_id: str): logging.info("Removed %d error rust files", total_remove) + +def sync_rust_cargo_main(root_path: Path, problem_folder: str) -> dict[str, list[str]]: + """Sync root Cargo.toml with Rust solution packages under a problem folder.""" + cargo_path = root_path / "Cargo.toml" + if not cargo_path.exists(): + raise FileNotFoundError(f"Cargo.toml not found: {cargo_path}") + + problem_root = root_path / problem_folder + if not problem_root.exists(): + raise FileNotFoundError(f"Problem folder not found: {problem_root}") + + content = cargo_path.read_text(encoding="utf-8") + rust_problem_ids = [] + for problem_path in sorted(problem_root.iterdir()): + if not problem_path.is_dir() or not problem_path.name.startswith(f"{problem_folder}_"): + continue + if not (problem_path / "solution.rs").exists() or not (problem_path / "Cargo.toml").exists(): + continue + problem_id = problem_path.name.removeprefix(f"{problem_folder}_") + member_path = f"{problem_folder}/{problem_folder}_{problem_id}" + dependency_path = f'path = "{member_path}"' + if f'"{member_path}"' not in content or dependency_path not in content: + rust_problem_ids.append(problem_id) + + if rust_problem_ids: + lc_libs.RustWriter.cargo_add_problems(cargo_path, [[problem_id, problem_folder] for problem_id in rust_problem_ids]) + + removed_paths = [] + content = cargo_path.read_text(encoding="utf-8") + + def replace_stale_path(match: re.Match) -> str: + """Replace stale path with empty string, preserving surrounding context.""" + matched_path = match.group(1) + if not (root_path / matched_path).exists(): + removed_paths.append(matched_path) + return "" + return match.group(0) + + # Use regex substitution to remove only the stale path, not the entire line + pattern = rf'"({re.escape(problem_folder)}/{re.escape(problem_folder)}_[^"]+)"' + new_content = re.sub(pattern, replace_stale_path, content) + + # Clean up resulting empty lines and trailing commas in members array + new_lines = [] + for line in new_content.split("\n"): + # Remove lines that become empty or contain only whitespace/comma after substitution + stripped = line.strip() + if stripped in ("", ","): + continue + # Handle lines with trailing comma after path removal (e.g., "\t\t," -> skip) + if stripped.endswith(",") and stripped[:-1].strip() == "": + continue + new_lines.append(line) + cargo_path.write_text("\n".join(new_lines), encoding="utf-8") + + result = { + "added": rust_problem_ids, + "removed": sorted(set(removed_paths)), + } + logging.info("Synced Cargo.toml: added=%s removed=%s", result["added"], result["removed"]) + return result + + def clean_error_rust(args): root_path = Path(__file__).parent.parent.parent problem_folder = os.getenv(constant.PROBLEM_FOLDER, get_default_folder()) @@ -320,6 +383,11 @@ def clean_error_rust(args): clean_rust = sub_parser.add_parser("clean_rust", help="Clean error rust files") clean_rust.set_defaults(func=clean_error_rust) clean_rust.add_argument("-d", "--daily", action="store_true", help="Keep daily rust error files") + sync_cargo = sub_parser.add_parser("sync_rust_cargo", help="Sync root Cargo.toml with Rust solution packages") + sync_cargo.set_defaults(func=lambda _: sync_rust_cargo_main( + Path(__file__).parent.parent.parent, + os.getenv(constant.PROBLEM_FOLDER, get_default_folder()), + )) arguments = parser.parse_args() arguments.func(arguments) sys.exit() diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 1ca625f7c..ec6b3ca34 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -28,6 +28,15 @@ # Languages that are fully supported SUPPORTED_LANGUAGES = ["python3", "cpp", "java", "typescript", "golang", "rust"] +# These cases are intentionally sourced from python/dev/question_code_snippets.json. +# Keep them aligned with that fixture so targeted codegen tests do not silently skip. +DEV_SNIPPET_CASES = { + "simple": "1", + "list_node": "23", + "tree_node": "919", + "design": "1656", +} + def get_writer(lang_slug: str) -> Optional[lc_libs.LanguageWriter]: """Get the Writer instance for a language slug.""" @@ -40,6 +49,14 @@ def get_writer(lang_slug: str) -> Optional[lc_libs.LanguageWriter]: return cls() +def get_problem_snippets(question_snippets: List[Dict[str, Any]], problem_id: str) -> List[Dict[str, Any]]: + """Return snippet records for a problem that must exist in the dev fixture.""" + for item in question_snippets: + if problem_id in item: + return item[problem_id] + raise AssertionError(f"Problem {problem_id} not found in python/dev/question_code_snippets.json") + + class TestCodeGeneration: """Tests for code generation across languages.""" @@ -68,17 +85,8 @@ def test_writer_has_solution_file(self, lang_slug: str): @pytest.mark.codegen def test_generate_simple_two_sum(self, question_snippets: List[Dict[str, Any]], temp_dir: Path): """Test code generation for Two Sum problem (simple function signature).""" - problem_id = "1" - problem_data = None - - # Find Two Sum in snippets - for item in question_snippets: - if problem_id in item: - problem_data = item[problem_id] - break - - if problem_data is None: - pytest.skip(f"Problem {problem_id} not found in snippets") + problem_id = DEV_SNIPPET_CASES["simple"] + problem_data = get_problem_snippets(question_snippets, problem_id) for code_snippet in problem_data: lang_slug = code_snippet.get("langSlug", "") @@ -105,17 +113,8 @@ def test_generate_simple_two_sum(self, question_snippets: List[Dict[str, Any]], @pytest.mark.codegen def test_generate_tree_node_problem(self, question_snippets: List[Dict[str, Any]], temp_dir: Path): """Test code generation for a TreeNode problem.""" - # Problem 226: Invert Binary Tree - problem_id = "226" - problem_data = None - - for item in question_snippets: - if problem_id in item: - problem_data = item[problem_id] - break - - if problem_data is None: - pytest.skip(f"Problem {problem_id} not found in snippets") + problem_id = DEV_SNIPPET_CASES["tree_node"] + problem_data = get_problem_snippets(question_snippets, problem_id) for code_snippet in problem_data: lang_slug = code_snippet.get("langSlug", "") @@ -143,17 +142,8 @@ def test_generate_tree_node_problem(self, question_snippets: List[Dict[str, Any] @pytest.mark.codegen def test_generate_list_node_problem(self, question_snippets: List[Dict[str, Any]], temp_dir: Path): """Test code generation for a ListNode problem.""" - # Problem 206: Reverse Linked List - problem_id = "206" - problem_data = None - - for item in question_snippets: - if problem_id in item: - problem_data = item[problem_id] - break - - if problem_data is None: - pytest.skip(f"Problem {problem_id} not found in snippets") + problem_id = DEV_SNIPPET_CASES["list_node"] + problem_data = get_problem_snippets(question_snippets, problem_id) for code_snippet in problem_data: lang_slug = code_snippet.get("langSlug", "") @@ -179,18 +169,43 @@ def test_generate_list_node_problem(self, question_snippets: List[Dict[str, Any] pytest.skip(f"Language {lang_slug} not fully implemented: {e}") @pytest.mark.codegen - @pytest.mark.slow - def test_generate_all_snippets(self, question_snippets: List[Dict[str, Any]]): - """Test code generation for all problems in snippets. + def test_generate_design_problem(self, question_snippets: List[Dict[str, Any]], temp_dir: Path): + """Test code generation for a class/design problem from the dev snippets.""" + problem_id = DEV_SNIPPET_CASES["design"] + problem_data = get_problem_snippets(question_snippets, problem_id) + + for code_snippet in problem_data: + lang_slug = code_snippet.get("langSlug", "") + if lang_slug not in SUPPORTED_LANGUAGES: + continue + + writer = get_writer(lang_slug) + if writer is None: + continue + + try: + generated = writer.write_solution( + code_snippet["code"], + None, + format_question_id(problem_id), + "problems" + ) + assert generated, f"Generated code should not be empty for {lang_slug}" + assert "orderedstream" in generated.lower() and "insert" in generated, \ + f"Generated code should preserve design/class structure for {lang_slug}" + except NotImplementedError as e: + pytest.skip(f"Language {lang_slug} not fully implemented: {e}") - This is a comprehensive test that validates all code snippets. - Marked as slow, run with: pytest -m slow - """ + @pytest.mark.codegen + def test_generate_all_snippets(self, question_snippets: List[Dict[str, Any]]): + """Test code generation for all supported snippets in python/dev.""" errors = [] generated_count = {} + problems_seen = set() for item in question_snippets: for problem_id, code_list in item.items(): + problems_seen.add(problem_id) for code_snippet in code_list: lang_slug = code_snippet.get("langSlug", "") if lang_slug not in SUPPORTED_LANGUAGES: @@ -214,14 +229,12 @@ def test_generate_all_snippets(self, question_snippets: List[Dict[str, Any]]): except Exception as e: errors.append(f"Problem {problem_id} ({lang_slug}): {type(e).__name__}: {e}") - # Log summary print(f"\nCode generation summary: {generated_count}") - # Allow some errors but report them + assert problems_seen, "No problems found in python/dev/question_code_snippets.json" + assert generated_count, "No supported snippets generated from python/dev/question_code_snippets.json" if errors: - error_rate = len(errors) / max(sum(generated_count.values()), 1) - if error_rate > 0.1: # More than 10% failure rate - pytest.fail(f"Too many code generation failures:\n" + "\n".join(errors[:10])) + pytest.fail("Code generation failures:\n" + "\n".join(errors[:20])) class TestWriterEnvironment: @@ -304,4 +317,4 @@ def test_resolve_circular_link(self, tmp_path: Path): json.dump({"link_to": "1", "link_folder": "problems"}, f) with pytest.raises(ValueError, match="Circular link"): - LanguageWriter._resolve_link(problem_1) \ No newline at end of file + LanguageWriter._resolve_link(problem_1) diff --git a/tests/test_repository_health.py b/tests/test_repository_health.py new file mode 100644 index 000000000..9a9be15da --- /dev/null +++ b/tests/test_repository_health.py @@ -0,0 +1,189 @@ +"""Tests for generated problem repository health checks.""" + +import json +from pathlib import Path + +import pytest + +from python.scripts.repository_health import apply_fix, build_fix_suggestions, scan_repository + + +def _write_problem(problem_path: Path, *, with_rust: bool = False) -> None: + problem_path.mkdir(parents=True) + (problem_path / "problem.md").write_text("# 1. Two Sum\n", encoding="utf-8") + (problem_path / "testcase.py").write_text("testcases = []\n", encoding="utf-8") + (problem_path / "solution.py").write_text("class Solution:\n pass\n", encoding="utf-8") + if with_rust: + (problem_path / "solution.rs").write_text("pub struct Solution;\n", encoding="utf-8") + (problem_path / "Cargo.toml").write_text( + '[package]\nname = "solution_1"\nversion = "0.1.0"\nedition = "2021"\n', + encoding="utf-8", + ) + + +@pytest.mark.unit +def test_health_accepts_complete_problem(tmp_path: Path): + root = tmp_path + _write_problem(root / "problems" / "problems_1") + + report = scan_repository(root, ["problems"]) + + assert report.ok + assert report.scanned == 1 + assert report.issues == () + + +@pytest.mark.unit +def test_health_reports_missing_testcase(tmp_path: Path): + problem = tmp_path / "problems" / "problems_1" + problem.mkdir(parents=True) + (problem / "problem.md").write_text("# 1. Two Sum\n", encoding="utf-8") + (problem / "solution.py").write_text("class Solution:\n pass\n", encoding="utf-8") + + report = scan_repository(tmp_path, ["problems"]) + + assert not report.ok + assert any("missing testcase" in issue.message for issue in report.errors) + + +@pytest.mark.unit +def test_health_validates_link_target(tmp_path: Path): + target = tmp_path / "problems" / "problems_1" + _write_problem(target) + linked = tmp_path / "problems" / "problems_2" + linked.mkdir() + (linked / "problem.md").write_text("# 2. Same Problem\n", encoding="utf-8") + (linked / "link.json").write_text( + json.dumps({"link_to": "1", "link_folder": "problems"}), + encoding="utf-8", + ) + + report = scan_repository(tmp_path, ["problems"]) + + assert report.ok + assert report.scanned == 2 + + +@pytest.mark.unit +def test_health_reports_missing_link_target(tmp_path: Path): + linked = tmp_path / "problems" / "problems_2" + linked.mkdir(parents=True) + (linked / "problem.md").write_text("# 2. Same Problem\n", encoding="utf-8") + (linked / "link.json").write_text( + json.dumps({"link_to": "999", "link_folder": "problems"}), + encoding="utf-8", + ) + + report = scan_repository(tmp_path, ["problems"]) + + assert not report.ok + assert any("linked target does not exist" in issue.message for issue in report.errors) + + +@pytest.mark.unit +def test_health_warns_for_rust_workspace_mismatch(tmp_path: Path): + _write_problem(tmp_path / "problems" / "problems_1", with_rust=True) + (tmp_path / "Cargo.toml").write_text( + '[workspace]\nmembers = ["rust/library"]\n', + encoding="utf-8", + ) + + report = scan_repository(tmp_path, ["problems"]) + + assert report.ok + assert any("not listed in root Cargo.toml" in issue.message for issue in report.warnings) + + +@pytest.mark.unit +def test_health_ignores_legacy_problem_containers(tmp_path: Path): + problems = tmp_path / "problems" + (problems / "剑指Offer").mkdir(parents=True) + (problems / "Interview").mkdir() + (problems / "LCP").mkdir() + + report = scan_repository(tmp_path, ["problems"]) + + assert report.ok + assert report.scanned == 0 + assert report.issues == () + + +@pytest.mark.unit +def test_health_suggests_removing_cache_only_problem_dir(tmp_path: Path): + problem = tmp_path / "problems" / "problems_3878" + cache = problem / "__pycache__" + cache.mkdir(parents=True) + (cache / "solution.cpython-314.pyc").write_bytes(b"cache") + + report = scan_repository(tmp_path, ["problems"]) + fixes = build_fix_suggestions(report, tmp_path) + + assert not report.ok + assert len(fixes) == 1 + assert fixes[0].action == "remove_empty_dir" + assert fixes[0].path == problem.resolve() + + +@pytest.mark.unit +def test_health_empty_dir_fix_removes_only_after_revalidation(tmp_path: Path): + problem = tmp_path / "problems" / "problems_3878" + cache = problem / "__pycache__" + cache.mkdir(parents=True) + (cache / "testcase.cpython-314.pyc").write_bytes(b"cache") + + fix = build_fix_suggestions(scan_repository(tmp_path, ["problems"]), tmp_path)[0] + result = apply_fix(fix) + + assert "Removed:" in result + assert not problem.exists() + + +@pytest.mark.unit +def test_health_does_not_suggest_removing_non_empty_problem_dir(tmp_path: Path): + problem = tmp_path / "problems" / "problems_3878" + problem.mkdir(parents=True) + (problem / "notes.txt").write_text("keep me\n", encoding="utf-8") + + report = scan_repository(tmp_path, ["problems"]) + fixes = build_fix_suggestions(report, tmp_path) + + assert not report.ok + assert fixes == () + + +@pytest.mark.unit +def test_health_suggests_rust_cargo_sync(tmp_path: Path): + _write_problem(tmp_path / "problems" / "problems_1", with_rust=True) + (tmp_path / "Cargo.toml").write_text( + '[workspace]\nmembers = ["rust/library", "problems/problems_999"]\n\n[dependencies]\n' + 'solution_999 = { path = "problems/problems_999", features = ["solution_999"] }\n', + encoding="utf-8", + ) + + report = scan_repository(tmp_path, ["problems"]) + fixes = build_fix_suggestions(report, tmp_path) + + assert report.ok + assert any("not listed in root Cargo.toml" in issue.message for issue in report.warnings) + assert any("workspace member path does not exist" in issue.message for issue in report.warnings) + assert any(fix.action == "sync_rust_cargo" for fix in fixes) + + +@pytest.mark.unit +def test_health_rust_cargo_sync_adds_missing_and_removes_stale(tmp_path: Path): + _write_problem(tmp_path / "problems" / "problems_1", with_rust=True) + (tmp_path / "Cargo.toml").write_text( + '[workspace]\nmembers = [\n "rust/library",\n "problems/problems_999",\n]\n\n[dependencies]\n' + 'solution_999 = { path = "problems/problems_999", features = ["solution_999"] }\n', + encoding="utf-8", + ) + + fix = next(fix for fix in build_fix_suggestions(scan_repository(tmp_path, ["problems"]), tmp_path) + if fix.action == "sync_rust_cargo") + result = apply_fix(fix) + cargo = (tmp_path / "Cargo.toml").read_text(encoding="utf-8") + + assert "Synced Rust Cargo entries" in result + assert '"problems/problems_1"' in cargo + assert 'solution_1 = { path = "problems/problems_1", features = ["solution_1"] }' in cargo + assert "problems/problems_999" not in cargo