diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be07e01..0e82ce3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,11 @@ jobs: echo "LLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm" >> $GITHUB_ENV echo "Clang_DIR=/usr/lib/llvm-20/lib/cmake/clang" >> $GITHUB_ENV + - name: Compute next version (SemVer) + id: semver + run: | + python3 scripts/ci/semver_check.py + - name: Set up QEMU (Linux) if: runner.os == 'Linux' uses: docker/setup-qemu-action@v3 @@ -90,7 +95,7 @@ jobs: python3 test/examples/test_extern_project.py - name: Docker tests (multi-arch, Linux) - if: runner.os == 'Linux' + if: runner.os == 'Linux' && github.event_name == 'push' && github.ref == 'refs/heads/main' run: | docker buildx build --platform linux/amd64 -f test/docker/Dockerfile . --progress=plain --output=type=cacheonly docker buildx build --platform linux/arm64 -f test/docker/Dockerfile . --progress=plain --output=type=cacheonly diff --git a/.github/workflows/commit-check.yml b/.github/workflows/commit-check.yml new file mode 100644 index 0000000..5d75c9a --- /dev/null +++ b/.github/workflows/commit-check.yml @@ -0,0 +1,16 @@ +name: Commit conventions + +on: + push: + +jobs: + commit_check: + name: Check conventional commits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Validate commit messages + run: | + python3 scripts/ci/commit_checker.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..bad34cd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: local + hooks: + - id: conventional-commit-msg + name: conventional commit message + entry: python3 scripts/git/commit_msg_checker.py + language: system + stages: [commit-msg] + pass_filenames: true diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e46e7c..d6f7861 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -209,7 +209,8 @@ else() ) endif() -add_executable(cc src/cli/main.cc) +add_executable(cc src/cli/main.cc src/cli/help.cc src/cli/args.cc) +target_include_directories(cc PRIVATE src) # Propagate CLANG_RESOURCE_DIR as a compile definition to all relevant targets foreach(tgt compilerlib_static compilerlib_shared cc) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab180a7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +## Commit message convention +We enforce Conventional Commits in CI and via a local commit-msg hook. + +Allowed types: +- feat, fix, chore, docs, refactor, perf, ci, build, style, revert, test + +Rules: +- Format: `()?: ` +- Subject line length: **max 72 characters** + +Examples: +- `feat(cli): improve --help output` +- `fix: handle missing output path` + +## Local setup (recommended) +We use pre-commit to install a commit-msg hook. + +Option (script): +``` +./scripts/setup-dev.sh +``` + +## CI +CI runs `scripts/ci/commit_checker.py` on every push and will fail if any commit +message is not Conventional or exceeds the subject length limit. diff --git a/scripts/ci/commit_checker.py b/scripts/ci/commit_checker.py new file mode 100755 index 0000000..a665d83 --- /dev/null +++ b/scripts/ci/commit_checker.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from typing import Iterable, List, Optional, Tuple + +ALLOWED_TYPES = ( + "feat", + "fix", + "chore", + "docs", + "refactor", + "perf", + "ci", + "build", + "style", + "revert", + "test", +) + +MAX_SUBJECT_LEN = 72 + +CONVENTIONAL_RE = re.compile( + r"^(?P" + "|".join(ALLOWED_TYPES) + r")" + r"(?P\([^)]+\))?" + r"(?P!)?: " + r"(?P.+)$" +) + + +@dataclass +class Commit: + sha: str + subject: str + body: str + + +@dataclass +class InvalidCommit: + commit: Commit + reason: str + + +def run_git(args: List[str]) -> str: + try: + out = subprocess.check_output(["git", *args], text=True).strip() + except subprocess.CalledProcessError as exc: + print(exc.output, file=sys.stderr) + raise + return out + + +def get_event_range() -> Optional[str]: + override = os.environ.get("CHECK_RANGE") + if override: + return override + + event = os.environ.get("GITHUB_EVENT_NAME", "") + if event == "pull_request": + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path or not os.path.exists(event_path): + return None + with open(event_path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + base_sha = payload.get("pull_request", {}).get("base", {}).get("sha") + head_sha = payload.get("pull_request", {}).get("head", {}).get("sha") + if base_sha and head_sha: + return f"{base_sha}..{head_sha}" + return None + + if event == "push": + before = os.environ.get("GITHUB_BEFORE") + head = os.environ.get("GITHUB_SHA") + if before and head and not is_zero_sha(before): + return f"{before}..{head}" + if head: + return None + + return None + + +def is_zero_sha(value: str) -> bool: + return re.fullmatch(r"0+", value or "") is not None + + +def range_for_ref(ref: str) -> str: + try: + run_git(["rev-parse", f"{ref}^"]) + except subprocess.CalledProcessError: + return ref + return f"{ref}^..{ref}" + + +def get_upstream_ref() -> Optional[str]: + try: + upstream = run_git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]) + except subprocess.CalledProcessError: + return None + return upstream or None + + +def find_base_ref() -> Optional[str]: + explicit = os.environ.get("BASE_BRANCH") + candidates: List[str] = [] + if explicit: + candidates.extend([explicit, f"origin/{explicit}"]) + + upstream = get_upstream_ref() + if upstream: + candidates.append(upstream) + + candidates.extend(["origin/main", "origin/master", "main", "master"]) + + seen = set() + for ref in candidates: + if ref in seen: + continue + seen.add(ref) + try: + run_git(["rev-parse", "--verify", ref]) + return ref + except subprocess.CalledProcessError: + continue + return None + + +def compute_branch_range() -> str: + base_ref = find_base_ref() + if not base_ref: + return range_for_ref("HEAD") + try: + base_sha = run_git(["merge-base", "HEAD", base_ref]) + except subprocess.CalledProcessError: + return range_for_ref("HEAD") + if not base_sha: + return range_for_ref("HEAD") + return f"{base_sha}..HEAD" + + +def iter_commits(range_spec: Optional[str]) -> Iterable[Commit]: + args = ["log", "--format=%H%x1f%s%x1f%b%x1e"] + if range_spec: + args.insert(1, range_spec) + raw = run_git(args) + if not raw: + return [] + commits: List[Commit] = [] + for record in raw.split("\x1e"): + record = record.strip() + if not record: + continue + parts = record.split("\x1f") + if len(parts) < 2: + continue + sha = parts[0] + subject = parts[1] + body = parts[2] if len(parts) > 2 else "" + commits.append(Commit(sha=sha, subject=subject, body=body)) + return commits + + +def is_merge_commit(commit: Commit) -> bool: + subject = commit.subject + return subject.startswith("Merge ") or subject.startswith("Merge pull request") + + +def validate_commits(commits: Iterable[Commit]) -> List[InvalidCommit]: + invalid: List[InvalidCommit] = [] + for commit in commits: + if is_merge_commit(commit): + continue + if len(commit.subject) > MAX_SUBJECT_LEN: + invalid.append( + InvalidCommit( + commit=commit, + reason=f"subject too long ({len(commit.subject)} > {MAX_SUBJECT_LEN})", + ) + ) + continue + if not CONVENTIONAL_RE.match(commit.subject): + invalid.append(InvalidCommit(commit=commit, reason="invalid conventional format")) + return invalid + + +def main() -> int: + check_range = get_event_range() + if not check_range: + check_range = compute_branch_range() + print(f"Commit check range: {check_range}") + commits = list(iter_commits(check_range)) + invalid = validate_commits(commits) + if invalid: + print("Non-conventional commits detected:", file=sys.stderr) + for entry in invalid: + print( + f"- {entry.commit.sha[:7]} {entry.commit.subject} ({entry.reason})", + file=sys.stderr, + ) + return 1 + + print("All commits follow Conventional Commits.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/semver_check.py b/scripts/ci/semver_check.py new file mode 100755 index 0000000..350ea57 --- /dev/null +++ b/scripts/ci/semver_check.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import os +import re +import subprocess +import sys +from typing import Iterable, List, Optional, Tuple + +ALLOWED_TYPES = ( + "feat", + "fix", + "chore", + "docs", + "refactor", + "perf", + "ci", + "build", + "style", + "revert", + "test", +) + +CONVENTIONAL_RE = re.compile( + r"^(?P" + "|".join(ALLOWED_TYPES) + r")" + r"(?P\([^)]+\))?" + r"(?P!)?: " + r"(?P.+)$" +) + +BREAKING_RE = re.compile(r"^BREAKING[ -]CHANGE:", re.MULTILINE) + + +class Commit: + def __init__(self, sha: str, subject: str, body: str) -> None: + self.sha = sha + self.subject = subject + self.body = body + + +def run_git(args: List[str]) -> str: + try: + out = subprocess.check_output(["git", *args], text=True).strip() + except subprocess.CalledProcessError as exc: + print(exc.output, file=sys.stderr) + raise + return out + + +def is_zero_sha(value: str) -> bool: + return re.fullmatch(r"0+", value or "") is not None + + +def get_latest_tag() -> Optional[str]: + tags = run_git(["tag", "--list", "v[0-9]*.[0-9]*.[0-9]*", "--sort=-v:refname"]) + if not tags: + return None + return tags.splitlines()[0].strip() + + +def parse_version(tag: Optional[str]) -> Tuple[int, int, int]: + if not tag: + return (0, 0, 0) + match = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag) + if not match: + return (0, 0, 0) + return tuple(int(p) for p in match.groups()) + + +def iter_commits(range_spec: Optional[str]) -> Iterable[Commit]: + args = ["log", "--format=%H%x1f%s%x1f%b%x1e"] + if range_spec: + args.insert(1, range_spec) + raw = run_git(args) + if not raw: + return [] + commits: List[Commit] = [] + for record in raw.split("\x1e"): + record = record.strip() + if not record: + continue + parts = record.split("\x1f") + if len(parts) < 2: + continue + sha = parts[0] + subject = parts[1] + body = parts[2] if len(parts) > 2 else "" + commits.append(Commit(sha=sha, subject=subject, body=body)) + return commits + + +def is_merge_commit(commit: Commit) -> bool: + subject = commit.subject + return subject.startswith("Merge ") or subject.startswith("Merge pull request") + + +def classify_bump(commits: Iterable[Commit]) -> str: + bump = "none" + for commit in commits: + if is_merge_commit(commit): + continue + match = CONVENTIONAL_RE.match(commit.subject) + if not match: + continue + if match.group("breaking") or BREAKING_RE.search(commit.body or ""): + return "major" + ctype = match.group("type") + if ctype == "feat" and bump != "minor": + bump = "minor" + elif ctype == "fix" and bump == "none": + bump = "patch" + return bump + + +def bump_version(base: Tuple[int, int, int], bump: str) -> Tuple[int, int, int]: + major, minor, patch = base + if bump == "major": + return (major + 1, 0, 0) + if bump == "minor": + return (major, minor + 1, 0) + if bump == "patch": + return (major, minor, patch + 1) + return base + + +def write_output(key: str, value: str) -> None: + output_path = os.environ.get("GITHUB_OUTPUT") + if not output_path: + return + with open(output_path, "a", encoding="utf-8") as fh: + fh.write(f"{key}={value}\n") + + +def main() -> int: + base_tag = get_latest_tag() + base_version = parse_version(base_tag) + version_range = os.environ.get("VERSION_RANGE") + if not version_range: + version_range = f"{base_tag}..HEAD" if base_tag else "HEAD" + + version_commits = list(iter_commits(version_range)) + bump = classify_bump(version_commits) + next_version = bump_version(base_version, bump) + + base_tag_out = base_tag or "" + next_version_str = f"v{next_version[0]}.{next_version[1]}.{next_version[2]}" + next_version_raw = f"{next_version[0]}.{next_version[1]}.{next_version[2]}" + + write_output("base_tag", base_tag_out) + write_output("bump", bump) + write_output("next_version", next_version_str) + write_output("next_version_raw", next_version_raw) + + print(f"Base tag: {base_tag_out or 'none'}") + print(f"Version range: {version_range}") + print(f"Bump: {bump}") + print(f"Next version: {next_version_str}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/git/commit_msg_checker.py b/scripts/git/commit_msg_checker.py new file mode 100755 index 0000000..d529432 --- /dev/null +++ b/scripts/git/commit_msg_checker.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +import sys +from typing import List + +ALLOWED_TYPES = ( + "feat", + "fix", + "chore", + "docs", + "refactor", + "perf", + "ci", + "build", + "style", + "revert", + "test", +) + +MAX_SUBJECT_LEN = 72 + +CONVENTIONAL_RE = re.compile( + r"^(?P" + "|".join(ALLOWED_TYPES) + r")" + r"(?P\([^)]+\))?" + r"(?P!)?: " + r"(?P.+)$" +) + + +def read_subject(path: str) -> str: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#"): + continue + return stripped + return "" + + +def is_merge_message(subject: str) -> bool: + return subject.startswith("Merge ") or subject.startswith("Merge pull request") + + +def is_git_revert(subject: str) -> bool: + return subject.startswith("Revert ") + + +def main(argv: List[str]) -> int: + if len(argv) < 2: + print("commit message file path missing", file=sys.stderr) + return 1 + + subject = read_subject(argv[1]) + if not subject: + print("empty commit message", file=sys.stderr) + return 1 + + if is_merge_message(subject) or is_git_revert(subject): + return 0 + + if len(subject) > MAX_SUBJECT_LEN: + print( + f"Commit subject too long ({len(subject)} > {MAX_SUBJECT_LEN}).", + file=sys.stderr, + ) + return 1 + + if not CONVENTIONAL_RE.match(subject): + print("Commit message must follow Conventional Commits.", file=sys.stderr) + print(f"Got: {subject}", file=sys.stderr) + print( + "Expected: ()?: with type in: " + + ", ".join(ALLOWED_TYPES), + file=sys.stderr, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100755 index 0000000..4fc5478 --- /dev/null +++ b/scripts/setup-dev.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to install pre-commit" >&2 + exit 1 +fi + +ROOT_DIR=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +VENV_DIR="${ROOT_DIR}/.venv-pre-commit" + +python3 -m venv "${VENV_DIR}" +source "${VENV_DIR}/bin/activate" +python -m pip install --upgrade pip >/dev/null 2>&1 +python -m pip install pre-commit >/dev/null 2>&1 +python -m pre_commit install --hook-type commit-msg + +echo "Commit-msg hook installed using ${VENV_DIR}." diff --git a/src/cli/args.cc b/src/cli/args.cc new file mode 100644 index 0000000..0ddef6d --- /dev/null +++ b/src/cli/args.cc @@ -0,0 +1,56 @@ +#include "cli/args.h" + +namespace cli +{ + ParseResult parseArgs(int argc, char* argv[]) + { + ParseResult result; + if (argc < 2) + { + result.outcome = ParseOutcome::Help; + return result; + } + + result.compiler_args.reserve(argc - 1); + bool passthrough_args = false; + + for (int i = 1; i < argc; ++i) + { + std::string arg = argv[i]; + + if (passthrough_args) + { + result.compiler_args.push_back(std::move(arg)); + continue; + } + + if (arg == "-h" || arg == "--help") + { + result.outcome = ParseOutcome::Help; + return result; + } + + if (arg == "--") + { + result.compiler_args.push_back(arg); + passthrough_args = true; + continue; + } + + if (arg == "--in-mem" || arg == "--in-memory") + { + result.mode = compilerlib::OutputMode::ToMemory; + continue; + } + if (arg == "--instrument") + { + result.instrument = true; + continue; + } + + result.compiler_args.push_back(std::move(arg)); + } + + return result; + } +} // namespace cli diff --git a/src/cli/args.h b/src/cli/args.h new file mode 100644 index 0000000..6536880 --- /dev/null +++ b/src/cli/args.h @@ -0,0 +1,30 @@ +#ifndef CORETRACE_CLI_ARGS_H +#define CORETRACE_CLI_ARGS_H + +#include "compilerlib/compiler.h" + +#include +#include + +namespace cli +{ + enum class ParseOutcome + { + Ok, + Help, + Error, + }; + + struct ParseResult + { + ParseOutcome outcome = ParseOutcome::Ok; + compilerlib::OutputMode mode = compilerlib::OutputMode::ToFile; + bool instrument = false; + std::vector compiler_args; + std::string error; + }; + + ParseResult parseArgs(int argc, char* argv[]); +} // namespace cli + +#endif // CORETRACE_CLI_ARGS_H diff --git a/src/cli/help.cc b/src/cli/help.cc new file mode 100644 index 0000000..ba2d3b0 --- /dev/null +++ b/src/cli/help.cc @@ -0,0 +1,63 @@ +#include "cli/help.h" + +#include +#include +#include + +namespace cli +{ + void printHelp(const char* argv0) + { + std::string name = "cc"; + if (argv0 && *argv0) + { + std::filesystem::path path(argv0); + auto fname = path.filename().string(); + if (!fname.empty()) + { + name = fname; + } + } + + std::cout + << "CoreTrace Compiler (based on the Clang/LLVM toolchain)\n" + << "\n" + << "Usage:\n" + << " " << name << " [options] ...\n" + << "\n" + << "Core options:\n" + << " -h, --help Show this help and exit.\n" + << " --instrument Enable CoreTrace instrumentation (required for " + "--ct-*).\n" + << " --in-mem, --in-memory Print LLVM IR to stdout (use with -emit-llvm).\n" + << "\n" + << "Instrumentation toggles:\n" + << " --ct-modules= Comma-separated list: trace,alloc,bounds,vtable,all.\n" + << " --ct-shadow Enable shadow memory.\n" + << " --ct-shadow-aggressive Enable aggressive shadow mode.\n" + << " --ct-shadow=aggressive Same as --ct-shadow-aggressive.\n" + << " --ct-bounds-no-abort Do not abort on bounds errors.\n" + << " --ct-no-trace / --ct-trace\n" + << " --ct-no-alloc / --ct-alloc\n" + << " --ct-no-bounds / --ct-bounds\n" + << " --ct-no-autofree / --ct-autofree\n" + << " --ct-no-alloc-trace / --ct-alloc-trace\n" + << " --ct-no-vcall-trace / --ct-vcall-trace\n" + << " --ct-no-vtable-diag / --ct-vtable-diag\n" + << "\n" + << "Defaults:\n" + << " instrumentation: off\n" + << " modules: trace,alloc,bounds (vtable disabled)\n" + << " shadow: off, bounds abort: on, autofree: off, alloc trace: on\n" + << "\n" + << "Notes:\n" + << " - All other arguments are forwarded to clang.\n" + << " - Output defaults to a.out when linking (override with -o or -o=).\n" + << "\n" + << "Examples:\n" + << " " << name << " --instrument -o app main.c\n" + << "\n" + << "Exit codes:\n" + << " 0 on success, 1 on compiler errors.\n"; + } +} // namespace cli diff --git a/src/cli/help.h b/src/cli/help.h new file mode 100644 index 0000000..3682058 --- /dev/null +++ b/src/cli/help.h @@ -0,0 +1,9 @@ +#ifndef CORETRACE_CLI_HELP_H +#define CORETRACE_CLI_HELP_H + +namespace cli +{ + void printHelp(const char* argv0); +} + +#endif // CORETRACE_CLI_HELP_H diff --git a/src/cli/main.cc b/src/cli/main.cc index 70497f2..fd31baf 100644 --- a/src/cli/main.cc +++ b/src/cli/main.cc @@ -1,52 +1,34 @@ #include "compilerlib/compiler.h" -#include #include +#include "cli/args.h" +#include "cli/help.h" + int main(int argc, char* argv[]) { - // std::vector args(argv + 1, argv + argc); - // // auto [ok, err] = compilerlib::compile(args); - // compilerlib::CompileResult result = compilerlib::compile(args); - // bool ok = result.success; - // std::string err = result.diagnostics; - // llvm::errs() << err; - - std::vector args; - args.reserve(argc - 1); - - compilerlib::OutputMode mode = compilerlib::OutputMode::ToFile; - bool instrument = false; - - for (int i = 1; i < argc; ++i) + auto parsed = cli::parseArgs(argc, argv); + if (parsed.outcome == cli::ParseOutcome::Help) { - std::string arg = argv[i]; - if (arg == "--in-mem" || arg == "--in-memory") - { - mode = compilerlib::OutputMode::ToMemory; - } - else if (arg == "--instrument") - { - instrument = true; - } - else - { - args.push_back(std::move(arg)); - } + cli::printHelp(argv[0]); + return 0; + } + if (parsed.outcome == cli::ParseOutcome::Error) + { + std::cerr << parsed.error; + return 1; } - auto res = compilerlib::compile(args, mode, instrument); - + auto res = compilerlib::compile(parsed.compiler_args, parsed.mode, parsed.instrument); if (!res.success) { std::cerr << res.diagnostics; return 1; } - if (mode == compilerlib::OutputMode::ToMemory && !res.llvmIR.empty()) + if (parsed.mode == compilerlib::OutputMode::ToMemory && !res.llvmIR.empty()) { std::cout << res.llvmIR << std::endl; } - // return ok ? 0 : 1; return 0; } diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 7f3e4a7..935bd12 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -45,4 +45,5 @@ RUN python3 -m venv .venv \ && python -m pip install --upgrade pip \ && python -m pip install git+https://github.com/CoreTrace/coretrace-testkit.git \ && python test/examples/test_smoke.py \ - && python test/examples/test_extern_project.py + && python test/examples/test_extern_project.py \ + && python test/examples/test_help_smoke.py diff --git a/test/examples/test_help_smoke.py b/test/examples/test_help_smoke.py new file mode 100644 index 0000000..53ec2a6 --- /dev/null +++ b/test/examples/test_help_smoke.py @@ -0,0 +1,65 @@ +from pathlib import Path +import shutil +import tempfile + +from ctestfw.runner import CompilerRunner, RunnerConfig +from ctestfw.plan import CompilePlan +from ctestfw.framework.testcase import TestCase +from ctestfw.framework.reporter import ConsoleReporter +from ctestfw.assertions.compiler import ( + assert_exit_code, + assert_stdout_contains, +) + +ROOT = Path(__file__).resolve().parents[2] +FIXTURES = ROOT / "test" / "examples" / "fixtures" +WORK = ROOT / "test" / "examples" / ".work" + + +def copy_fixtures(ws: Path, files: list[Path]) -> None: + for f in files: + dst = ws / f.name + shutil.copy2(f, dst) + + +def main() -> int: + cc_bin = (ROOT / "build" / "cc").resolve() + runner = CompilerRunner(RunnerConfig(executable=cc_bin)) + if not cc_bin.exists(): + print(f"cc binary not found: {cc_bin}") + return 1 + + src = FIXTURES / "hello.c" + if not src.exists(): + print(f"fixture not found: {src}") + return 1 + + tc_help = TestCase( + name="help_smoke", + plan=CompilePlan( + name="help_smoke", + sources=[Path("hello.c")], + out=None, + extra_args=["--help"], + ), + assertions=[ + assert_exit_code(0), + assert_stdout_contains("Usage:"), + assert_stdout_contains("Core options:"), + assert_stdout_contains("--instrument"), + assert_stdout_contains("Exit codes:"), + ], + ) + + WORK.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f"{tc_help.name}_", dir=str(WORK)) as d: + ws = Path(d) + copy_fixtures(ws, [src]) + report = tc_help.run(runner, ws) + + rep = type("Tmp", (), {"name": "help_smoke", "reports": [report]})() + return ConsoleReporter().render(rep) + + +if __name__ == "__main__": + raise SystemExit(main())