Skip to content

Commit 86225e7

Browse files
committed
Harden repo validation and smoke checks
1 parent 4a5733d commit 86225e7

8 files changed

Lines changed: 148 additions & 14 deletions

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ Run multi-language smoke checks with:
5454
- `./scripts/smoke-languages.ps1` (PowerShell)
5555
- `bash ./scripts/smoke-languages.sh` (Bash)
5656

57+
These smoke checks also compile standalone C# exercises by generating temporary validation projects during the check.
58+
5759
4. Update related README files when behavior or structure changes.
5860
5. Open a pull request with a clear description of what changed and why.
5961

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ bash ./scripts/verify-repo.sh
126126

127127
GitHub Actions validates links, README structure, module completeness, checkpoint completeness, C++ build, and multi-language smoke checks on Linux and Windows.
128128

129+
The multi-language smoke scripts also compile standalone C# exercises by generating temporary validation projects during the check.
130+
129131
## Contributing
130132

131133
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution workflow and documentation requirements.

scripts/check-module-completeness.ps1

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ $languageExtensions = @{
2020
python = "py"
2121
}
2222

23+
function Get-HeadingLineNumber([string[]]$lines, [string]$heading) {
24+
$escapedHeading = [regex]::Escape($heading)
25+
for ($i = 0; $i -lt $lines.Count; $i++) {
26+
if ($lines[$i] -match "^[ ]{0,3}$escapedHeading[ ]*$") {
27+
return $i + 1
28+
}
29+
}
30+
31+
return -1
32+
}
33+
2334
$failures = @()
2435
$moduleCount = 0
2536

@@ -71,15 +82,15 @@ foreach ($language in $languageExtensions.Keys) {
7182
$failures += "$($module.FullName): missing exercises/02.$ext"
7283
}
7384

