Taking inventory of a Classic ASP application before you estimate

Most Classic ASP estimates are guesses dressed up as numbers. Someone counts the .asp files, multiplies by a per-page figure, and the result is a schedule nobody can defend. We have seen a 1,900-file application where 430 files were reachable and 61 pages carried 94% of the traffic. The multiplication method would have priced the wrong system.

This tutorial is the measurement pass we run in the first day or two of a Classic ASP assessment. It produces four artifacts: an include graph, a COM dependency list, a data-access profile, and a traffic-ranked page list. You need a read-only copy of the source tree, a few weeks of IIS logs, and PowerShell. Nothing here touches the running application.

One timing note, because it comes up in every Classic ASP conversation now: Microsoft has deprecated the VBScript engine and now ships it as a feature on demand, with removal from Windows in a future release (Windows deprecated features). Classic ASP itself remains a supported IIS component and asp.dll is not going away on a published date. Treat this as a reason to know your inventory, not a reason to panic. The measured system is the one you can plan around.

1. Find the code that is actually reachable

Count the surface

Start with the raw shape of the tree:

Get-ChildItem -Recurse -Include *.asp,*.inc,*.asa,*.vbs |
  Group-Object Extension |
  Select-Object Name, Count, @{n='KB';e={[math]::Round(($_.Group | Measure-Object Length -Sum).Sum / 1KB)}}

Write the numbers down. They are the denominator everyone will quote at you later, and part of the job is showing how much smaller the real number is.

Build the include graph

Classic ASP composes pages with #include. The graph tells you which files are shared infrastructure and which are leaves:

$root = (Get-Location).Path
$edges = foreach ($f in Get-ChildItem -Recurse -Include *.asp,*.inc) {
  Select-String -Path $f.FullName -Pattern '#include\s+(file|virtual)\s*=\s*"([^"]+)"' -AllMatches |
    ForEach-Object { $_.Matches } |
    ForEach-Object {
      [pscustomobject]@{
        From = $f.FullName.Substring($root.Length + 1)
        To   = $_.Groups[2].Value
      }
    }
}
$edges | Export-Csv includes.csv -NoTypeInformation
$edges | Group-Object To | Sort-Object Count -Descending | Select-Object -First 20 Count, Name

The top of that list is your shared layer: the database connection include, the login check, the formatting helpers. Those files are not slices. They are the seams every slice has to keep honoring, so they get characterization tests first.

Find the orphans

Any .asp that no include references and no log line requests is a candidate for the dead pile:

$referenced = ($edges.To | ForEach-Object { Split-Path $_ -Leaf }) | Sort-Object -Unique
Get-ChildItem -Recurse -Include *.asp,*.inc |
  Where-Object { $referenced -notcontains $_.Name } |
  Select-Object FullName, LastWriteTime |
  Sort-Object LastWriteTime

Do not delete anything on the strength of this. Cross-check against the traffic list in step 4, then ask the owner. Old admin tools that run once a year at audit time look exactly like dead code.

2. List every COM dependency

COM objects are where Classic ASP estimates go wrong, because each one is a separate decision with its own cost:

Get-ChildItem -Recurse -Include *.asp,*.inc |
  Select-String -Pattern 'Server\.CreateObject\s*\(\s*"([^"]+)"' -AllMatches |
  ForEach-Object { $_.Matches } |
  ForEach-Object { $_.Groups[1].Value } |
  Group-Object | Sort-Object Count -Descending |
  Select-Object Count, Name

Sort the results into four buckets:

  • Built-ins with direct .NET equivalents. ADODB.Connection, Scripting.FileSystemObject, Scripting.Dictionary, MSXML2.ServerXMLHTTP. These are mechanical translations to SqlConnection, System.IO, Dictionary<,> and HttpClient.
  • Third-party components you can still license. A PDF writer, a credit-card gateway wrapper, an upload component. Cost is a current version or a replacement, plus a parity test on the output.
  • In-house DLLs where you have the source. Usually VB6. These are your interop seam and often the best first slice, because a VB6 DLL already has a defined interface.
  • Components with no source and no vendor. The genuinely expensive ones. For each, decide now whether the plan is reverse-engineering behavior from a characterization harness, or keeping it alive on a small isolated host while everything else moves.

