On May 20, 2026, AWS ended support for DMS Fleet Advisor.

Fleet Advisor answered a question every migration team asks first: what is actually in my database estate, and how hard will it be to move? It was free. It was fully managed. It was backed by the largest cloud provider on earth.

It still lost.

AWS's official notice says only: "After careful consideration, we decided to end support for AWS DMS Fleet Advisor." No reason given. But you don't need one — the documentation tells you. Here is what Fleet Advisor required before it would tell you a single thing about your databases:

  1. Install a standalone data collector in your local environment
  2. Create an Amazon S3 bucket
  3. Create IAM policies, roles, and users — via CloudFormation, which was the recommended path
  4. Create database users with the minimum required permissions on every source
  5. Establish network access from the collector to each database server

Then you'd meet the ceilings: recommendations for up to 100 databases at a time, one-to-one target mapping only, no multitenant server support.

Now picture running that gauntlet inside a bank. You are a Business Solution Architect. You have been asked to scope a migration. You do not yet have approval for the migration — that approval is what the assessment is for. And to produce the assessment, you must first request production database credentials, get an agent binary through software approval, provision an S3 bucket, and get an IAM stack past a security review.

That is a six-week procurement conversation to answer a question you were hoping to answer this week.

AWS's replacement recommendation is Migration Evaluator — a consulting-led engagement. Read that as the finding it is: AWS looked at self-serve migration assessment, and concluded that humans and services do it better than a product.

I think they were half right. And the half they got wrong is the interesting part.


The lesson: friction is a competitor, and it usually wins

We talk about developer tools as if they compete on capability. Fleet Advisor had more capability than the alternative most teams actually used. Do you know what that alternative was?

Opening the stored procedures in SSMS and reading them.

Reading a thousand stored procedures by hand is objectively terrible. It takes weeks. It is error-prone. It does not scale. And it beat a free, fully managed AWS service — because it required zero approvals, zero credentials, zero agents, and zero conversations with anyone.

The competitor was never SCT or a third-party vendor. The competitor was a human with Ctrl+F, and the human's setup time was zero.

When your tool's time-to-first-value is measured in weeks of procurement, you are not competing with other tools. You are competing with people just doing it manually, badly, forever. And in that fight, capability is worth less than you think.

This reframes the design question entirely. Not "what is the most complete assessment I can produce?" but "what is the most useful thing I can produce from the artifacts a BSA already has on their laptop, with zero approvals?"

The answer to that question turns out to be: quite a lot.


What a BSA actually has

Not credentials. Not an AWS account. Not permission to install an agent.

A Git repository full of .sql files.

So I built SPXray — an open-source static analyzer that takes exactly that and nothing else. Drop .sql files in a browser, get back every physical table, schema, column and CRUD operation per stored procedure, in about 30 seconds. No install. No agent. No IAM role. No database connection — not as a limitation, as a design constraint.

The landscape it sits in:

Tool What it needs first The question it answers
AWS SCT Live DB connection + AWS account Will this convert to Aurora?
AWS DMS Fleet Advisor Collector, S3, IAM, DB creds What's in my fleet? — retired May 2026
SQLFlow / GSP A commercial licence Where does this data flow?
sqlglot Nothing Parse this query
SPXray A folder of .sql files What does my migration wave touch?

Those are four genuinely different questions. This is not a "better SCT" — SCT is free and excellent at conversion, and I'd tell you to use it for that. It is a different job, done at a different moment, for someone with a different set of permissions.


The technical problem: nobody parses procedure bodies for free

Here is where it gets interesting for the parser people.

You would reasonably assume the correct approach is: don't write a regex, use an AST parser. sqlglot is excellent, free, no-dependency, and handles 31 dialects. Reach for it and go home.

Except an independent 2026 parser comparison found that sqlglot, given a T-SQL stored procedure, returned "contains unsupported syntax. Falling back to parsing as a 'Command'." It parsed without throwing — but the result lost structural understanding of the procedure body. TRY/CATCH blocks, variable declarations, and control flow were simply not represented in the tree. Their verdict: "If you need to extract lineage from inside stored procedures, the Command fallback mode will not provide it."

General SQL Parser handled every case, including the hard Oracle and stored procedure patterns. It is also, in their words, "a commercial product, so there is a licensing cost."

So the market for parsing inside T-SQL procedure bodies is: free-but-falls-back, or works-but-costs-money. That gap is the entire technical justification for this project. A hand-built extraction engine is normally the wrong answer — here it's the only free one.

Caveat worth stating loudly: SQLFlow's parser has been developed commercially for nearly two decades against a regression corpus of 13,000+ real-world SQL fixtures. I have a few dozen. If you need parsing accuracy across every dialect and you can pay, pay. This is not false modesty — it is the honest boundary, and I'd rather you read it here than discover it in production.


Four bugs that only real SQL finds

Toy SQL passes. Production SQL is where parsers die. Every one of these shipped, and every one is now a permanent regression test.

