# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## What this is A minimal Classic ASP / VBScript application hosted on IIS Express, structured as a front-controller MVC-ish app. There is no build step, package manager, or test suite — this is raw `.asp` served directly by IIS. ## Running the site The IIS Express site config is generated, not hand-maintained. `applicationhost.config` at the repo root is regenerated from your machine's installed IIS Express base config every time you launch — do not hand-edit it; edit `build-iisexpress-config.ps1` instead if the site definition needs to change. - Start: `./run-iisexpress.ps1` (or `run-iisexpress.cmd`) - Start with IIS request tracing: `./run-iisexpress.ps1 -TraceErrors` (or `run-iisexpress.cmd trace`) - Stop: `./stop-iisexpress.ps1` (kills any running `iisexpress` process) - URL: `http://localhost:8080` Requirements: IIS Express installed locally with the Classic ASP feature enabled. The site's physical root is `Public/` (see `build-iisexpress-config.ps1`'s `-PhysicalPath`), not the repo root. There's no build/lint step, but there is an object-level test suite (see **Tests** below) — run it before and after any change to `Core/*.asp`. View rendering and end-to-end wiring are still verified manually: run the site and hit it in a browser or `curl http://localhost:8080/...`. ## Tests `tests/` holds a standalone VBScript test harness for the classes in `Core/` (not the `Public/` views — see why below). Run it with: - `tests/run-tests.ps1` (or `tests/run-tests.cmd`) - Exits non-zero if any test fails, so it's safe to gate on. **Why a separate harness instead of testing through IIS:** spinning up IIS Express per test would be far too slow for a TDD loop, and `Core/App.asp`, `Router.asp`, `HttpRequest.asp`, `ClockService.asp`, and the `Core/Controllers/*.asp` files are all pure `<% Class ... End Class %>` blocks with no HTML — no reason they can't run in plain `cscript.exe`. `tests/RunTests.vbs` reads each of those files, strips the `<%`/`%>` wrapper, and runs the remaining VBScript through `ExecuteGlobal` — this loads the *real* class definitions (no separate copy to drift out of sync), just outside IIS. This only works because those files never declare their own `Option Explicit` (same reason as the `#include` note above) and contain nothing but class definitions — don't add top-level executable statements to them, or the strip-and-execute loader breaks. `Core/IncludeAll.asp` and `Core/Bootstrap.asp` are deliberately *not* in this list — they're front-controller wiring, not reusable classes, and `Bootstrap.asp` in particular is exactly the executable code this loader can't handle. Those two are only exercised by the manual live-IIS checks described above. `tests/TestSupport.vbs` provides everything `cscript` doesn't have natively: - `AssertEquals` / `AssertTrue` / `AssertFalse` / `AssertIsNothing` / `AssertNotNothing`, plus `RunTest(name, procNameAsString)` which invokes the named zero-arg test Sub via `Execute procName & "()"` (VBScript has no clean first-class function reference for this — `GetRef` exists but is really meant for event binding, so a name string + `Execute` is the reliable option) and records pass/fail. - `FakeServer` / `FakeResponse` stand in for the ASP `Server`/`Response` intrinsics that don't exist outside IIS — they're assigned to plain global variables literally named `Server` and `Response` before any `Core/*.asp` file is loaded, so `Server.CreateObject(...)` / `Response.Status = ...` in the real code resolve to the fakes without the production files knowing the difference. - `FakeAspRequest` stands in for the real ASP `Request` object that `HttpRequest.Bind()` expects (`SetServerVariable`/`SetQueryString`/`SetForm`/`SetCookie` to seed it per test). - `FakeService` is just a neutral dummy object for tests that need "some object" to register. Each `Core` class/concept gets its own suite (`RouterTests.vbs`, `AppTests.vbs`, `HttpRequestTests.vbs`, `ControllerTests.vbs`, `DispatcherTests.vbs`) — a suite is just a `.vbs` file of `Sub Test_*()` procedures followed by explicit `RunTest "description", "Test_ProcName"` calls at the bottom (no auto-discovery/reflection — VBScript doesn't have a clean way to enumerate procedures at runtime). `tests/RunTests.vbs` is the entry point that wires the framework, fakes, `Core/*.asp` classes, and suites together in order and prints a summary. **A real bug this harness caught immediately:** `IsObject(Nothing)` returns `True` in VBScript — it only tells you the variable is *object-typed*, not that it holds an actual object. Every `If Not IsObject(m_X) Then Err.Raise ...`-style "has this been set yet" guard in `App`, `HttpRequest`, and `GreetController` was silently broken because of this — they'd never raise, even when nothing had been bound/set. Manual IIS testing never caught it because `Default.asp` always binds/sets things correctly before use. Fixed by checking `m_X Is Nothing` instead (or `(Not IsObject(x)) Or (x Is Nothing)` when validating a caller-supplied argument that could be a non-object, not just `Nothing`). If you add a similar "was this ever assigned" guard, use `Is Nothing`, not `IsObject`. ## Architecture **`Core/` lives outside the web root on purpose.** The IIS physical root is `Public/`, and `Core/*.asp` sits one level up at the repo root — so those class files can never be requested directly over HTTP, only pulled in via server-side `#include`. This only works because `build-iisexpress-config.ps1` sets `enableParentPaths="true"` for the site, which permits `..` in `file=` include paths. Keep new core/business-logic classes in `Core/`, not `Public/`, and never add `Option Explicit` to a file that gets `#include`d — `Default.asp` already declares it once at the top of the merged script, and VBScript raises a syntax error on a second `Option Explicit` later in the same compiled page. **`Public/Default.asp` pulls in every `Core/*.asp` class via one ``**, rather than listing each file itself. `Core/IncludeAll.asp` is nothing but a flat list of sibling `#include`s (`App.asp`, `ClockService.asp`, `HttpRequest.asp`, `Router.asp`, `Dispatcher.asp`, `DynamicIncludes.asp`, `Controllers/*.asp`) — IIS's SSI preprocessor expands nested `#include`s recursively, resolving each one relative to the file that contains it, so paths inside `IncludeAll.asp` are written relative to `Core/` (no `../` needed) even though `Default.asp` itself reaches `IncludeAll.asp` with one. Add a new `Core/*.asp` class's `#include` line here, not to `Default.asp` — `IncludeAll.asp` only loads class *definitions*, nothing executable. **`Core/` also holds front-controller plumbing that isn't a class**, for the same "keep it out of the web root" reason as everything else in `Core/` — not everything there is a reusable service. Alongside `IncludeAll.asp` (loads class definitions), `Core/Bootstrap.asp` is the actual construction/wiring step that used to live directly in `Default.asp`: it sets `Response.Buffer`/`ContentType`, `Dim`s every page-level variable, `New`s up `App`/`ClockService`/`HttpRequest`/`Router`/`Dispatcher`, registers routes and the `clockService`, binds the request, and calls `Boot`. Both are included right after each other from `Default.asp` (`IncludeAll.asp` first, since `Bootstrap.asp`'s `New App` etc. need the class definitions already loaded). **`Bootstrap.asp` turns `On Error Resume Next` on and deliberately leaves it on** — `Default.asp`'s `Match`/`Dispatch` calls run under that same error-handling mode before `Default.asp` itself checks `Err.Number` and calls `On Error Goto 0`. This works because `#include` (and this nested pair of them) all splice into one compiled script, same as everywhere else in this file — but it means you can't understand `Default.asp`'s error handling by reading `Default.asp` alone. **Front controller.** `Public/web.config` rewrites every request that isn't a real file or directory to `Public/Default.asp` (preserving the query string). `Default.asp` is therefore the single entry point for the whole app, and now holds only the per-request flow - class loading and object wiring both live in the two `Core/` includes above: ``` Default.asp ' loads every Core/*.asp class definition ' New's + wires everything, AddRoute's, Binds, Boots, ' and leaves On Error Resume Next switched on → oRouter.Match(oApplication.Route) ' -> Dictionary{ControllerName, Params} or Nothing → oDispatcher.Dispatch app, routeMatch, controllerKey, oController ' ByRef outputs - see Dispatcher below → check Err.Number, then On Error Goto 0 ' closes out the Resume Next opened in Bootstrap.asp → viewName = "Error" (if Err.Number <> 0) or controllerKey (otherwise) → Require "../Core/Views/" & viewName & ".asp" ' runtime-picked view - see DynamicIncludes below ``` **Routing.** `Core/Router.asp` (`Router` class) holds registered routes and matches a request path against them: `AddRoute(pattern, controllerName)` / `Match(route)`. Patterns are split on `/`; a segment written `{name}` captures whatever's in that position into a `Params` dictionary, everything else must match literally (case-insensitive). `Match` returns a `Dictionary` with `ControllerName` and `Params`, or `Nothing` if no pattern fits — first registered match wins, so register more specific patterns before catch-alls. `Router` deliberately knows nothing about concrete controller types (that's why `RouterTests.vbs` can exercise it with made-up controller-name strings) — see `Dispatcher` below for where matching a key turns into an actual running controller. **Patterns support multiple `{param}` segments** (e.g. `/greet/{name}/{id}` captures both `name` and `id`), and two patterns that only differ in segment count can safely map to the same controller — they never both match the same request, since `Match` requires an exact segment-count match before it even looks at individual segments (`/greet/{name}` and `/greet/{name}/{id}` for `Greet` is the live example: `Bootstrap.asp` registers both, and `GreetController.HasId` is only `True` when the longer one matched). **What `Router`/patterns do *not* give you**: a generic ASP.NET-MVC-style `{controller}/{action}/{id}` catch-all that dispatches to *any* controller/action pair. VBScript can't instantiate a script-defined `Class` from a string, so even a wildcard pattern still needs `Dispatcher` to hard-map each real controller name to a `New XController` call — a catch-all route would change the pattern, not remove that mapping. Query strings are unrelated to routing entirely: `App.ResolveRoute()` strips everything after `?` before a route is ever matched, and a controller reads query args separately via `app.HttpRequest.QueryString("key")`. **Dispatching.** `Core/Dispatcher.asp` (`Dispatcher` class, single method `Dispatch(ByRef app, ByRef routeMatch, ByRef controllerKey, ByRef controller)`) is the one place that turns a `Router.Match` result into a running controller. It resolves `controllerKey` (`"NotFound"` if `routeMatch` is `Nothing`, or if the matched key isn't one of its known `Case`s), constructs the matching controller, and calls `Init`/`Execute` on it — reporting the resolved key and the controller instance back to the caller via the `ByRef` parameters. **Important VBScript limitation:** script-defined `Class`es can't be instantiated dynamically from a string (no `CreateObject("HomeController")`), so `Dispatcher` still needs a static `Select Case controllerKey → New Controller` internally — this limitation is unavoidable regardless of which file holds it, but it's confined to `Dispatcher` alone now (this limitation does NOT apply to picking the *view*, which is dynamic — see `DynamicIncludes` below). Adding a route means: new controller in `Core/Controllers/`, new view in `Core/Views/` named to match the controller key, one `AddRoute` call in `Default.asp`, one `Case` branch in `Dispatcher.Dispatch`. **`App.Route` depends on `HTTP_X_ORIGINAL_URL`, not `PATH_INFO`.** Because `Public/web.config` internally rewrites every request to `Default.asp`, `PATH_INFO` inside the page is always empty — it reflects the rewritten target, never what was actually requested. IIS's URL Rewrite module stores the real pre-rewrite path in the `HTTP_X_ORIGINAL_URL` server variable, so `App.ResolveRoute()` reads that first and only falls back to `PATH_INFO` if it's absent (e.g. a direct hit on `Default.asp` itself, bypassing the rewrite). If routes ever stop resolving correctly, check this first before suspecting the `Router`. **Views live in `Core/Views/` and are picked at runtime via `Core/DynamicIncludes.asp`, not a static `#include` chain.** Classic ASP's `` is resolved at parse time, so it normally can't pick a file based on a runtime-computed route. `DynamicIncludes.asp` works around this: `Require(path)` reads a file's raw text directly (`Server.MapPath` + `FileSystemObject`, bypassing the SSI preprocessor entirely), compiles it into equivalent VBScript (HTML becomes `Response.Write` calls, `<%= %>` becomes `Response.Write`, and any `` *inside* that file becomes a nested `Require(...)` call), and runs the result via `ExecuteGlobal` — so the path can be a runtime string. `Default.asp` computes `viewName` from the `controllerKey` that `Dispatcher.Dispatch` resolved (or `"Error"` on failure) and calls `Require "../Core/Views/" & viewName & ".asp"` once. Since this still runs via `ExecuteGlobal` in the same script as `Default.asp`, the loaded view can reference `Default.asp`'s single generic `oController` variable (whatever `Dispatcher.Dispatch` produced) and `errorDescription` directly — no `Session`-stashing needed. Every view accesses its controller through that same `oController` name, not a type-specific one (`Home.asp` uses `oController.App.Route` and `oController.ServiceSummary`; `Greet.asp` uses `oController.Name`) — this is what lets `Default.asp` stay generic instead of needing a `Select Case` to pick which named variable a view should see. `Home.asp` renders the diagnostic/welcome page via `HomeController`; `Greet.asp` renders `GreetController`'s `{name}` param; `NotFound.asp` renders when the router has no match (and `NotFoundController.Execute` sets a real `404` `Response.Status`); `Error.asp` is the friendly fallback shown when `Boot` or a controller raises. **Convention: a view's filename must match its controller key exactly** (`Home` → `Home.asp`) since `Default.asp` derives one from the other - there's no separate mapping table. **`Core/DynamicIncludes.asp` internals and gotchas**, since three real bugs were found and fixed while wiring this in (this file predates and wasn't written as part of this session's other work, so verify any further changes to it live rather than trusting a read-through): - `ExecuteFile` tracks `DynamicInclude_CurrentPath` so nested `#include`-turned-`Require` calls inside a loaded file resolve relative to *that file's* directory. It originally (a) appended the *full* path (directory included) on top of an already-updated `DynamicInclude_CurrentPath`, duplicating the directory segment, and (b) restored `DynamicInclude_CurrentPath` to the hardcoded global default after each call instead of the caller's own baseline — which broke a parent file that includes two siblings in sequence (exactly what every view does: `_Header.asp` then `_Footer.asp`). Both are fixed now: only the bare filename is appended to `DynamicInclude_CurrentPath`, and the caller's baseline is explicitly saved and restored. - `ReadFile` normalizes whatever line endings it finds in the source file to `vbNewLine`. This isn't cosmetic: `ParseFile` splices literal HTML line breaks into generated code as `" & vbNewLine & "` by searching for `vbNewLine` (CRLF) specifically — files saved with bare LF endings (as this repo's files are) would leave a raw, unconverted newline embedded inside a generated `Response.Write "..."` string literal, which fails to compile. This surfaced as a `200 OK` with a completely empty body and no error message (a compile error in `ExecuteGlobal`'d code isn't caught by `Require`'s own `On Error Resume Next`) — if that symptom shows up again, dump `ParseFile`'s raw output for the view in question and read it before assuming the bug is elsewhere. - Before calling `Require` for a *new* top-level render (not a nested one), reset `DynamicInclude_CurrentPath = vbNullString` first, as `Default.asp` does. Its resting default (`DI_CurrentFolder = "../"`) is baked in for a different calling convention than ours and would double up the `../` prefix on top of a full relative path like `"../Core/Views/Home.asp"`. - `Err.Raise 53, "DynamicIncludes.ReadFile", "..."` — the fix from before this file was wired in (originally passed the message as `Source` instead of `Description`) — and the three debug `Response.Write` leftovers are already removed; don't reintroduce either pattern. - No caching: every `Require`/`Include` re-reads the file from disk and re-runs several regex passes over it on every call. Fine at this app's scale; worth knowing if it ever needs to matter. The shared page chrome (``/CSS/card wrapper) lives in `Core/Views/_Header.asp` and `Core/Views/_Footer.asp` — every view sets `pageTitle`, `badgeClass` (`"ok"` or `"bad"`), and `badgeText` right before `#include`-ing `_Header.asp`, then closes with `#include _Footer.asp`. These three variables are `Dim`'d once in `Default.asp` (not in the view files) for the same reason `Option Explicit` can only be declared once — `Home.asp` and `Error.asp` both get compiled into the same merged script (only one branch executes, but both are always parsed), so a second `Dim` of the same name in either view would be a redeclaration error. Add any new shared chrome variable to `Default.asp`'s `Dim` block, not to a view file. **`Core/App.asp` (`App` class)** is the composition root / mini service container — pure app/request infrastructure, no view- or route-specific logic: - Holds a `Scripting.Dictionary` (`m_Services`) as a service locator — `RegisterInstance` / `Resolve` / `HasService` / `ServiceNames()`. `InjectInto` uses `CallByName` to push itself onto any object exposing a `SetApp` method (best-effort — errors are swallowed via `On Error Resume Next`). - Never touches the raw ASP `Request` object directly — it goes through the injected `HttpRequest` wrapper (`SetRequest` / `EnsureRequest`), so route resolution and server variables are testable independent of IIS. - `ResolveRoute()` derives the route (see the `HTTP_X_ORIGINAL_URL` note above), normalizing `/Default.asp` to `/`. **`Core/Controllers/*.asp`** each follow the same shape: `Init(app, routeParams)` to receive the `App` instance and the router's captured params, `Execute()` to run any per-request logic, an `App` property for the view to reach request context through, plus whatever controller-specific data the view needs (e.g. `HomeController.ServiceSummary` turns `App.ServiceNames()` into the HTML-encoded, comma-joined text the old `App` class used to build itself — that formatting belongs to the controller/view, not the DI container). This uniform `Init`/`Execute`/`App` shape is what lets `Dispatcher.Dispatch` treat every controller identically. **`Core/HttpRequest.asp` (`HttpRequest` class)** wraps the ASP intrinsic `Request` object (`Bind`) and proxies `Item`, `QueryString`, `Form`, `ServerVariable`, `Cookie`, `ClientCertificate`, `TotalBytes`, `BinaryRead`. Every accessor calls `EnsureBound` first and raises a custom error (`vbObjectError + 110x`) if `Bind` was never called. Always `Bind` before use. **`Core/ClockService.asp`** is a trivial example service (`GetNowText`) showing the register/resolve pattern — not core infrastructure, just a template for adding new services. ## Conventions (from AGENTS.md) This repo is meant to be treated as production-sensitive legacy code, not a scratch project. Key rules that aren't obvious from the code alone: - **Ask before writing code.** State the plan and check in before making changes. - Prefer small, targeted diffs over rewrites; don't modernize working code just to make it look nicer. - Avoid one-letter variable names. (`Option Explicit` belongs only in `Default.asp` — see the architecture note above on why it can't also live in `#include`d files.) - Keep business logic out of markup; route new logic through classes/services (extend the `App` service-locator pattern) rather than duplicating it inline in `.asp` pages. - New services follow the `ClockService` shape: a plain class, registered into `App` via `RegisterInstance` in `Default.asp`, resolved via `App.Resolve("name")` where needed. Objects that need the container can implement `SetApp` and be wired via `App.InjectInto`. - Be deliberate about VBScript's `Null`/`Empty` coercion and type comparisons; guard against "object not set", type mismatch, and unclosed ADO recordsets/connections if/when data access is added. - No ORM/framework — if SQL or ADO is introduced, use parameterized commands and minimize connection lifetime. - New logic in `Core/` gets a test in `tests/` (see **Tests** above) — write the failing test first where practical, then make it pass. Keep `Core/*.asp` files pure class definitions (no top-level executable code, no `Option Explicit`) so the test harness's strip-and-`ExecuteGlobal` loader keeps working.