74-
$content = Get-Content -Path $readmePath -Raw
85+
$lines = Get-Content -Path $readmePath
7586
$positions = @{}
7687
$missingHeadings = @()
7788
foreach ($heading in $requiredHeadings) {
78-
$index = $content.IndexOf($heading)
79-
if ($index -lt 0) {
89+
$lineNumber = Get-HeadingLineNumber -lines $lines -heading $heading
90+
if ($lineNumber -lt 0) {
8091
$missingHeadings += $heading
8192
} else {
82-
$positions[$heading] = $index
93+
$positions[$heading] = $lineNumber
8394
}
8495
}
8596

scripts/check-module-completeness.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ ext_for_language() {
2525
esac
2626
}
2727

28+
heading_line_number() {
29+
local heading="$1"
30+
local file="$2"
31+
grep -nE "^[[:space:]]{0,3}${heading}[[:space:]]*$" "$file" | head -n1 | cut -d: -f1 || true
32+
}
33+
2834
failures=0
2935
module_count=0
3036

@@ -77,7 +83,7 @@ for language in cpp csharp go python; do
7783
missing=()
7884
lines=()
7985
for heading in "${required_headings[@]}"; do
80-
line="$(grep -nF "$heading" "$readme" | head -n1 | cut -d: -f1 || true)"
86+
line="$(heading_line_number "$heading" "$readme")"
8187
if [[ -z "$line" ]]; then
8288
missing+=("$heading")
8389
else

scripts/check-readme-structure.ps1

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ $requiredHeadings = @(
1515
$languageNames = @("cpp", "csharp", "go", "python")
1616
$levelNames = @("01-foundations", "02-core", "03-advanced", "04-expert")
1717

18+
function Get-HeadingLineNumber([string[]]$lines, [string]$heading) {
19+
$escapedHeading = [regex]::Escape($heading)
20+
for ($i = 0; $i -lt $lines.Count; $i++) {
21+
if ($lines[$i] -match "^[ ]{0,3}$escapedHeading[ ]*$") {
22+
return $i + 1
23+
}
24+
}
25+
26+
return -1
27+
}
28+
1829
$moduleReadmes = foreach ($language in $languageNames) {
1930
foreach ($level in $levelNames) {
2031
$levelPath = Join-Path $root "languages/$language/$level"
@@ -38,16 +49,16 @@ if ($moduleReadmes.Count -eq 0) {
3849
$failures = @()
3950

4051
foreach ($file in $moduleReadmes) {
41-
$content = Get-Content -Path $file -Raw
52+
$lines = Get-Content -Path $file
4253
$missing = @()
4354
$positions = @{}
4455

4556
foreach ($heading in $requiredHeadings) {
46-
$index = $content.IndexOf($heading)
47-
if ($index -lt 0) {
57+
$lineNumber = Get-HeadingLineNumber -lines $lines -heading $heading
58+
if ($lineNumber -lt 0) {
4859
$missing += $heading
4960
} else {
50-
$positions[$heading] = $index
61+
$positions[$heading] = $lineNumber
5162
}
5263
}
5364

scripts/check-readme-structure.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ required_headings=(
1616
languages=("cpp" "csharp" "go" "python")
1717
levels=("01-foundations" "02-core" "03-advanced" "04-expert")
1818

19+
heading_line_number() {
20+
local heading="$1"
21+
local file="$2"
22+
grep -nE "^[[:space:]]{0,3}${heading}[[:space:]]*$" "$file" | head -n1 | cut -d: -f1 || true
23+
}
24+
1925
module_readmes=()
2026
for language in "${languages[@]}"; do
2127
for level in "${levels[@]}"; do
@@ -40,7 +46,7 @@ for file in "${module_readmes[@]}"; do
4046
lines=()
4147

4248
for heading in "${required_headings[@]}"; do
43-
line="$(grep -nF "$heading" "$file" | head -n1 | cut -d: -f1 || true)"
49+
line="$(heading_line_number "$heading" "$file")"
4450
if [[ -z "$line" ]]; then
4551
missing+=("$heading")
4652
else

scripts/smoke-languages.ps1

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,34 @@ $ErrorActionPreference = "Stop"
33
$root = Split-Path -Parent $PSScriptRoot
44
Set-Location $root
55

6+
function Convert-ToXmlAttributeValue([string]$value) {
7+
return $value.Replace("&", "&amp;").Replace('"', "&quot;").Replace("<", "&lt;").Replace(">", "&gt;")
8+
}
9+
10+
function Test-CSharpExerciseFile([string]$exercisePath, [string]$tempRoot, [int]$index) {
11+
$projectDir = Join-Path $tempRoot ("exercise-" + $index)
12+
New-Item -Path $projectDir -ItemType Directory | Out-Null
13+
14+
$escapedExercisePath = Convert-ToXmlAttributeValue ([System.IO.Path]::GetFullPath($exercisePath))
15+
$projectPath = Join-Path $projectDir "exercise-check.csproj"
16+
@"
17+
<Project Sdk="Microsoft.NET.Sdk">
18+
<PropertyGroup>
19+
<OutputType>Exe</OutputType>
20+
<TargetFramework>net8.0</TargetFramework>
21+
<ImplicitUsings>disable</ImplicitUsings>
22+
<Nullable>disable</Nullable>
23+
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
24+
</PropertyGroup>
25+
<ItemGroup>
26+
<Compile Include="$escapedExercisePath" Link="Program.cs" />
27+
</ItemGroup>
28+
</Project>
29+
"@ | Set-Content -Path $projectPath
30+
31+
dotnet build $projectPath --nologo --verbosity quiet | Out-Null
32+
}
33+
634
Write-Host "[1/6] Python syntax check..."
735
python -m compileall -q languages/python
836

@@ -149,6 +177,25 @@ foreach ($project in $projects) {
149177
dotnet build $project.FullName --nologo --verbosity quiet
150178
}
151179

180+
$csharpExerciseTempRoot = Join-Path $env:TEMP ("csharp-exercise-smoke-" + [Guid]::NewGuid().ToString("N"))
181+
New-Item -Path $csharpExerciseTempRoot -ItemType Directory | Out-Null
182+
try {
183+
$exerciseIndex = 0
184+
$exerciseFiles = Get-ChildItem -Path languages/csharp -Recurse -Filter *.cs |
185+
Where-Object { $_.FullName -like "*\exercises\*" } |
186+
Sort-Object FullName
187+
188+
foreach ($exercise in $exerciseFiles) {
189+
Test-CSharpExerciseFile -exercisePath $exercise.FullName -tempRoot $csharpExerciseTempRoot -index $exerciseIndex
190+
$exerciseIndex++
191+
}
192+
}
193+
finally {
194+
if (Test-Path $csharpExerciseTempRoot) {
195+
Remove-Item -Path $csharpExerciseTempRoot -Recurse -Force
196+
}
197+
}
198+
152199
Write-Host "[6/6] C# runtime smoke..."
153200
dotnet run --project languages/csharp/01-foundations/functions/example/functions-example.csproj | Out-Null
154201
dotnet run --project languages/csharp/01-foundations/formatted-output-and-iomanip/example/formatted-output-and-iomanip-example.csproj | Out-Null

scripts/smoke-languages.sh

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,43 @@ require_cmd() {
1212
fi
1313
}
1414

15+
xml_escape() {
16+
local value="$1"
17+
value="${value//&/&amp;}"
18+
value="${value//\"/&quot;}"
19+
value="${value//</&lt;}"
20+
value="${value//>/&gt;}"
21+
printf '%s' "$value"
22+
}
23+
24+
build_csharp_exercise() {
25+
local exercise_path="$1"
26+
local temp_root="$2"
27+
local index="$3"
28+
local project_dir="$temp_root/exercise-$index"
29+
local escaped_path
30+
31+
escaped_path="$(xml_escape "$(realpath "$exercise_path")")"
32+
mkdir -p "$project_dir"
33+
34+
cat > "$project_dir/exercise-check.csproj" <<EOF
35+
<Project Sdk="Microsoft.NET.Sdk">
36+
<PropertyGroup>
37+
<OutputType>Exe</OutputType>
38+
<TargetFramework>net8.0</TargetFramework>
39+
<ImplicitUsings>disable</ImplicitUsings>
40+
<Nullable>disable</Nullable>
41+
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
42+
</PropertyGroup>
43+
<ItemGroup>
44+
<Compile Include="$escaped_path" Link="Program.cs" />
45+
</ItemGroup>
46+
</Project>
47+
EOF
48+
49+
dotnet build "$project_dir/exercise-check.csproj" --nologo --verbosity quiet >/dev/null
50+
}
51+
1552
if command -v python >/dev/null 2>&1; then
1653
PYTHON_BIN="python"
1754
elif command -v python3 >/dev/null 2>&1; then
@@ -23,6 +60,15 @@ fi
2360

2461
require_cmd go
2562
require_cmd dotnet
63+
require_cmd realpath
64+
65+
tmp_dir=""
66+
csharp_exercise_tmp_dir=""
67+
cleanup() {
68+
[[ -n "${tmp_dir:-}" ]] && rm -rf "$tmp_dir"
69+
[[ -n "${csharp_exercise_tmp_dir:-}" ]] && rm -rf "$csharp_exercise_tmp_dir"
70+
}
71+
trap cleanup EXIT
2672

2773
echo "[1/6] Python syntax check..."
2874
"$PYTHON_BIN" -m compileall -q languages/python
@@ -62,10 +108,6 @@ EOF
62108

63109
echo "[3/6] Go compile check..."
64110
tmp_dir="$(mktemp -d)"
65-
cleanup() {
66-
rm -rf "$tmp_dir"
67-
}
68-
trap cleanup EXIT
69111

70112
idx=0
71113
while IFS= read -r file; do
@@ -111,6 +153,13 @@ while IFS= read -r project; do
111153
dotnet build "$project" --nologo --verbosity quiet
112154
done < <(find languages/csharp -type f -name "*.csproj" | sort)
113155

156+
csharp_exercise_tmp_dir="$(mktemp -d)"
157+
exercise_idx=0
158+
while IFS= read -r exercise; do
159+
build_csharp_exercise "$exercise" "$csharp_exercise_tmp_dir" "$exercise_idx"
160+
exercise_idx=$((exercise_idx + 1))
161+
done < <(find languages/csharp -type f -path "*/exercises/*.cs" | sort)
162+
114163
echo "[6/6] C# runtime smoke..."
115164
dotnet run --project languages/csharp/01-foundations/functions/example/functions-example.csproj >/dev/null
116165
dotnet run --project languages/csharp/01-foundations/formatted-output-and-iomanip/example/formatted-output-and-iomanip-example.csproj >/dev/null

0 commit comments

Comments
 (0)