You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

22KB

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 #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.aspIncludeAll.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 onDefault.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 (HomeHome.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 (<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:

  • 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 #included 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.

Powered by TurnKey Linux.