1. The encoding bug that ate half a file

SSMS saves .sql files in Windows-1252 by default. An em-dash or a bullet point in a comment is byte 0x96 or 0x95 — invalid UTF-8.

# The bug: this throws mid-file, and depending on how you handle it,
# everything after the bad byte silently disappears
text = Path(f).read_text(encoding='utf-8')
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 1247

Enter fullscreen mode Exit fullscreen mode

The catastrophic failure isn't the exception. It's the code path where you catch it, fall back, and lose content after the bad byte — because now your report is confidently wrong. Tables that exist in the file don't exist in the report, and nothing tells the user.

def read_bytes_safe(raw: bytes) -> str:
    """Read the whole buffer first, then decode. Never partial."""
    if raw.startswith(b'\xef\xbb\xbf'):   # strip BOM
        raw = raw[3:]
    for enc in ['utf-8', 'windows-1252', 'cp1252', 'latin-1']:
        try:
            return raw.decode(enc)
        except (UnicodeDecodeError, LookupError):
            continue
    # latin-1 accepts all 256 byte values, so we never reach here —
    # but if we did, replace rather than drop.
    return raw.decode('latin-1', errors='replace')

Enter fullscreen mode Exit fullscreen mode

The test asserts the invariant, not the mechanism:

def test_bad_bytes_never_truncate_file():
    raw = (b"SELECT a.Id FROM dbo.TableAlpha a;\n"
           b"-- smart quote \x92 en-dash \x96 bullet \x95\n"
           b"SELECT b.Id FROM dbo.TableBravo b;\n")
    physical, _ = parse_sp(read_bytes_safe(raw))
    assert "DBO.TABLEBRAVO" in physical   # content AFTER the bad byte survives

Enter fullscreen mode Exit fullscreen mode

2. Alias collision across statements

SELECT o.OrderID   FROM sales.Orders o;   -- o = Orders
SELECT o.OfficeName FROM hr.Offices o;    -- o = Offices

Enter fullscreen mode Exit fullscreen mode

Build one alias map for the whole procedure and OfficeName lands on sales.Orders. The fix is scoping: build the alias map per DML statement, never globally. Obvious in hindsight; invisible until real SQL reuses o, p and c fifteen times in one procedure. Which it always does.

3. Bracketed multi-word identifiers

SELECT LE.[Party ID], LE.[Scheduled Review Date] FROM ...

Enter fullscreen mode Exit fullscreen mode

Any token-based parser splits [Party ID] into PARTY and ID. Now your report claims two columns exist that don't, and misses the one that does.

The fix is a normalize/restore pass around the whole extraction:

def normalize_bracketed(sql: str):
    """[Party ID] -> PARTY_ID, keeping a map back to the display form."""
    mapping = {}
    def replacer(m):
        inner  = m.group(1).strip()
        normed = re.sub(r'\s+', '_', inner).upper()
        mapping[normed] = inner
        return normed
    return re.sub(r'\[([^\]]+)\]', replacer, sql), mapping

Enter fullscreen mode Exit fullscreen mode

Parse in normalized space; restore display names on the way out.

4. The one that mattered: inventing tables from string literals

This is the bug I'm least proud of and learned the most from.

SELECT c.Id FROM dbo.Customers c
WHERE c.Notes = 'migrated FROM dbo.PhantomTable last year'

Enter fullscreen mode Exit fullscreen mode

The FROM pattern matched inside the string literal. The report listed dbo.PhantomTable — a table that does not exist anywhere, invented from a comment someone wrote in a data field.

It got worse. The same bug scraped table names out of dynamic SQL:

DECLARE @s NVARCHAR(MAX) = N'SELECT * FROM dbo.SecretTable';
EXEC sp_executesql @s;

Enter fullscreen mode Exit fullscreen mode

The tool flagged this procedure as "contains dynamic SQL — cannot be statically analyzed, review manually" and simultaneously reported dbo.SecretTable in the results. Both statements cannot be true. Either you can analyze it or you can't. The tool was contradicting itself on the same screen.

Fix — mask literal contents alongside comments:

def mask_string_literals(sql: str) -> str:
    """Blank the CONTENTS of quoted literals, preserving length."""
    def blank(m):
        return "'" + ("\u0000" * (len(m.group(0)) - 2)) + "'"
    return re.sub(r"'(?:[^']|'')*'", blank, sql)

def clean_sql(sql: str) -> str:
    sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.DOTALL)  # block comments
    sql = re.sub(r'--[^\n]*', ' ', sql)                    # line comments
    sql = mask_string_literals(sql)                        # literal contents
    return re.sub(r'\s+', ' ', sql).strip()

Enter fullscreen mode Exit fullscreen mode

The real lesson isn't the regex. It's this: for a tool a human will plan a migration around, a wrong answer is far worse than a missing one. A missing table gets caught in review. An invented table sends someone hunting through a schema for an hour, and quietly destroys their trust in every other row in the report.


