Skip to content

Commit e3067cd

Browse files
committed
Add cross-language exercise parity validation
1 parent 12d15ac commit e3067cd

5 files changed

Lines changed: 218 additions & 11 deletions

File tree

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ Validate cross-language parity with:
5050
- `./scripts/check-cross-language-parity.ps1` (PowerShell)
5151
- `bash ./scripts/check-cross-language-parity.sh` (Bash)
5252

53+
Validate exercise parity with:
54+
55+
- `./scripts/check-exercise-parity.ps1` (PowerShell)
56+
- `bash ./scripts/check-exercise-parity.sh` (Bash)
57+
5358
Validate example output contracts with:
5459

5560
- `./scripts/check-example-output-contracts.ps1` (PowerShell)

README.md

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

189191
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.
190192

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.
193+
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, exercise parity checks validate `exercises/01` and `exercises/02` alignment across tracks, and example plus exercise output contracts validate stable learner-visible output for smoke-targeted programs.
192194

193195
## Contributing
194196

scripts/automation_core/ops.py

Lines changed: 175 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ def build_parser() -> argparse.ArgumentParser:
6969
add_simple_command(
7070
subparsers, "check-exercise-output-contracts", handle_check_exercise_output_contracts
7171
)
72+
add_simple_command(subparsers, "check-exercise-parity", handle_check_exercise_parity)
7273
add_simple_command(
7374
subparsers, "check-cross-language-parity", handle_check_cross_language_parity
7475
)
@@ -134,6 +135,11 @@ def handle_check_exercise_output_contracts(ctx: RepoContext, _: argparse.Namespa
134135
return 0
135136

136137

138+
def handle_check_exercise_parity(ctx: RepoContext, _: argparse.Namespace) -> int:
139+
check_exercise_parity(ctx)
140+
return 0
141+
142+
137143
def handle_check_cross_language_parity(ctx: RepoContext, _: argparse.Namespace) -> int:
138144
check_cross_language_parity(ctx)
139145
return 0
@@ -2005,6 +2011,162 @@ def check_exercise_output_contracts(ctx: RepoContext) -> None:
20052011
print(f"Exercise output contracts passed for {executed_jobs} jobs.")
20062012

20072013

2014+
def exercise_contract_key(
2015+
target: str, *, language: str, extension: str
2016+
) -> tuple[str, str, str] | None:
2017+
normalized = target.replace("\\", "/")
2018+
pattern = (
2019+
rf"^languages/{re.escape(language)}/([^/]+)/([^/]+)/exercises/(0[12])\."
2020+
rf"{re.escape(extension)}$"
2021+
)
2022+
match = re.match(pattern, normalized)
2023+
if not match:
2024+
return None
2025+
return match.group(1), match.group(2), match.group(3)
2026+
2027+
2028+
def check_exercise_parity(ctx: RepoContext) -> None:
2029+
failures: list[str] = []
2030+
languages = ["cpp", "csharp", "go", "python", "typescript"]
2031+
extension_map = {
2032+
language: ctx.manifest.languages[language]["extension"] for language in languages
2033+
}
2034+
2035+
for level, modules in ctx.manifest.module_order.items():
2036+
for module in modules:
2037+
for language in languages:
2038+
if level not in ctx.manifest.languages[language].get("module_levels", []):
2039+
continue
2040+
extension = extension_map[language]
2041+
for exercise_id in ("01", "02"):
2042+
exercise_path = (
2043+
ctx.root
2044+
/ "languages"
2045+
/ language
2046+
/ level
2047+
/ module
2048+
/ "exercises"
2049+
/ f"{exercise_id}.{extension}"
2050+
)
2051+
if not exercise_path.is_file():
2052+
failures.append(f"{exercise_path}: missing exercise file for parity check")
2053+
2054+
contracts = load_exercise_output_contracts(ctx)
2055+
expected_levels = set(ctx.manifest.module_order.keys())
2056+
expected_modules = {
2057+
(level, module)
2058+
for level, modules in ctx.manifest.module_order.items()
2059+
for module in modules
2060+
}
2061+
contract_keys_by_language: dict[str, set[tuple[str, str, str]]] = {}
2062+
2063+
unknown_languages = sorted(language for language in contracts if language not in languages)
2064+
for language in unknown_languages:
2065+
failures.append(
2066+
f"scripts/exercise_output_contracts.json: unexpected language key '{language}'"
2067+
)
2068+
2069+
for language in languages:
2070+
jobs = contracts.get(language, [])
2071+
if not jobs:
2072+
failures.append(
2073+
f"scripts/exercise_output_contracts.json: no contracts configured for {language}"
2074+
)
2075+
contract_keys_by_language[language] = set()
2076+
continue
2077+
2078+
extension = extension_map[language]
2079+
keys: set[tuple[str, str, str]] = set()
2080+
for job in jobs:
2081+
target = job.get("program")
2082+
if not target:
2083+
failures.append(
2084+
"scripts/exercise_output_contracts.json: "
2085+
f"{language} contract missing 'program' field"
2086+
)
2087+
continue
2088+
2089+
target_path = repo_path(ctx, target)
2090+
if not target_path.is_file():
2091+
failures.append(
2092+
"scripts/exercise_output_contracts.json: "
2093+
f"{language} contract target does not exist -> {target}"
2094+
)
2095+
2096+
key = exercise_contract_key(target, language=language, extension=extension)
2097+
if key is None:
2098+
failures.append(
2099+
"scripts/exercise_output_contracts.json: "
2100+
f"{language} contract target must be an exercises/01|02 file -> {target}"
2101+
)
2102+
continue
2103+
2104+
level, module, exercise_id = key
2105+
if level not in expected_levels:
2106+
failures.append(
2107+
"scripts/exercise_output_contracts.json: "
2108+
f"{language} contract uses unknown level '{level}' -> {target}"
2109+
)
2110+
if (level, module) not in expected_modules:
2111+
failures.append(
2112+
"scripts/exercise_output_contracts.json: "
2113+
f"{language} contract uses unknown module '{module}' in level '{level}' -> "
2114+
f"{target}"
2115+
)
2116+
if exercise_id not in {"01", "02"}:
2117+
failures.append(
2118+
"scripts/exercise_output_contracts.json: "
2119+
f"{language} contract exercise id must be 01 or 02 -> {target}"
2120+
)
2121+
if key in keys:
2122+
failures.append(
2123+
"scripts/exercise_output_contracts.json: "
2124+
f"duplicate {language} contract key {level}/{module}/exercises/{exercise_id}"
2125+
)
2126+
keys.add(key)
2127+
2128+
if not job.get("required_stdout_contains") and not job.get("required_stdout_patterns"):
2129+
failures.append(
2130+
"scripts/exercise_output_contracts.json: "
2131+
f"{language} contract has no stdout expectations -> {target}"
2132+
)
2133+
2134+
contract_keys_by_language[language] = keys
2135+
2136+
baseline_language = next(
2137+
(language for language in languages if contract_keys_by_language[language]),
2138+
None,
2139+
)
2140+
if baseline_language is None:
2141+
failures.append(
2142+
"scripts/exercise_output_contracts.json: no exercise contracts parsed for any language"
2143+
)
2144+
else:
2145+
baseline_keys = contract_keys_by_language[baseline_language]
2146+
for language in languages:
2147+
current_keys = contract_keys_by_language[language]
2148+
missing = sorted(baseline_keys - current_keys)
2149+
extra = sorted(current_keys - baseline_keys)
2150+
for level, module, exercise_id in missing:
2151+
failures.append(
2152+
"scripts/exercise_output_contracts.json: "
2153+
f"{language} missing contract for {level}/{module}/exercises/{exercise_id}"
2154+
)
2155+
for level, module, exercise_id in extra:
2156+
failures.append(
2157+
"scripts/exercise_output_contracts.json: "
2158+
f"{language} has extra contract for {level}/{module}/exercises/{exercise_id}"
2159+
)
2160+
2161+
if failures:
2162+
print("Exercise parity validation failed:")
2163+
for failure in failures:
2164+
print(f" - {failure}")
2165+
raise AutomationError("Exercise parity validation failed.")
2166+
2167+
print("Exercise parity validation passed.")
2168+
2169+
20082170
def module_focus_comment(path: Path) -> tuple[str | None, str | None]:
20092171
lines = path.read_text(encoding="utf-8").splitlines()
20102172
header_comment_lines: list[str] = []
@@ -2155,34 +2317,37 @@ def check_cross_language_parity(ctx: RepoContext) -> None:
21552317
def verify_repo(ctx: RepoContext) -> None:
21562318
python_cmd = find_python_command()
21572319

2158-
print("[1/10] Checking markdown links...")
2320+
print("[1/11] Checking markdown links...")
21592321
run_command([python_cmd, str(ctx.scripts_dir / "check-links.py")], action="Markdown link check")
21602322

2161-
print("[2/10] Checking README structure...")
2323+
print("[2/11] Checking README structure...")
21622324
check_readme_structure(ctx)
21632325

2164-
print("[3/10] Checking module completeness...")
2326+
print("[3/11] Checking module completeness...")
21652327
check_module_completeness(ctx)
21662328

2167-
print("[4/10] Checking checkpoint completeness...")
2329+
print("[4/11] Checking checkpoint completeness...")
21682330
check_checkpoint_completeness(ctx)
21692331

2170-
print("[5/10] Checking documentation sync...")
2332+
print("[5/11] Checking documentation sync...")
21712333
check_doc_sync(ctx)
21722334

2173-
print("[6/10] Checking example comments...")
2335+
print("[6/11] Checking example comments...")
21742336
check_example_comments(ctx)
21752337

2176-
print("[7/10] Checking cross-language parity...")
2338+
print("[7/11] Checking cross-language parity...")
21772339
check_cross_language_parity(ctx)
21782340

2179-
print("[8/10] Checking example output contracts...")
2341+
print("[8/11] Checking exercise parity...")
2342+
check_exercise_parity(ctx)
2343+
2344+
print("[9/11] Checking example output contracts...")
21802345
check_example_output_contracts(ctx)
21812346

2182-
print("[9/10] Checking exercise output contracts...")
2347+
print("[10/11] Checking exercise output contracts...")
21832348
check_exercise_output_contracts(ctx)
21842349

2185-
print("[10/10] Compiling compiled-language tracks...")
2350+
print("[11/11] Compiling compiled-language tracks...")
21862351
build_all(ctx)
21872352

21882353
print("Repository verification completed successfully.")

scripts/check-exercise-parity.ps1

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-parity
17+
if ($LASTEXITCODE -ne 0) {
18+
exit $LASTEXITCODE
19+
}

scripts/check-exercise-parity.sh

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-parity

0 commit comments

Comments
 (0)