Skip to content

Commit 12d15ac

Browse files
committed
Add exercise output contract checks to automation
1 parent 23ff581 commit 12d15ac

6 files changed

Lines changed: 420 additions & 14 deletions

File tree

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ Validate example output contracts with:
5555
- `./scripts/check-example-output-contracts.ps1` (PowerShell)
5656
- `bash ./scripts/check-example-output-contracts.sh` (Bash)
5757

58+
Validate exercise output contracts with:
59+
60+
- `./scripts/check-exercise-output-contracts.ps1` (PowerShell)
61+
- `bash ./scripts/check-exercise-output-contracts.sh` (Bash)
62+
5863
Run full repository checks with:
5964

6065
- `./scripts/verify-repo.ps1` (PowerShell)

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Run checks locally:
157157
./scripts/check-checkpoint-completeness.ps1
158158
./scripts/check-cross-language-parity.ps1
159159
./scripts/check-example-output-contracts.ps1
160+
./scripts/check-exercise-output-contracts.ps1
160161
./scripts/audit-education-quality.ps1
161162
./scripts/lint.ps1
162163
./scripts/smoke-languages.ps1
@@ -171,6 +172,7 @@ bash ./scripts/check-module-completeness.sh
171172
bash ./scripts/check-checkpoint-completeness.sh
172173
bash ./scripts/check-cross-language-parity.sh
173174
bash ./scripts/check-example-output-contracts.sh
175+
bash ./scripts/check-exercise-output-contracts.sh
174176
bash ./scripts/audit-education-quality.sh
175177
bash ./scripts/lint.sh
176178
bash ./scripts/smoke-languages.sh
@@ -186,7 +188,7 @@ The multi-language smoke scripts also compile standalone C# exercises by generat
186188

187189
Use [EDUCATIONAL_EXAMPLE_REVIEW_RUBRIC.md](EDUCATIONAL_EXAMPLE_REVIEW_RUBRIC.md) to keep entry examples pedagogically consistent during reviews. The education audit command is advisory and writes markdown/json findings without failing CI.
188190

189-
Documentation sync also validates that [CONCEPT_INDEX.md](CONCEPT_INDEX.md) covers every implemented module and checkpoint path listed in the automation manifest. Cross-language parity checks now validate module focus/teaching headers across tracks, and example output contracts validate stable learner-visible output for smoke-targeted examples.
191+
Documentation sync also validates that [CONCEPT_INDEX.md](CONCEPT_INDEX.md) covers every implemented module and checkpoint path listed in the automation manifest. Cross-language parity checks now validate module focus/teaching headers across tracks, and example plus exercise output contracts validate stable learner-visible output for smoke-targeted examples.
190192

191193
## Contributing
192194

scripts/automation_core/ops.py

