I built a lint that caught 3 tax-rule lies my manual sweep missed — a pattern-based safety net for hand-maintained SEO

작성자

카테고리:

← 피드로
DEV Community · Chetan Sanghani · 2026-07-23 개발(SW)

Three Sundays ago I found seven pages on my site independently asserting something factually wrong about Indian tax law. The .razor sources were all correct. The pre-rendered static HTML mirrors — which is what Google actually indexes — had drifted.

I fixed all seven by hand. I congratulated myself. I moved on.

Then this Wednesday I built a lint to catch the pattern automatically. It surfaced three more identical lies on three completely different pages. My manual sweep had missed them.

Here’s what I built, why regex alone doesn’t work, and the two-phase pattern that does.

The setup — why drift happens

SmartTaxCalc.in is a Blazor WebAssembly SPA deployed to Cloudflare Pages. Every blog post has two representations:

  1. Pages/Blog/*.razor — the source of truth. Compiled into the WASM bundle. Rendered client-side after Blazor boots.
  2. wwwroot/blog/<slug>/index.html — a static HTML mirror. What Cloudflare serves at page load. What Google indexes. What users see for the first 1-2 seconds before Blazor hydrates.

The mirrors are hand-maintained. No build step regenerates them from the razors. So when I fix a fact in a razor, nothing propagates it to the mirror. The mirror keeps the old, wrong content.

I already had one lint guarding one part of this:

# scripts/lint-title-parity.ps1
# Ensures the razor's <PageTitle> matches the mirror's <title>.
# Runs in CI on every push touching razor/mirror paths.

Enter fullscreen mode Exit fullscreen mode

That catches SEO titles going out of sync. It does not catch anything below the title tag. Every paragraph of body content is on trust.

The Sunday crisis

Running through an editorial integrity audit, I found the same false claim in seven different blog mirrors:

“Section 80CCD(1B) NPS is now allowed in both regimes.”

For readers not in India: this is wrong. Section 80CCD(1B) is a ₹50,000 individual NPS deduction that is Old-Tax-Regime-only. The New Regime disallows it. It matters — a taxpayer following that advice would over-claim and face a Section 143(1) intimation.

The razors said the correct thing everywhere: “80CCD(1B) is Old Regime only.” Every mirror I inspected asserted the opposite. Seven of them. Independently. No shared component.

Root cause: some earlier session (months ago, before I was on the project) fixed the razors after a Budget-2025 misreading. Never touched the mirrors. Title-parity was 94/0 clean the whole time because titles never changed.

I fixed all seven mirrors in one commit and moved on.

The lint I built this Wednesday

Three days later, still uneasy that I might have missed one, I built a lint.

Naïve attempt: search for the deduction name near “New Regime”:

$pattern = '80CCD\(1B\).*(?:allowed|available)(?:.*)?new\s+regime'

Enter fullscreen mode Exit fullscreen mode

Ran it against 100 mirrors. Seventeen violations. All false positives. Every mirror flagged said something like:

“Section 80CCD(1B) is disallowed under the New Regime.”

The word allowed is a substring of disallowed. Word boundaries help a little (\ballowed\b), but not enough — real sentences frame this rule in many ways: “not allowed”, “not permitted”, “only in Old Regime”, “cannot claim”, “forfeit”, etc.

The two-phase pattern that works

The insight: a positive assertion and a negation can share the same lexical proximity. What distinguishes them is context. So don’t try to write one regex that captures both.

Instead:

  1. Phase 1: Find suspicious co-occurrences (the deduction name near “New Regime”). Cast a wide net.
  2. Phase 2: For each hit, examine the surrounding ~400 characters. If any safe-indicator word appears (disallow, not allow, Old Regime only, without touching, mistake, cannot, etc.), the sentence is likely correcting the claim, not asserting it. Skip.

Here’s the core of the current lint (PowerShell 7):

$forbiddenClaims = @(
    @{
        Name    = '80CCD(1B) claimed to work in New Regime'
        Pattern = '(?:80CCD\(1B\)[^\r\n]{0,120}(?:new\s+regime|new\s+tax\s+regime))' +
                  '|(?:(?:new\s+regime|new\s+tax\s+regime)[^\r\n]{0,120}80CCD\(1B\))'
        Fact    = "Section 80CCD(1B) is Old Regime only. Only 80CCD(2) survives the New Regime."
    }
    # ... more rules
)

$safeIndicators = @(
    '\bdisallow',
    '\bnot\s+allow',
    '\bdoes\s+not\s+(?:allow|permit|apply)',
    '\bold\s+regime\s+only',
    '\bwithout\s+(?:ever\s+)?touching',
    '\bmistake',
    '\bbelieving\s+80CCD',
    # ~20 more
)

function Test-HasSafeIndicator {
    param([string]$window)
    foreach ($ind in $safeIndicators) {
        if ($window -imatch $ind) { return $true }
    }
    return $false
}

foreach ($file in $mirrorFiles) {
    $bodyContent = # ...skip <head>...

    foreach ($rule in $forbiddenClaims) {
        $matches = [regex]::Matches($bodyContent, $rule.Pattern,
            [Text.RegularExpressions.RegexOptions]::IgnoreCase)

        foreach ($match in $matches) {
            $windowStart = [Math]::Max(0, $match.Index - 200)
            $windowEnd = [Math]::Min($bodyContent.Length, $match.Index + $match.Length + 200)
            $window = $bodyContent.Substring($windowStart, $windowEnd - $windowStart)

            if (Test-HasSafeIndicator -window $window) { continue }

            # Real violation. Report.
            $violations += [pscustomobject]@{
                File  = $file.FullName
                Rule  = $rule.Name
                Match = $match.Value
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Two hundred lines total, including all 4 rules and 30-odd safe indicators.

What happened when I ran it

First run against the “clean” tree (post my Sunday fixes):

Mirrors inspected: 100
Violations: 13

Enter fullscreen mode Exit fullscreen mode

Thirteen? I’d sworn I fixed all of them.

Nine were false positives — sentences correctly stating the rule, plus a few worked-example patterns like “Under the Old Regime with HRA claim you would take home X” (correct, but “HRA” and “New Regime” appeared within 100 chars of each other elsewhere in the paragraph). I extended the safe-indicators list to cover those.

Four were real. Different pages, all mine, all live:

  1. /blog/tax-saving-new-regime/, line 166 (intro paragraph):

    “For FY 2026-27, a salaried New Regime taxpayer can still claim Rs 75,000 standard deduction, Rs 50,000 NPS under 80CCD(1B), and an uncapped employer NPS contribution under 80CCD(2).”

  2. /blog/nps-tax-benefits/, line 383 (Bottom Line section):

    “…NPS Tier-1 is one of the highest-leverage tax-saving instruments available — especially under the New Regime, where it’s the only meaningful retirement deduction left. Contributing the full Rs 50,000 under 80CCD(1B) saves…”

  3. /blog/tax-saving-tips/, line 166 (intro):

    “…and Section 80CCD(1B) NPS is now allowed in both regimes. This guide cuts through the noise…”

  4. /blog/section-80c-investments/ — the same class of drift, slightly different phrasing.

I’d manually fixed the other body sections of exactly these files on Sunday. The intros and bottom-lines were on completely different lines. I hadn’t caught them because I was grep-searching for the specific phrasings I’d already found. The lint’s proximity pattern didn’t care about phrasing — it just noticed the semantic co-occurrence.

Same commit that added the lint also fixed the four newly-surfaced lies. Verification loop:

$ pwsh -File scripts/lint-mirror-integrity.ps1
Mirrors inspected: 100
Violations: 0

Enter fullscreen mode Exit fullscreen mode

Why this generalizes

I’m not the only one running a Blazor/Astro/Next.js site with a JS-hydration layer over a static SEO mirror. The exact tech stack varies. The pattern is universal:

  • Two representations of the same content
  • Only one is authoritative (the source)
  • Only the other is what search engines actually see
  • Sync is manual or “should be automatic but isn’t”

The lint doesn’t care about the specific technology. It cares about the semantic co-occurrence in whatever HTML the crawler will see.

Adapt it to your own YMYL (Your-Money-Your-Life) content:

  • Financial: interest rates, tax brackets, deduction limits, loan terms
  • Medical: dosage claims, treatment eligibility, side-effect statements
  • Legal: statute-of-limitations, jurisdiction rules, filing deadlines
  • Coding tutorials: version-specific API claims (“this works in React 17” while React 19 shipped last month)

Each rule is one entry: a co-occurrence pattern and a fact. Safe indicators are shared across all rules. Both are easy to grow — the day I discover another drift class, I add one entry to the $forbiddenClaims array.

What the lint does not do

Deliberate non-goals:

  • It does not regenerate mirrors from razors. That’s a build-step problem, orders of magnitude bigger. The lint just guards specific known-false claims.
  • It does not use an LLM. LLM-based fact-checking would be more general but expensive and non-deterministic. Regex + safe-indicator words is deterministic, runs in under a second on 100 files, and costs nothing in CI.
  • It doesn’t check the razor sources. Those are the source of truth. If a razor is wrong, the CA reviewer catches it. The lint’s scope is: mirrors must not be worse than razors.

Wire it into CI

.github/workflows/lint-title-parity.yml picked up the new job:

mirror-integrity:
  name: Mirror body-content YMYL integrity
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - name: Run lint-mirror-integrity
      shell: pwsh
      run: pwsh -File ./scripts/lint-mirror-integrity.ps1

Enter fullscreen mode Exit fullscreen mode

Path-filtered to razor/mirror/lint-script paths so it doesn’t fire on unrelated pushes. Fails the check on any violation.

What I learned

Three things I didn’t expect:

  1. My “manual sweep” felt thorough because I’d fixed the specific phrasings I’d found. I hadn’t fixed the pattern. The lint caught what I’d missed because it looked for the pattern, not the phrasing.

  2. Safe-indicator words are the harder half. The forbidden-pattern list was five minutes of work. The safe-indicator list has been iterated three times as new false-positives appeared. Every new indicator I add reduces false positives across every rule.

  3. The first run showed me things I didn’t want to see. Discovering four more lies after I thought the crisis was resolved was uncomfortable. But it’s exactly why the lint exists. If it never surfaced a real violation, it wouldn’t be earning its CI time.

The current state

  • Six rules cover 80CCD(1B), HRA, Section 24(b), plus a MathSolver-schema guard (a Google-validator gotcha I ran into weeks earlier and don’t want to accidentally re-introduce).
  • Safe-indicators list is at 30-odd terms.
  • Runs in about 400 ms across 100 mirror files.
  • Zero current violations. Sitting on main as a tripwire.

The lint is short. The insight is small. But it caught real user-facing YMYL bugs that a human editor and a title-parity lint had both missed. That’s a fair trade for 200 lines of PowerShell.

If you’re running a Blazor / Astro / Next.js site with a similar hydration pattern and want the full script, the SmartTaxCalc repo is public at github.com/chetan00118/IndiaTaxPlanner. The file is scripts/lint-mirror-integrity.ps1.

If you have a YMYL-content-heavy site and no drift guard, this is your polite nudge.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다