Merge branch 'phase/49-skip-stale-build'

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-20 08:51:57 +03:00
co-authored by Cursor
9 changed files with 497 additions and 16 deletions
+13 -5
View File
@@ -49,12 +49,14 @@ npm --prefix src/HSchool.Client run build
dotnet run --project src/HSchool.AppHost dotnet run --project src/HSchool.AppHost
``` ```
`run-aspire.cmd` is the same command for Windows users who want a double-clickable entry point — `run-aspire.cmd` is the Windows entry point: it skips MSBuild when AppHost and Server dlls are
keep the two in sync if the AppHost path ever moves. newer than C# / csproj / props. Pass `--rebuild` to force a build. Keep the script in sync with
`tools/apphost-uptodate.ps1` if the AppHost path ever moves.
`dotnet run --project src/HSchool.AppHost` starts the server *and* the Vite dev server and opens `dotnet run --project src/HSchool.AppHost` still compiles every time — that is for a dirty tree,
the Aspire dashboard. The Vite port is assigned per run (`npm run dev -- --port <random>`), so read not a fast relaunch. It starts the server *and* the Vite dev server and opens the Aspire
the client URL off the dashboard instead of assuming 5173. dashboard. The Vite port is assigned per run (`npm run dev -- --port <random>`), so read the
client URL off the dashboard instead of assuming 5173.
Do not start a dev server with a bare `npm run dev` when you meant to run the whole app — the Do not start a dev server with a bare `npm run dev` when you meant to run the whole app — the
client only finds the backend through the Aspire-injected `SERVER_HTTP` environment variable, or client only finds the backend through the Aspire-injected `SERVER_HTTP` environment variable, or
@@ -131,6 +133,8 @@ say so explicitly in the change description.
is empty. Reset deletes through the API, which deletes the save files. Headless AppHost is empty. Reset deletes through the API, which deletes the save files. Headless AppHost
sets `HSchool:AllowSaveReload` so tests can `POST /api/dev/reload-schools` without killing sets `HSchool:AllowSaveReload` so tests can `POST /api/dev/reload-schools` without killing
the shared fixture. the shared fixture.
- Launch-script up-to-date checks belong in `tests/HSchool.AppHost.Tests` and must not use
`AppHostFixture`. Keep `LaunchBuildStamp` and `tools/apphost-uptodate.ps1` in sync.
- Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the - Screen logic is covered in Vitest under happy-dom (`ui/*.test.ts`): people filters and the
pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and pager, the create dialog (core stays on, map reset, submit busy), planner rejection text, and
the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries the payroll-cap message. Layout and styles are still verified by running the app. Dictionaries
@@ -185,3 +189,7 @@ say so explicitly in the change description.
the clock does not advance, so occupancy (often empty) and lesson labels from the timetable the clock does not advance, so occupancy (often empty) and lesson labels from the timetable
keep going out. Opening sends one extra snapshot. A test that waits for *people* on pause keep going out. Opening sends one extra snapshot. A test that waits for *people* on pause
will hang; a test that waits for a lesson label will not. will hang; a test that waits for a lesson label will not.
- **`dotnet run` on the AppHost always evaluates MSBuild**, even when nothing changed.
`run-aspire.cmd` skips that with `--no-build` when AppHost and Server dlls are newer than
C# / csproj / props. `--rebuild` forces a build. Bare `dotnet run --project src/HSchool.AppHost`
still compiles.
+2 -1
View File
@@ -32,7 +32,8 @@ dotnet run --project src/HSchool.AppHost
On Windows `run-aspire.cmd` does the same and can be double-clicked; it checks that the .NET SDK On Windows `run-aspire.cmd` does the same and can be double-clicked; it checks that the .NET SDK
and Node are on PATH first and passes any arguments through and Node are on PATH first and passes any arguments through
(`run-aspire.cmd --launch-profile http`). (`run-aspire.cmd --launch-profile http`). A second launch with no C# changes skips MSBuild;
`run-aspire.cmd --rebuild` forces a build.
The Aspire dashboard opens with two resources: `server` (ASP.NET Core) and `client` (Vite dev The Aspire dashboard opens with two resources: `server` (ASP.NET Core) and `client` (Vite dev
server). Aspire assigns the client a random port on every run, so take its URL from the dashboard server). Aspire assigns the client a random port on every run, so take its URL from the dashboard
+31 -1
View File
@@ -1,7 +1,7 @@
# Вне очереди # Вне очереди
Задачи, которых нет в нумерованных срезах. Сюда не дописывают срез 9 и не заводят срез 10: Задачи, которых нет в нумерованных срезах. Сюда не дописывают срез 9 и не заводят срез 10:
это склейка уже живых систем, после любой фазы школа целая. склейка уже живых систем и мелкий DX. После любой фазы школа целая.
Люди: [`people.md`](people.md). Штат: [`staffing.md`](staffing.md). Расписание: Люди: [`people.md`](people.md). Штат: [`staffing.md`](staffing.md). Расписание:
[`schedule.md`](schedule.md). Присутствие: [`ai.md`](ai.md). Одежда и климат: [`schedule.md`](schedule.md). Присутствие: [`ai.md`](ai.md). Одежда и климат:
@@ -88,3 +88,33 @@
| Тепло | Рядом с голодом, произведение двух `NeedFactor` | | Тепло | Рядом с голодом, произведение двух `NeedFactor` |
| Учебник | Только сумка, не шкафчик и не дом; ваниль ×0.5 | | Учебник | Только сумка, не шкафчик и не дом; ваниль ×0.5 |
| Погода на дорогу | Минуты в `BehaviorDef`; лога нет | | Погода на дорогу | Минуты в `BehaviorDef`; лога нет |
## Пропуск сборки при запуске
### Зачем
`run-aspire.cmd` каждый раз вызывает `dotnet run`, а тот — MSBuild графа Aspire, даже когда
исходники не менялись. Повторный запуск после Ctrl+C стоит десятки секунд впустую: SDK
пересчитывает хост, хотя dll на месте.
### Было / Стало / Почему
**Было.** Скрипт всегда `dotnet run --project src/HSchool.AppHost`. Аргументы проходили насквозь.
**Стало.** Если `HSchool.AppHost.dll` и `HSchool.Server.dll` новее входов (C#, csproj, props,
`global.json`), скрипт делает `dotnet run --no-build`. Иначе — `dotnet build`, потом `--no-build`.
`--rebuild` собирает всегда. Конфигурация (`-c` / `--configuration`) выбирает, какие dll сравнивать;
по умолчанию Debug, как у `dotnet run`.
**Почему.** Инкрементальный MSBuild всё равно оценивает Aspire SDK. `--no-build` это пропускает.
Клиентский TypeScript и JSONC модов не входы: Vite и каталог читают диск (`ContentRoot`), а не
`bin`. Тестовые проекты AppHost не ссылается — их правки запуск не пересобирают.
Правила «устарел ли билд» живут в `tools/apphost-uptodate.ps1` (то, что вызывает `.cmd`) и в
`LaunchBuildStamp` (то, что проверяют тесты). Менять надо оба.
### Что не входит
Голый `dotnet run --project src/HSchool.AppHost` по-прежнему собирает — так удобнее агентам с
грязным деревом. Linux-обёртки нет: точка входа Windows — `.cmd`. Протокол, HTTP и сейв не
трогаем.
+45
View File
@@ -0,0 +1,45 @@
# Фаза 49. Пропуск сборки при запуске
## Зависимости
Нет. Скрипт запуска уже есть.
## Зачем
Повторный `run-aspire.cmd` без правок в C# не должен ждать MSBuild графа Aspire. Сборка нужна
только когда входы новее dll или когда просили `--rebuild`.
## Задачи
- [x] `tools/apphost-uptodate.ps1`: выход 0, если AppHost и Server dll новее входов; 1 — если
нет dll или вход новее. Входы: `*.cs` / `*.csproj` / `*.props` под `src/` без `bin` /
`obj` / `node_modules`, плюс `global.json`, `Directory.Build.props`, `Directory.Packages.props`.
TypeScript и JSONC не входы
- [x] `run-aspire.cmd` вызывает проверку; при 0 — `dotnet run --no-build` и пишет, что сборка
пропущена; иначе `dotnet build`, затем `--no-build`. `--rebuild` всегда собирает и не
уходит в AppHost. `-c` / `--configuration` выбирают папку `bin`
- [x] `LaunchBuildStamp` с теми же правилами, тесты на временных папках без `AppHostFixture`
- [x] `AGENTS.md` и `README.md`: повторный запуск без правок — `run-aspire.cmd`; голый
`dotnet run` по-прежнему собирает. `--rebuild` упомянут
## Тесты, без которых фаза не закрыта
- [x] Нет dll — нужно собирать
- [x] Dll новее всех входов — пропускать
- [x] `.cs` новее dll — собирать
- [x] `.ts` или `.jsonc` новее dll — пропускать
- [x] Файл в `bin` / `obj` / `node_modules` не считается входом
- [x] `Directory.Packages.props` новее dll — собирать
- [x] `-c Release` смотрит `bin/Release`, а не Debug
## Критерий готовности
- Два запуска `run-aspire.cmd` подряд без правок: второй пишет, что сборка пропущена, и
стартует AppHost
- `run-aspire.cmd --rebuild` собирает даже со свежими dll
- `dotnet test` проходит; тесты штампа не поднимают хост
## Стоп
Не менять Aspire-граф, протокол, сейвы. Не вшивать `--no-build` в голый `dotnet run`. Не
сканировать `tests/` и не считать клиентский TS поводом пересобрать сервер.
+4 -2
View File
@@ -227,12 +227,14 @@
Дизайн: [`../design/off-queue.md`](../design/off-queue.md). Дизайн: [`../design/off-queue.md`](../design/off-queue.md).
Склейка уже живых систем, не новый кусок игры. Номера не спорят со срезом 10. После каждой Склейка уже живых систем и мелкий DX, не новый кусок игры. Номера не спорят со срезом 10.
фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно с 48. После каждой игровой фазы школа целая. 50–52 стоят на 48; 53 — на 18 и 32, можно параллельно
с 48. 49 от них не зависит.
| Фаза | Статус | Зачем | | Фаза | Статус | Зачем |
| --- | --- | --- | | --- | --- | --- |
| [48. Учитель в кабинете](48-lesson-teacher-present.md) | ✅ | Нет учителя на месте — нет роста навыка | | [48. Учитель в кабинете](48-lesson-teacher-present.md) | ✅ | Нет учителя на месте — нет роста навыка |
| [49. Пропуск сборки при запуске](49-skip-stale-build.md) | ✅ | `run-aspire.cmd` не гоняет MSBuild, если dll свежие |
| [50. Навык учителя](50-teacher-skill-gain.md) | 🔄 | Соискатель 78 учит лучше, чем 41 | | [50. Навык учителя](50-teacher-skill-gain.md) | 🔄 | Соискатель 78 учит лучше, чем 41 |
| [51. Тепло на уроке](51-warmth-lesson.md) | ⬜ | Замёрзший учится хуже, как голодный | | [51. Тепло на уроке](51-warmth-lesson.md) | ⬜ | Замёрзший учится хуже, как голодный |
| [52. Учебник на уроке](52-textbook-lesson.md) | ⬜ | Нет в сумке — половинный рост | | [52. Учебник на уроке](52-textbook-lesson.md) | ⬜ | Нет в сумке — половинный рост |
+60 -7
View File
@@ -1,8 +1,10 @@
@echo off @echo off
setlocal setlocal EnableDelayedExpansion
rem Starts the whole app: game server, Vite client and the Aspire dashboard. rem Starts the whole app: game server, Vite client and the Aspire dashboard.
rem Arguments are passed through, e.g. run-aspire.cmd --launch-profile http rem Skips MSBuild when AppHost and Server dlls are newer than C# / csproj / props.
rem run-aspire.cmd --launch-profile http
rem run-aspire.cmd --rebuild
cd /d "%~dp0" cd /d "%~dp0"
@@ -21,19 +23,70 @@ if errorlevel 1 (
echo. echo.
) )
set "CONFIG=Debug"
set "REBUILD=0"
set "ARGS="
:parse
if "%~1"=="" goto parsed
if /I "%~1"=="--rebuild" (
set "REBUILD=1"
shift
goto parse
)
if /I "%~1"=="-c" if not "%~2"=="" set "CONFIG=%~2"
if /I "%~1"=="--configuration" if not "%~2"=="" set "CONFIG=%~2"
set "ARGS=!ARGS! %1"
shift
goto parse
:parsed
if "%REBUILD%"=="1" (
echo [run-aspire] --rebuild: building.
goto build
)
where powershell >nul 2>&1
if errorlevel 1 (
echo [run-aspire] PowerShell not found, building.
goto build
)
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\apphost-uptodate.ps1" -RepoRoot "%CD%" -Configuration "%CONFIG%"
if errorlevel 1 (
echo [run-aspire] sources changed or binaries missing, building.
goto build
)
echo [run-aspire] binaries are up to date, skipping build.
goto run
:build
echo.
dotnet build "src\HSchool.AppHost\HSchool.AppHost.csproj" -c "%CONFIG%"
if errorlevel 1 (
echo.
echo [run-aspire] build failed.
call :maybe_pause
exit /b 1
)
echo.
:run
echo [run-aspire] Starting the Aspire AppHost. Press Ctrl+C to shut everything down. echo [run-aspire] Starting the Aspire AppHost. Press Ctrl+C to shut everything down.
echo. echo.
dotnet run --project "src\HSchool.AppHost\HSchool.AppHost.csproj" %* dotnet run --project "src\HSchool.AppHost\HSchool.AppHost.csproj" --no-build --no-restore !ARGS!
set "EXITCODE=%ERRORLEVEL%" set "EXITCODE=!ERRORLEVEL!"
if not "%EXITCODE%"=="0" ( if not "!EXITCODE!"=="0" (
echo. echo.
echo [run-aspire] AppHost exited with code %EXITCODE%. echo [run-aspire] AppHost exited with code !EXITCODE!.
) )
call :maybe_pause call :maybe_pause
endlocal & exit /b %EXITCODE% exit /b !EXITCODE!
rem Keeps the window open when the file was double-clicked from Explorer. rem Keeps the window open when the file was double-clicked from Explorer.
:maybe_pause :maybe_pause
@@ -0,0 +1,105 @@
namespace HSchool.AppHost.Tests;
/// <summary>
/// Same rules as <c>tools/apphost-uptodate.ps1</c>. Keep the two in sync.
/// </summary>
internal static class LaunchBuildStamp
{
public const string TargetFramework = "net10.0";
public static readonly string[] InputExtensions = [".cs", ".csproj", ".props"];
public static readonly string[] NamedInputs =
[
"global.json",
"Directory.Build.props",
"Directory.Packages.props",
];
public static readonly string[] SkipDirectoryNames = ["bin", "obj", "node_modules"];
public static readonly string[] OutputProjects = ["HSchool.AppHost", "HSchool.Server"];
public static bool NeedsBuild(string repoRoot, string configuration = "Debug")
{
ArgumentException.ThrowIfNullOrWhiteSpace(repoRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(configuration);
DateTime? oldestOutput = null;
foreach (var project in OutputProjects)
{
var dll = Path.Combine(
repoRoot,
"src",
project,
"bin",
configuration,
TargetFramework,
project + ".dll");
if (!File.Exists(dll))
{
return true;
}
var written = File.GetLastWriteTimeUtc(dll);
if (oldestOutput is null || written < oldestOutput.Value)
{
oldestOutput = written;
}
}
if (oldestOutput is null)
{
return true;
}
var cutoff = oldestOutput.Value;
foreach (var name in NamedInputs)
{
var path = Path.Combine(repoRoot, name);
if (File.Exists(path) && File.GetLastWriteTimeUtc(path) > cutoff)
{
return true;
}
}
var src = Path.Combine(repoRoot, "src");
return !Directory.Exists(src) || HasNewerInput(src, cutoff);
}
private static bool HasNewerInput(string directory, DateTime cutoff)
{
foreach (var file in Directory.EnumerateFiles(directory))
{
if (!IsInputExtension(Path.GetExtension(file)))
{
continue;
}
if (File.GetLastWriteTimeUtc(file) > cutoff)
{
return true;
}
}
foreach (var child in Directory.EnumerateDirectories(directory))
{
var leaf = Path.GetFileName(child);
if (SkipDirectoryNames.Contains(leaf, StringComparer.OrdinalIgnoreCase))
{
continue;
}
if (HasNewerInput(child, cutoff))
{
return true;
}
}
return false;
}
private static bool IsInputExtension(string extension) =>
InputExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,157 @@
namespace HSchool.AppHost.Tests;
public class LaunchBuildStampTests
{
private static readonly DateTime Inputs = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
private static readonly DateTime Outputs = new(2026, 1, 1, 12, 5, 0, DateTimeKind.Utc);
private static readonly DateTime Newer = new(2026, 1, 1, 12, 10, 0, DateTimeKind.Utc);
[Fact]
public void MissingDll_NeedsBuild()
{
using var repo = FreshRepo();
File.Delete(repo.Dll("HSchool.Server"));
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void FreshDlls_SkipBuild()
{
using var repo = FreshRepo();
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void NewerCs_NeedsBuild()
{
using var repo = FreshRepo();
repo.Touch("src/HSchool.Server/Program.cs", Newer);
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void NewerTsOrJsonc_SkipBuild()
{
using var repo = FreshRepo();
repo.Write("src/HSchool.Client/src/main.ts", "export {}");
repo.Write("src/HSchool.Server/mods/core/defs/foo.jsonc", "{}");
repo.Touch("src/HSchool.Client/src/main.ts", Newer);
repo.Touch("src/HSchool.Server/mods/core/defs/foo.jsonc", Newer);
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void FilesInBinObjNodeModules_AreNotInputs()
{
using var repo = FreshRepo();
repo.Write("src/HSchool.Server/bin/Debug/net10.0/Generated.cs", "class G {}");
repo.Write("src/HSchool.Server/obj/Debug/net10.0/Generated.cs", "class G {}");
repo.Write("src/HSchool.Client/node_modules/pkg/index.cs", "class N {}");
repo.Touch("src/HSchool.Server/bin/Debug/net10.0/Generated.cs", Newer);
repo.Touch("src/HSchool.Server/obj/Debug/net10.0/Generated.cs", Newer);
repo.Touch("src/HSchool.Client/node_modules/pkg/index.cs", Newer);
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void NewerDirectoryPackagesProps_NeedsBuild()
{
using var repo = FreshRepo();
repo.Touch("Directory.Packages.props", Newer);
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root));
}
[Fact]
public void ReleaseLooksAtReleaseBin()
{
using var repo = FreshRepo();
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root, "Debug"));
Assert.True(LaunchBuildStamp.NeedsBuild(repo.Root, "Release"));
repo.WriteDll("HSchool.AppHost", "Release");
repo.WriteDll("HSchool.Server", "Release");
repo.TouchDlls("Release", Outputs);
Assert.False(LaunchBuildStamp.NeedsBuild(repo.Root, "Release"));
}
private static TempRepo FreshRepo()
{
var repo = new TempRepo();
repo.Write("global.json", "{}");
repo.Write("Directory.Build.props", "<Project />");
repo.Write("Directory.Packages.props", "<Project />");
repo.Write("src/HSchool.AppHost/AppHost.cs", "class A {}");
repo.Write("src/HSchool.AppHost/HSchool.AppHost.csproj", "<Project />");
repo.Write("src/HSchool.Server/Program.cs", "class S {}");
repo.Write("src/HSchool.Server/HSchool.Server.csproj", "<Project />");
repo.WriteDll("HSchool.AppHost");
repo.WriteDll("HSchool.Server");
repo.Touch("global.json", Inputs);
repo.Touch("Directory.Build.props", Inputs);
repo.Touch("Directory.Packages.props", Inputs);
repo.Touch("src/HSchool.AppHost/AppHost.cs", Inputs);
repo.Touch("src/HSchool.AppHost/HSchool.AppHost.csproj", Inputs);
repo.Touch("src/HSchool.Server/Program.cs", Inputs);
repo.Touch("src/HSchool.Server/HSchool.Server.csproj", Inputs);
repo.TouchDlls("Debug", Outputs);
return repo;
}
private sealed class TempRepo : IDisposable
{
public string Root { get; } = Path.Combine(Path.GetTempPath(), "h-school-stamp-" + Guid.NewGuid().ToString("N"));
public TempRepo() => Directory.CreateDirectory(Root);
public string Write(string relative, string contents)
{
var path = PathUnder(relative);
var dir = Path.GetDirectoryName(path);
if (dir is not null)
{
Directory.CreateDirectory(dir);
}
File.WriteAllText(path, contents);
return path;
}
public string WriteDll(string project, string configuration = "Debug") =>
Write($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll", "dll");
public string Dll(string project, string configuration = "Debug") =>
PathUnder($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll");
public void Touch(string relative, DateTime utc) => File.SetLastWriteTimeUtc(PathUnder(relative), utc);
public void TouchDlls(string configuration, DateTime utc)
{
foreach (var project in LaunchBuildStamp.OutputProjects)
{
Touch($"src/{project}/bin/{configuration}/{LaunchBuildStamp.TargetFramework}/{project}.dll", utc);
}
}
public void Dispose()
{
try
{
Directory.Delete(Root, recursive: true);
}
catch (IOException)
{
}
}
private string PathUnder(string relative) =>
Path.Combine(Root, relative.Replace('/', Path.DirectorySeparatorChar));
}
}
+80
View File
@@ -0,0 +1,80 @@
# Same rules as tests/HSchool.AppHost.Tests/LaunchBuildStamp.cs. Keep the two in sync.
# Exit 0: AppHost and Server dlls are newer than inputs. Exit 1: need a build.
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[string] $Configuration = "Debug"
)
$ErrorActionPreference = "Stop"
$tfm = "net10.0"
$outputs = @(
Join-Path $RepoRoot "src\HSchool.AppHost\bin\$Configuration\$tfm\HSchool.AppHost.dll"
Join-Path $RepoRoot "src\HSchool.Server\bin\$Configuration\$tfm\HSchool.Server.dll"
)
foreach ($dll in $outputs) {
if (-not (Test-Path -LiteralPath $dll)) {
exit 1
}
}
$oldest = $null
foreach ($dll in $outputs) {
$written = (Get-Item -LiteralPath $dll).LastWriteTimeUtc
if ($null -eq $oldest -or $written -lt $oldest) {
$oldest = $written
}
}
$named = @(
"global.json"
"Directory.Build.props"
"Directory.Packages.props"
)
foreach ($name in $named) {
$path = Join-Path $RepoRoot $name
if ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).LastWriteTimeUtc -gt $oldest) {
exit 1
}
}
$src = Join-Path $RepoRoot "src"
if (-not (Test-Path -LiteralPath $src)) {
exit 1
}
$extensions = @{
".cs" = $true
".csproj" = $true
".props" = $true
}
$skip = @{
"bin" = $true
"obj" = $true
"node_modules" = $true
}
$stack = New-Object System.Collections.Stack
$stack.Push($src)
while ($stack.Count -gt 0) {
$dir = [string]$stack.Pop()
foreach ($child in [System.IO.Directory]::EnumerateDirectories($dir)) {
$leaf = Split-Path -Path $child -Leaf
if ($skip.ContainsKey($leaf.ToLowerInvariant())) {
continue
}
$stack.Push($child)
}
foreach ($file in [System.IO.Directory]::EnumerateFiles($dir)) {
$ext = [System.IO.Path]::GetExtension($file).ToLowerInvariant()
if (-not $extensions.ContainsKey($ext)) {
continue
}
if ((Get-Item -LiteralPath $file).LastWriteTimeUtc -gt $oldest) {
exit 1
}
}
}
exit 0