All posts
Jeremy Stover

Good bones, drowning in copy-paste: Semfora vs. fifteen-year-old WebForms

engineeringtesting

I went looking for the oldest, ugliest, largest real C# codebase I could get my hands on, and pointed the current Semfora engine at it. The winner: nopCommerce as it shipped in December 2010. ASP.NET WebForms, auto-generated SOAP proxies for every payment gateway, ten stored-procedure install scripts, and Visual SourceSafe binding files still committed to the tree.

The short version: the engine indexed 22,585 symbols across 2,463 files in 40 seconds with zero errors, then ran its whole analysis surface (metrics, duplication, health scoring, fault-tree, cross-release diff) in single-digit seconds each. Nothing crashed and nothing hung, which is what I was testing for. What I did not expect was how specific the findings would get: the health score split into a verdict of "good architecture, 18.9% duplication," the fault tree put a hand-rolled HTML sanitizer at the top of the risk pile, and the cross-release diff caught the Entity Framework rewrite landing a method with cognitive complexity 238.

This post is the evidence, with the raw numbers.

Why 2010 WebForms

Parsers and code-graph tools tend to fail in interesting ways on genuinely old code: WebForms code-behind, partial classes scattered across designer files, machine-generated WSDL client code, pre-generics idioms, thousand-line god classes. I had already run the engine against a synthetic legacy fixture (a fake 2009-era industrial control app built to be nasty) and it came through clean. A synthetic fixture proves the parser; a real codebase an order of magnitude bigger proves the pipeline. So the selection criteria were oldest, ugliest, and biggest.

The target: release-1.90

The tag I indexed is dated 2010-12-07. Its shape:

AttributeValue
C# files2,178
WebForms pages (.aspx)293
SQL install scripts10 (stored procedures)
Working tree48 MB
SolutionNopCommerce.sln, VS2010-era
Source control artifactNopCommerce.vssscc, Visual SourceSafe bindings

The period tells are everywhere. Visual SourceSafe was already legacy in 2010, and its binding files are still in the tree. The Web References namespaces hold .NET 2.0 "Add Web Reference" SOAP client code for PayPal, Authorize.Net, FedEx, USAePay, Clickatell, and the EU VAT check service, all machine-generated from WSDL. Each payment gateway and shipping carrier is its own assembly under Payment/, Shipping/, Tax/, and PromotionProviders/. The domain core lives in Libraries/ (Nop.Common, Nop.BusinessLogic) with the web tier in NopCommerceStore/, including an enormous Administration/ area.

Indexing: 40 seconds, zero errors

From the repo root, detached at release-1.90:

files indexed:   2463  (first-party: 1665, generated: 798)
symbols indexed: 22585 (first-party: 21768, generated: 817)
errors:          0
modules:         315
wall clock:      40 seconds
index size:      59.8 MB on disk

Zero errors across 2,178 hand-written C# files spanning code-behind, partial classes, and generated proxies. The generated-code detection classified 798 files as machine-written and kept them out of the first-party metrics; from the counts and the WebForms layout these are almost certainly the .designer.cs files and the SOAP proxies, though that classification is my inference, the tool just reports the number. The engine also indexed the bundled front-end JavaScript from the jQuery and TinyMCE era: the symbol split came out to 20,142 C#, 2,436 JS, and 7 PHP.

That is the load-bearing claim of this post: a 22,585-symbol legacy codebase, indexed clean, in 40 seconds.

The health score refuses to say "old equals bad"

semfora-engine vital produces a single score with attribution:

SVS:   93   (Grade A)
Pillars:
  stability  100.0
  clarity     79.2
  structure  100.0
Drivers (points lost):
  duplication  -> clarity, 9.4 pts
  knot_count   -> clarity, 6.5 pts
  fog_index    -> clarity, 3.7 pts
Raw metrics:
  modularity=0.937  alignment=0.942
  propagation_cost=0.0001  duplication=18.9%

I expected a fifteen-year-old WebForms app to score like a fifteen-year-old WebForms app. Instead the breakdown is the story. Structurally, nopCommerce 1.90 holds up: modularity 0.937, design alignment 0.942, and a propagation cost of 0.0001, meaning changes barely ripple. The layered Nop.* library design was genuinely good. All the damage is in the clarity pillar, and almost all of that is the 18.9% duplication, with cyclomatic tangle and readability contributing the rest.

Good bones, drowning in copy-paste. Before a modernization project, that is exactly the diagnosis you want, because it points at refactoring rather than a rewrite. Two honesty notes: the Grade A is a whole-repo aggregate, so the clarity number and the duplication figure belong next to it in any retelling, and the vital pass analyzes a filtered subset of 11,374 symbols after excluding generated and trivial code, versus the 22,585 total indexed.

Duplication as archaeology

The duplicate-detection pass, run at a 0.92 similarity threshold, took 2 seconds over the 22k symbols and found 273 clusters covering 226 duplicate functions. The clusters read like a dig site report:

ClusterCountSimilarityWhat it is
Page_Load14100%identical page lifecycle handlers
SearchButton_Click1395 to 100%every admin grid page has the same search handler
CancelAsync7100%auto-generated SOAP clients: PayPal, Authorize.Net, FedEx, USAePay, Clickatell, EU VAT
ddlCountry_SelectedIndexChanged2100%dropdown postback handlers

