Good bones, drowning in copy-paste: Semfora vs. fifteen-year-old WebForms
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:
| Attribute | Value |
|---|---|
| C# files | 2,178 |
WebForms pages (.aspx) | 293 |
| SQL install scripts | 10 (stored procedures) |
| Working tree | 48 MB |
| Solution | NopCommerce.sln, VS2010-era |
| Source control artifact | NopCommerce.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:
| Cluster | Count | Similarity | What it is |
|---|---|---|---|
Page_Load | 14 | 100% | identical page lifecycle handlers |
SearchButton_Click | 13 | 95 to 100% | every admin grid page has the same search handler |
CancelAsync | 7 | 100% | auto-generated SOAP clients: PayPal, Authorize.Net, FedEx, USAePay, Clickatell, EU VAT |
ddlCountry_SelectedIndexChanged | 2 | 100% | 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:
| Symbol | Module | load_bearing |
|---|---|---|
FormatText | Nop.Common.Utils.Html | 99.7 |
GetCustomerTimeZone | Nop.BusinessLogic.Profile | 94.9 |
GenerateRandomDigitCode | Nop.Common.Utils | 94.9 |
Encrypt | Nop.BusinessLogic.Security | 93.7 |
BindGrid | Administration.Modules | 79.2 |
EnsureOnlyAllowedHtml | Nop.Common.Utils.Html | 79.1 |
GetShoppingCartItemWarnings | Nop.BusinessLogic.Orders | 77.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:
| Finding | Target | Evidence |
|---|---|---|
| complexity_regression | CustomerService | cognitive complexity 0 to 238 |
| complexity_regression | ForumService | cognitive complexity 0 to 177 |
| complexity_regression | NopObjectContext.ObjectSets | cognitive complexity 0 to 110, the new EF context |
| high_blast_radius | NopRequestCache | 44 direct callers across 28 modules |
| untested_change | ResetCachedValues | none of its 20 callers is a test |
| untested_change | GetCustomerById | none 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:
| Operation | Wall time |
|---|---|
| index generate (cold, 2,463 files) | 40 s |
| metrics query, ranked | under 1 s |
| duplicate detection (22k symbols) | 2 s |
| vital health score | under 2 s |
| fault-tree locate | 3 s |
| cross-release semantic diff | 8 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.