Lines changed: 228 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ def build_parser() -> argparse.ArgumentParser:
6666
add_simple_command(
6767
subparsers, "check-example-output-contracts", handle_check_example_output_contracts
6868
)
69+
add_simple_command(
70+
subparsers, "check-exercise-output-contracts", handle_check_exercise_output_contracts
71+
)
6972
add_simple_command(
7073
subparsers, "check-cross-language-parity", handle_check_cross_language_parity
7174
)
@@ -126,6 +129,11 @@ def handle_check_example_output_contracts(ctx: RepoContext, _: argparse.Namespac
126129
return 0
127130

128131

132+
def handle_check_exercise_output_contracts(ctx: RepoContext, _: argparse.Namespace) -> int:
133+
check_exercise_output_contracts(ctx)
134+
return 0
135+
136+
129137
def handle_check_cross_language_parity(ctx: RepoContext, _: argparse.Namespace) -> int:
130138
check_cross_language_parity(ctx)
131139
return 0
@@ -1629,16 +1637,19 @@ def smoke_languages(ctx: RepoContext) -> None:
16291637
print("Multi-language smoke checks passed.")
16301638

16311639

1632-
def load_example_output_contracts(ctx: RepoContext) -> dict[str, list[dict[str, Any]]]:
1633-
contracts_path = ctx.scripts_dir / "example_output_contracts.json"
1640+
def load_output_contracts(
1641+
ctx: RepoContext, contracts_file: str, description: str
1642+
) -> dict[str, list[dict[str, Any]]]:
1643+
contracts_path = ctx.scripts_dir / contracts_file
16341644
if not contracts_path.is_file():
1635-
raise AutomationError(f"Missing output contracts file: {contracts_path}")
1645+
raise AutomationError(f"Missing {description} output contracts file: {contracts_path}")
16361646

16371647
payload = json.loads(contracts_path.read_text(encoding="utf-8"))
16381648
languages = payload.get("languages")
16391649
if not isinstance(languages, dict):
16401650
raise AutomationError(
1641-
f"{contracts_path}: expected top-level object with 'languages' mapping."
1651+
f"{contracts_path}: expected top-level object with 'languages' mapping "
1652+
f"for {description} contracts."
16421653
)
16431654
result: dict[str, list[dict[str, Any]]] = {}
16441655
for language, jobs in languages.items():
@@ -1650,6 +1661,92 @@ def load_example_output_contracts(ctx: RepoContext) -> dict[str, list[dict[str,
16501661
return result
16511662

16521663

1664+
def load_example_output_contracts(ctx: RepoContext) -> dict[str, list[dict[str, Any]]]:
1665+
return load_output_contracts(ctx, "example_output_contracts.json", "example")
1666+
1667+
1668+
def load_exercise_output_contracts(ctx: RepoContext) -> dict[str, list[dict[str, Any]]]:
1669+
return load_output_contracts(ctx, "exercise_output_contracts.json", "exercise")
1670+
1671+
1672+
def run_csharp_source_output_contracts(
1673+
ctx: RepoContext,
1674+
jobs: list[dict[str, Any]],
1675+
*,
1676+
label_prefix: str,
1677+
) -> int:
1678+
executed_jobs = 0
1679+
if not jobs:
1680+
return executed_jobs
1681+
1682+
with tempfile.TemporaryDirectory(prefix="csharp-source-output-contracts-") as temp_root:
1683+
temp_root_path = Path(temp_root)
1684+
for index, job in enumerate(jobs):
1685+
source_path = repo_path(ctx, job["program"])
1686+
if not source_path.is_file():
1687+
raise AutomationError(f"Missing C# contract source: {source_path}")
1688+
1689+
project_dir = temp_root_path / f"exercise-{index}"
1690+
project_dir.mkdir(parents=True, exist_ok=True)
1691+
project_path = project_dir / "exercise-check.csproj"
1692+
escaped_source = xml_escape(str(source_path.resolve()))
1693+
project_path.write_text(
1694+
"\n".join(
1695+
[
1696+
'<Project Sdk="Microsoft.NET.Sdk">',
1697+
" <PropertyGroup>",
1698+
" <OutputType>Exe</OutputType>",
1699+
" <TargetFramework>net8.0</TargetFramework>",
1700+
" <ImplicitUsings>disable</ImplicitUsings>",
1701+
" <Nullable>disable</Nullable>",
1702+
" <EnableDefaultCompileItems>false</EnableDefaultCompileItems>",
1703+
" </PropertyGroup>",
1704+
" <ItemGroup>",
1705+
f' <Compile Include="{escaped_source}" Link="Program.cs" />',
1706+
" </ItemGroup>",
1707+
"</Project>",
1708+
"",
1709+
]
1710+
),
1711+
encoding="utf-8",
1712+
)
1713+
run_command(
1714+
[
1715+
"dotnet",
1716+
"build",
1717+
str(project_path),
1718+
"--nologo",
1719+
"--verbosity",
1720+
"quiet",
1721+
"-p:UseAppHost=false",
1722+
],
1723+
quiet_stdout=True,
1724+
action=f"C# build for {label_prefix} output contract {job['program']}",
1725+
timeout_seconds=180,
1726+
)
1727+
1728+
run_target = project_dir / "bin" / "Debug" / "net8.0" / "exercise-check.dll"
1729+
capture_stdout = bool(
1730+
job.get("required_stdout_contains") or job.get("required_stdout_patterns")
1731+
)
1732+
completed = run_command(
1733+
["dotnet", str(run_target)],
1734+
capture_stdout=capture_stdout,
1735+
quiet_stdout=not capture_stdout,
1736+
action=f"C# execution for {label_prefix} output contract {job['program']}",
1737+
timeout_seconds=30,
1738+
)
1739+
if capture_stdout:
1740+
assert_output_contract(
1741+
completed.stdout or "",
1742+
job,
1743+
f"C# {label_prefix} output contract for {job['program']}",
1744+
)
1745+
executed_jobs += 1
1746+
1747+
return executed_jobs
1748+
1749+
16531750
def check_example_output_contracts(ctx: RepoContext) -> None:
16541751
contracts = load_example_output_contracts(ctx)
16551752
if not contracts:
@@ -1793,6 +1890,121 @@ def check_example_output_contracts(ctx: RepoContext) -> None:
17931890
print(f"Example output contracts passed for {executed_jobs} jobs.")
17941891

17951892

1893+
def check_exercise_output_contracts(ctx: RepoContext) -> None:
1894+
contracts = load_exercise_output_contracts(ctx)
1895+
if not contracts:
1896+
raise AutomationError("No exercise output contracts configured.")
1897+
1898+
executed_jobs = 0
1899+
python_cmd = find_python_command()
1900+
node_cmd = find_node_command()
1901+
1902+
for job in contracts.get("python", []):
1903+
smoke_runtime_job(
1904+
ctx,
1905+
job,
1906+
command_builder=lambda current_job, working_dir: [
1907+
python_cmd,
1908+
str(resolve_job_path(ctx, working_dir, current_job["program"])),
1909+
],
1910+
label=f"Python exercise output contract for {job['program']}",
1911+
)
1912+
executed_jobs += 1
1913+
1914+
for job in contracts.get("go", []):
1915+
smoke_runtime_job(
1916+
ctx,
1917+
job,
1918+
command_builder=lambda current_job, working_dir: [
1919+
"go",
1920+
"run",
1921+
*go_target_arguments(resolve_job_path(ctx, working_dir, current_job["program"])),
1922+
],
1923+
label=f"Go exercise output contract for {job['program']}",
1924+
)
1925+
executed_jobs += 1
1926+
1927+
if contracts.get("typescript"):
1928+
with tempfile.TemporaryDirectory(prefix="ts-exercise-output-contracts-") as temp_root:
1929+
temp_root_path = Path(temp_root)
1930+
compile_typescript(ctx, out_dir=temp_root_path)
1931+
for job in contracts.get("typescript", []):
1932+
smoke_runtime_job(
1933+
ctx,
1934+
job,
1935+
command_builder=lambda current_job, working_dir: [
1936+
node_cmd,
1937+
str(
1938+
typescript_output_path(
1939+
ctx,
1940+
temp_root_path,
1941+
resolve_job_path(ctx, working_dir, current_job["program"]),
1942+
)
1943+
),
1944+
],
1945+
label=f"TypeScript exercise output contract for {job['program']}",
1946+
)
1947+
executed_jobs += 1
1948+
1949+
executed_jobs += run_csharp_source_output_contracts(
1950+
ctx,
1951+
contracts.get("csharp", []),
1952+
label_prefix="exercise",
1953+
)
1954+
1955+
cpp_contracts = contracts.get("cpp", [])
1956+
if cpp_contracts:
1957+
toolchain = resolve_gpp_toolchain(ctx)
1958+
with tempfile.TemporaryDirectory(prefix="cpp-exercise-output-contracts-") as temp_root:
1959+
temp_root_path = Path(temp_root)
1960+
for index, job in enumerate(cpp_contracts):
1961+
source_path = repo_path(ctx, job["program"])
1962+
if not source_path.is_file():
1963+
raise AutomationError(f"Missing C++ contract source: {source_path}")
1964+
1965+
output_path = temp_root_path / f"cpp_exercise_contract_{index}"
1966+
compile_command = cpp_compile_command(ctx, toolchain, source_path, output_path)
1967+
compile_action = f"C++ compilation for exercise output contract {job['program']}"
1968+
if toolchain.mode == "wsl":
1969+
compile_action = (
1970+
f"C++ WSL compilation for exercise output contract {job['program']}"
1971+
)
1972+
run_command(compile_command, action=compile_action, timeout_seconds=120)
1973+
1974+
input_text = None
1975+
if "input_lines" in job:
1976+
input_text = "\n".join(job["input_lines"]) + "\n"
1977+
1978+
if toolchain.mode == "wsl":
1979+
binary_command = [
1980+
"wsl",
1981+
"bash",
1982+
"-lc",
1983+
shlex.quote(to_wsl_path(output_path)),
1984+
]
1985+
else:
1986+
binary_command = [str(compiled_binary_path(ctx, output_path))]
1987+
1988+
completed = run_command(
1989+
binary_command,
1990+
input_text=input_text,
1991+
capture_stdout=True,
1992+
action=f"C++ execution for exercise output contract {job['program']}",
1993+
timeout_seconds=30,
1994+
)
1995+
assert_output_contract(
1996+
completed.stdout or "",
1997+
job,
1998+
f"C++ exercise output contract for {job['program']}",
1999+
)
2000+
executed_jobs += 1
2001+
2002+
if executed_jobs == 0:
2003+
raise AutomationError("No exercise output contract jobs were executed.")
2004+
2005+
print(f"Exercise output contracts passed for {executed_jobs} jobs.")
2006+
2007+
17962008
def module_focus_comment(path: Path) -> tuple[str | None, str | None]:
17972009
lines = path.read_text(encoding="utf-8").splitlines()
17982010
header_comment_lines: list[str] = []
@@ -1943,31 +2155,34 @@ def check_cross_language_parity(ctx: RepoContext) -> None:
19432155
def verify_repo(ctx: RepoContext) -> None:
19442156
python_cmd = find_python_command()
19452157

1946-
print("[1/9] Checking markdown links...")
2158+
print("[1/10] Checking markdown links...")
19472159
run_command([python_cmd, str(ctx.scripts_dir / "check-links.py")], action="Markdown link check")
19482160

1949-
print("[2/9] Checking README structure...")
2161+
print("[2/10] Checking README structure...")
19502162
check_readme_structure(ctx)
19512163

1952-
print("[3/9] Checking module completeness...")
2164+
print("[3/10] Checking module completeness...")
19532165
check_module_completeness(ctx)
19542166

1955-
print("[4/9] Checking checkpoint completeness...")
2167+
print("[4/10] Checking checkpoint completeness...")
19562168
check_checkpoint_completeness(ctx)
19572169

1958-
print("[5/9] Checking documentation sync...")
2170+
print("[5/10] Checking documentation sync...")
19592171
check_doc_sync(ctx)
19602172

1961-
print("[6/9] Checking example comments...")
2173+
print("[6/10] Checking example comments...")
19622174
check_example_comments(ctx)
19632175

1964-
print("[7/9] Checking cross-language parity...")
2176+
print("[7/10] Checking cross-language parity...")
19652177
check_cross_language_parity(ctx)
19662178

1967-
print("[8/9] Checking example output contracts...")
2179+
print("[8/10] Checking example output contracts...")
19682180
check_example_output_contracts(ctx)
19692181

1970-
print("[9/9] Compiling compiled-language tracks...")
2182+
print("[9/10] Checking exercise output contracts...")
2183+
check_exercise_output_contracts(ctx)
2184+
2185+
print("[10/10] Compiling compiled-language tracks...")
19712186
build_all(ctx)
19722187

19732188
print("Repository verification completed successfully.")
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
$ErrorActionPreference = "Stop"
2+
3+
$root = Split-Path -Parent $PSScriptRoot
4+
Set-Location $root
5+
6+
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
7+
if (-not $pythonCmd) {
8+
$pythonCmd = Get-Command python3 -ErrorAction SilentlyContinue
9+
}
10+
11+
if (-not $pythonCmd) {
12+
Write-Host "Python was not found in PATH."
13+
exit 1
14+
}
15+
16+
& $pythonCmd.Path "$PSScriptRoot\automation.py" check-exercise-output-contracts
17+
if ($LASTEXITCODE -ne 0) {
18+
exit $LASTEXITCODE
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
cd "$ROOT_DIR"
6+
7+
if command -v python >/dev/null 2>&1; then
8+
PYTHON_BIN="python"
9+
elif command -v python3 >/dev/null 2>&1; then
10+
PYTHON_BIN="python3"
11+
else
12+
echo "Required command not found: python or python3"
13+
exit 1
14+
fi
15+
16+
"$PYTHON_BIN" ./scripts/automation.py check-exercise-output-contracts

0 commit comments

Comments
 (0)