The CancelAsync cluster is my favorite: every .NET 2.0 "Add Web Reference" import generated identical async-cancel plumbing, so the same seven-line method exists once per payment provider, frozen since whenever each WSDL was imported. The SearchButton_Click thirteen-peat is the human-written version of the same disease: the admin panel is 562 files of .aspx plus code-behind pairs (the largest and riskiest module in the index), and every grid page apparently started life as a paste of the previous one.

The sanitizer at the top of the risk pile

Ranking symbols by load_bearing, a 0 to 100 single-point-of-failure score, returned in under a second. The top of the list:

SymbolModuleload_bearing
FormatTextNop.Common.Utils.Html99.7
GetCustomerTimeZoneNop.BusinessLogic.Profile94.9
GenerateRandomDigitCodeNop.Common.Utils94.9
EncryptNop.BusinessLogic.Security93.7
BindGridAdministration.Modules79.2
EnsureOnlyAllowedHtmlNop.Common.Utils.Html79.1
GetShoppingCartItemWarningsNop.BusinessLogic.Orders77.0

An HTML formatting function at 99.7 is a strong claim, so I asked the fault-tree tool to treat FormatText as a symptom and rank likely incident-precursor code around it. It resolved the seeds and returned seven candidates, and the entire top of the list is the same family: FormatTextSimple, EnsureOnlyAllowedHtml, StripTags, ShortenUrl, ConvertPlainTextToHtml, all in Nop.Common.Utils.Html, connected by proven static call chains rather than guesses.

In other words, nopCommerce 1.90 routes its formatting through a hand-rolled HTML sanitizer, that sanitizer family is the most load-bearing code in the application, and the member with the most churn sits at rank one. In a 2010 e-commerce app, a custom sanitizer with churn is a textbook XSS breeding ground. The engine surfaced it from a cold start, with no knowledge of the domain and no CVE feed. It just followed the graph.

Watching the Entity Framework rewrite land

Because the clone carries every release tag from release-1.70 through release-4.20, I could run the engine's semantic diff between release-1.90 and release-2.00, which took 8 seconds. Release 2.00 is the big one, the rewrite that moved the data layer from stored procedures to Entity Framework and reorganized the source under src/.

The diff caught the rewrite paying for its modernization in complexity:

FindingTargetEvidence
complexity_regressionCustomerServicecognitive complexity 0 to 238
complexity_regressionForumServicecognitive complexity 0 to 177
complexity_regressionNopObjectContext.ObjectSetscognitive complexity 0 to 110, the new EF context
high_blast_radiusNopRequestCache44 direct callers across 28 modules
untested_changeResetCachedValuesnone of its 20 callers is a test
untested_changeGetCustomerByIdnone of its 13 callers is a test

One caveat on reading the complexity numbers: "0 to 238" partly reflects relocation. CustomerService.cs physically moved from Libraries/Nop.BusinessLogic/Customer/ to src/Libraries/Nop.Services/Customers/ between the releases and grew into a 968-line class with 33 public methods, so the diff is measuring a symbol that was thin or absent at 1.90 and enormous at 2.00, rather than one method edited in place.

The enormity is real, though. The worst method, GetAllCustomers, takes 15 parameters and assembles a dynamic LINQ query through cascading ifs. The part that would hurt in production:

if (!String.IsNullOrWhiteSpace(firstName))
    query = query.Where(c => c.CustomerAttributes
        .Where(ca => ca.Key == SystemCustomerAttributeNames.FirstName
                  && ca.Value.Contains(firstName)).Count() > 0);
if (!String.IsNullOrWhiteSpace(lastName))
    query = query.Where(c => c.CustomerAttributes
        .Where(ca => ca.Key == SystemCustomerAttributeNames.LastName
                  && ca.Value.Contains(lastName)).Count() > 0);

Customer names are stored as entity-attribute-value rows in CustomerAttributes, so filtering the admin customer grid by name emits a correlated subquery per customer against an attribute table, with .Contains() compiling to a leading-wildcard LIKE on top. Non-sargable, unindexable, and sitting directly under the search box every store admin uses daily.

Meanwhile NopRequestCache, the cache chokepoint the rewrite threaded through 28 modules, had zero tests among the 20 callers of its reset path. The diff also certified a negative: no new nontermination, rerender-loop, or per-iteration-IO facts were introduced by the rewrite. Suspicion where the evidence points, a clean bill where it does not.

Two honest zeros

Two queries returned nothing, and both were correct to. The schema query found 0 tables, because nopCommerce keeps its DDL inside stored-procedure install scripts rather than any migration layout the engine recognizes. The service-edges query found 0 runtime edges, because there were no signals in this codebase for it to build edges from. On a codebase that does not match its assumptions, the engine reports nothing found instead of inventing something plausible. For a tool whose output feeds AI assistants, declining to guess is a feature I care about more than any single detection.

Timing

All operations against the same 22,585-symbol index, single machine, release build:

OperationWall time
index generate (cold, 2,463 files)40 s
metrics query, rankedunder 1 s
duplicate detection (22k symbols)2 s
vital health scoreunder 2 s
fault-tree locate3 s
cross-release semantic diff8 s

Errors, panics, and hangs across the entire session: zero. The final tally on nopCommerce 1.90: an A grade with an 18.9% asterisk, one fossilized SOAP layer, and a hand-rolled HTML sanitizer that deserved a retirement party in 2011.

Comments

to join the conversation.