This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
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.
./run-iisexpress.ps1 (or run-iisexpress.cmd)./run-iisexpress.ps1 -TraceErrors (or run-iisexpress.cmd trace)./stop-iisexpress.ps1 (kills any running iisexpress process)http://localhost:8080Requirements: 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/ 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)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.
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 #included — 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 <!--#include file="../Core/ IncludeAll.asp" -->, rather than listing each file itself. Core/IncludeAll.asp is nothing but a
flat list of sibling #includes (App.asp, ClockService.asp, HttpRequest.asp, Router.asp,
Dispatcher.asp, DynamicIncludes.asp, Controllers/*.asp) — IIS's SSI preprocessor expands nested
#includes 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, Dims every
page-level variable, News up App/ClockService/HttpRequest/Router/Dispatcher, includes
Core/Routes.asp to register all routes, then registers 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
<!--#include IncludeAll.asp --> ' loads every Core/*.asp class definition
<!--#include Bootstrap.asp --> ' New's + wires everything, Binds, Boots,
<!--#include Routes.asp --> ' registers all AddRoute's (included by Bootstrap.asp)
' 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:
Routes.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 Cases), 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 Classes can't
be instantiated dynamically from a string (no CreateObject("HomeController")), so Dispatcher still
needs a static Select Case controllerKey → New <X>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 Core/Routes.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 <!--#include--> 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 <!--#include--> 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.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.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 (<head>/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:
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).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.
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:
Option Explicit belongs only in Default.asp — see the
architecture note above on why it can't also live in #included files.)App service-locator pattern) rather than duplicating it inline in .asp pages.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.Null/Empty coercion and type comparisons; guard against
“object not set”, type mismatch, and unclosed ADO recordsets/connections if/when data access is added.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.Powered by TurnKey Linux.