Also grep for CreateObject( without the Server. prefix, and for GetObject(. Both appear in older code and both hide dependencies.

3. Profile the data access

This is the number that predicts effort better than page count: how much business logic is embedded in inline SQL.

$files = Get-ChildItem -Recurse -Include *.asp,*.inc

'Inline SELECT/INSERT/UPDATE/DELETE:'
($files | Select-String -Pattern '\b(SELECT|INSERT INTO|UPDATE|DELETE FROM)\b' -AllMatches |
  ForEach-Object { $_.Matches.Count } | Measure-Object -Sum).Sum

'String-concatenated SQL (injection and rewrite risk):'
($files | Select-String -Pattern '(SELECT|WHERE|VALUES)[^\r\n]*"\s*&' -AllMatches |
  ForEach-Object { $_.Matches.Count } | Measure-Object -Sum).Sum

'Stored procedure calls:'
($files | Select-String -Pattern '(ADODB\.Command|CommandType\s*=\s*4|EXEC(UTE)?\s+\w+)' -AllMatches |
  ForEach-Object { $_.Matches.Count } | Measure-Object -Sum).Sum

A system that calls stored procedures is cheaper to strangle than one with SQL in the page, because the procedure is already an interface you can put a .NET 8 slice behind. A system with concatenated SQL in 600 places is telling you two things: the rewrite is larger than it looks, and you have an unfunded security finding sitting in production.

While you are in there, count the patterns that will not survive the move: Session( assignments, on-page Response.Write of HTML built from data, Response.Buffer and Response.Flush tricks, and any use of objRs.MoveNext loops that stream a recordset straight into markup. Each is a known translation, and knowing the volume is what makes an estimate a measurement.

4. Rank pages by what people actually use

Source code tells you what exists. IIS logs tell you what matters. Point this at a few weeks of logs:

Get-ChildItem C:\inetpub\logs\LogFiles -Recurse -Filter *.log |
  Get-Content |
  Where-Object { $_ -notmatch '^#' } |
  ForEach-Object { ($_ -split ' ')[4] } |
  Where-Object { $_ -match '\.asp$' } |
  Group-Object | Sort-Object Count -Descending |
  Select-Object -First 60 Count, Name |
  Export-Csv traffic.csv -NoTypeInformation

Field index 4 is cs-uri-stem in the default W3C format; confirm the #Fields header in your own logs before trusting it. Then join traffic.csv against the file list. Three groups fall out:

  • Hot and small. The daily screens. These get characterization tests and go early, because early slices need to prove the pattern on work people notice.
  • Hot and tangled. Usually order entry or invoicing, wired into half the include graph. These are the real project. Sequence them after the shared seams are tested.
  • Cold. Long tail of one-off pages. Candidates for retirement, and the cheapest way to shrink a migration is to get written agreement that these do not move.

Ask for the logs before you ask for the code. When an owner tells us 1,900 files and the logs show 61 pages carrying the load, the conversation changes from "rewrite the system" to "rewrite the part of the system the business runs on."

What this pass does not tell you

Be honest about the limits, because an inventory that oversells itself produces the same bad estimate as no inventory at all.

  • Regex is not a parser. VBScript built with Execute or string-assembled includes will slip past every pattern above. Treat the counts as lower bounds.
  • Behavior is still unknown. You now know the shape of the system, not what it does. That comes from characterization tests; see characterization tests for code nobody understands and the golden-master harness.
  • Database logic is out of frame. Triggers, jobs, views and SSIS packages need their own pass. In Classic ASP systems, a surprising share of the business rules live there.
  • AI helps here, with a ceiling. We do use a model to summarize what an unfamiliar include does and to draft the first translation of a page. It is good at that. It is not a source of truth about a system it has only read: it will describe intent confidently and miss the one branch that handles the customer with the negative balance. The counts above come from tools, not from reading, and the behavior comes from tests.

An inventory pass costs a day or two and it is the difference between an estimate you can defend and a number you will renegotiate. If you want a hand with one, tell us what the system is and a senior engineer will reply within one business day.