Testing a tool whose product is trust

If a BSA plans a cutover around your output and you silently missed a table, they find out at 2am on a Saturday. That risk profile dictates a specific testing strategy — and it is not "chase coverage percentage." Coverage measures lines executed, not promises kept.

I test six contracts — promises that, if broken, mean the tool is lying:

Contract Assertion
Determinism Five runs of the same input produce byte-identical output
Physical only Temp tables and CTE names never appear as tables
No silent drops Bad bytes never truncate a file
Never invent Ambiguous columns are unresolved, never guessed
Dynamic SQL honesty EXEC/sp_executesql always flagged
Offline The parser makes zero network calls

That last one is a real test, not a policy statement:

def test_parsing_makes_no_network_calls(monkeypatch):
    import socket
    def boom(*a, **k):
        raise AssertionError("parser attempted a network connection")
    monkeypatch.setattr(socket.socket, "connect", boom)
    physical, _ = parse_sp(sql_fixture("multi_cte_report.sql"))
    assert physical

Enter fullscreen mode Exit fullscreen mode

Enterprises ask "does it phone home?" A sentence in a README is an assertion. A test in CI is evidence.

Golden files: making behaviour changes reviewable

Every fixture has a committed JSON snapshot of exact parser output. Any change that alters output for existing fixtures fails loudly with a structured diff:

Parser output changed for multi_cte_report.sql
  TABLE LOST:  RISK.RATINGDETAILS
  DBO.PARTY.columns ADDED: ['ACTIVE']

Enter fullscreen mode Exit fullscreen mode

That failure is a question, not a verdict: did I intend this, and is the new output better? If yes, pytest --update-golden and commit the diff — reviewers read it to see the real behavioural change. If no, you just caught a regression before it shipped.

This is how "does my change break what already worked?" gets answered mechanically instead of hopefully. A parser PR with no golden diff changed nothing. A PR with one must explain it.

Strict xfail: known bugs that cannot rot

The most useful pattern I've adopted. Every known defect is a test marked xfail(strict=True):

@pytest.mark.xfail(strict=True, reason="KL-1: output aliases reported as physical columns")
def test_cte_output_alias_not_reported_as_physical_column():
    sql = """
    ;WITH PartyDetails AS (SELECT Id as 'Party ID' FROM dbo.Party)
    SELECT PD.[Party ID] FROM PartyDetails PD
    """
    physical, _ = parse_sp(sql)
    cols = physical["DBO.PARTY"]["columns"]
    assert "ID" in cols                  # the real source column
    assert "Party ID" not in cols        # an output alias is NOT a physical column

Enter fullscreen mode Exit fullscreen mode

While the bug exists, CI is green — it fails as expected. The day someone fixes it, the test XPASSes and CI turns red, forcing them to promote it to a real assertion and update the public limitations table.

Known limitations cannot silently rot into lies. That is worth more to me than ten points of coverage.

KL-1 above is real and open, by the way: given SELECT Id AS 'Party ID', the tool currently reports Party ID as a column of dbo.Party. It isn't — it's an output alias. Fixing it needs alias→source binding, which is exactly the thing regex cannot do reliably and an AST can. It's the strongest argument for the hybrid backend on the roadmap.


What this is not

I would rather lose you here than waste your afternoon:

  • Not a converter. It will not rewrite T-SQL into PL/pgSQL. AWS SCT is free and good at that.
  • Not a lineage platform. No source→target column flow across your estate. Use SQLFlow or Dataedo.
  • Never connects to a database. By design, permanently. Files in, report out.
  • Never executes your SQL. Which is why dynamic SQL is a hard boundary, not a bug. If you can pay for a 20-year parser, pay for it. If you need conversion, use SCT. This exists for the specific moment where you have SQL files, no credentials, and a question due Friday.

Takeaways

  1. Time-to-first-value is a feature, and often the only one that matters. A free AWS service lost to Ctrl+F because Ctrl+F needed no approvals. Count your tool's setup steps. That number competes directly with your capability.
  2. Design for the permissions your user actually has, not the ones the architecture diagram assumes. A BSA has a Git repo, not production credentials.
  3. For analysis tools, wrong is worse than missing. Invented output destroys trust in correct output. Make "never guess" a test, not an aspiration.
  4. Determinism is a feature you can sell. "Same input, same output, every run" is something no LLM-based tool can put in writing — and it's the difference between a report you can defend in a review and one you can't.
  5. Publish your limitations, and make CI enforce them. Strict-xfail turns your honest gaps into machine-checked facts that break the build when they change.

Repo: github.com/AutoShiftOps/spxray — Apache-2.0, 68 tests, 4 documented limitations.
Live demo: spxray.vercel.app

The most useful contribution is not a feature — it's a failing SQL snippet. If it misses a table in your SQL, open an issue with a minimal reproduction (structure only, invented names) and it becomes a permanent regression test. That is how a few dozen fixtures becomes a few hundred, which is the only path to a parser anyone should trust.