All notable changes to Django ORM Lens will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
DOL007 reported an N+1 on loops that iterate model classes. Reported in #72.
The rule treated every
for x in <expr>:head as a loop over a queryset, so a sweep likefor model in auditory_models():was in scope andmodel.ANONYMISE_AFTER— a plain class attribute resolved through the MRO, no database involved — came back as a possible N+1 suggestingselect_related()/prefetch_related(), which have nothing to act on there. Loops overrange(...),os.listdir(...)and any other helper call were reported the same way.The loop head is now gated on its source. A source containing a
(is in scope only when the text before that first(is<Model>.objects.<method>— exactly three dotted segments withobjectsin the middle — or a dotted chain ending in a queryset-producing method (filter,exclude,annotate,order_by, …).range(...),os.listdir(...),apps.get_models()and a helper call such asauditory_models()are skipped. The cost is a false negative: a helper that does return a queryset,recent()orself.get_queryset(), is skipped as well, because a line-oriented rule cannot follow what a callee returns.A source containing no
(— a bare name (for user in users:) or a dotted chain (for entry in self.pending:) — is still in scope: it names no call, so it is evidence neither way, andusers = User.objects.all()is the common idiom.
-
Export as SVG wrote an unopenable file. Reported by a user; PNG export was never affected, which is why this survived since the feature landed.
html-to-imagereturns two different data-URL flavours.toPnggivesdata:image/png;base64,…;toSvggivesdata:image/svg+xml;charset=utf-8,followed by percent-encoded markup. The save path assumed base64 for anything starting withdata:, and base64-decoding percent-encoded text does not fail — the decoder silently drops%,<,"and every other character outside its alphabet and happily returns bytes. A 48-character<svg>document came out asdc 2b 2f 83 6d 31 9a 59 …, so the file on disk had the right name, a plausible size, and no valid content anywhere in it.The transfer encoding is now read from the data URL header instead of guessed from the
data:prefix, so base64 and percent-encoded payloads each take their own path. A malformed escape sequence falls back to writing the raw text rather than throwing away the export.
-
The extension now asks for a GitHub star — once, and only after it has been useful. Measured on 2026-08-12: 62 Marketplace installs against 61 GitHub stars. Installs overtaking stars says people find the extension in the Marketplace, use it, and never open the repository; the ask was absent entirely, so its conversion was not low, it was zero by construction.
The prompt is deliberately not shown on install — a prompt that arrives before the tool has done anything gets dismissed reflexively, and that dismissal is permanent in the user's mind whether or not it is in ours. It fires on the third user-initiated ER diagram open. Sidebar refreshes that re-render an already-open panel are not counted: they are not the user asking for anything, and counting them would inflate the trigger into something closer to a timer.
"Later" and "Don't ask again" are stored as separate states. Conflating them either nags someone who declined or silently drops someone who was merely busy. A deferral re-arms the ask exactly once, twelve opens later; ignored twice, it goes quiet for good. Two prompts, lifetime maximum.
The decision is a pure function (
shouldPrompt) with the thresholds injectable, so the policy is covered by six tests that need no VS Code host. Everything touchingvscodeis confined to one function, and it swallows its own errors: a broken nag must not break the diagram it is attached to.
-
The sidebar and the ER diagram now see django-mptt models. The extension ships the TypeScript half of the parser change released as py-1.12.0: a class built on
MPTTModelused to be skipped before any field was read, andTreeForeignKey/TreeOneToOneField/TreeManyToManyFieldwere dropped against the field whitelist even inside a model that was detected. Both are fixed, and theTree*fields are reported as the Django field they subclass, so aTreeForeignKey('self', ...)draws the same self-edge a plainForeignKey('self', ...)does.Cut as its own release rather than folded into a later one: between py-1.12.0 shipping and this version, the CLI and the extension disagreed about what a django-mptt schema contains, which is the exact failure the shared golden fixture exists to prevent. See the py-1.12.0 entry below for the full reasoning and the Saleor measurement.
-
django-mptt models are no longer invisible.
MPTTModelmatched none of the base-class patterns the parser recognises, so aclass Category(MPTTModel)was dropped before a single field was read — absent from the sidebar, the ER diagram, and every analyzer. Closing that alone would not have been enough:TreeForeignKey,TreeOneToOneFieldandTreeManyToManyFieldare checked against a literal whitelist too, so the model would have surfaced with its scalar fields and no edges at all — worse than hidden, because it looks complete. Both halves are fixed. TheTree*fields are thin subclasses of Django's own relation fields, so they are reported as the field they subclass:TreeForeignKey('self', ...)produces exactly the self-edge a plainForeignKey('self', ...)does, honouringon_deleteandrelated_name, and nothing downstream needs to learn about mptt.Measured on the vendored corpus: Saleor's
product.Category— a realMPTTModelwith a self-referentialparent— now appears in the golden snapshot with all of its fields and itschildrenedge. The snapshot diff is 76 added lines and no removed ones, so nothing that used to parse was disturbed.Additive and import-free by design: no
django-mpttdependency, so the parser keeps working against a project whose venv is broken. Mirrored insrc/parser.tsso the extension and the CLI cannot disagree about a schema. Closes #49.
DOL021documented theUSE_TZdefault wrongly, and overstated whattimezone.now()returns. The page claimedUSE_TZ=Truewas "Django's default since 4.0". It was not: the framework default indjango.conf.global_settingsstayedFalsethrough 4.2 and becameTrueonly in 5.0. Since 4.0 thestartprojecttemplate writesUSE_TZ = Trueinto generated settings, which is a separate thing — a project upgraded to 4.x keepsFalseuntil someone sets it. Collapsing the two misled exactly the most common reader: an existing 4.x project with the setting untouched. The page also calledtimezone.now()"an aware UTC datetime" flatly, when it follows the setting and returns naive local time underUSE_TZ=False. The same claim was duplicated insrc/rules/datetime.tsand is corrected there too. Found by @Justine0211 while translating the page, by declining to translate a statement they could not verify against the Django release notes.
- The
parity_input.pytest fixture now carries thefrom django.db import modelsimport a realmodels.pywould have. No behaviour change — the parity test asserts model shape, never line numbers — but the fixture reads as genuine Django, and starts clean if that directory is ever linted. Contributed by @RinZ27 in #64.
- django-taggit's
TaggableManageris a many-to-many relation now. It declares itself like an ordinary field, but the relation it creates runs throughtaggit.TaggedItemtotaggit.Tag. The parser saw a field type it did not know and dropped the edge, so every tagged model showed one relation fewer than it has — on the Read the Docs golden fixture thetagsfield was absent outright, which is why that snapshot gains an entry rather than changing one. Explicitthrough=overrides are honoured. Both parsers are updated, Python and TypeScript, so the CLI and the extension keep answering identically. Contributed by @Guflly in #63, closing #50.
Three defects found by running the CLI over real checkouts of django-oscar, django-guardian, django-allauth and django-cms rather than over fixtures. Each one was invisible to a green test suite, and two of them made the tool answer confidently with something false.
-
driftreported a false blocking failure when two apps shared a name. The third finding from the same real-world run, on django-guardian, which ships bothexample_project/coreandexample_project_custom_group/core. The parser labels an app by its directory, so the declared side merged the two; the migration side replayed each directory on its own. One project's migrations were therefore compared against both projects' models, and the report contradicted itself —core.customgroupcame out as "declared, but no migration creates it" and "migrated but no longer declared", withcore.customuserprinted twice. The first of those is a blocking verdict, so anyone runningdriftin CI over a repo with two same-named app directories — every monorepo — could have a build failed by a model that was migrated perfectly well. Replayed state is now merged per app name, matching how the declared side is already keyed. On the real guardian checkout the blocking count goes from 1 to 0 and the duplicate row disappears, while a genuinely unmigrated field still blocks. -
Abstract bases in
abstract_models.pywere never read. The other half of the same django-oscar run: once its models were found, 72 of the 83 had zero fields between them, because every pluggable framework keeps the abstract base inabstract_models.pyand leavesmodels.pyholding only the concrete subclass. A model reported with no columns reads as a schema that lost them, which is a worse answer than admitting the file was not read. The workspace walk now takesabstract_models.pyalongsidemodels.py; the bases are still dropped from the results, they only become available for inheritance. oscar goes from 72 empty models to 8 — and those 8 are correct: they subclass concrete models, where multi-table inheritance leaves the columns on the parent's table. -
Models declared inside a module-level block were invisible. Running the CLI over a real django-oscar checkout — not a fixture — showed 12 models for the whole framework, and every one of them came from oscar's
tests/directory: 21 of its 22 appmodels.pyfiles parsed to nothing. Catalogue, order, offer, partner, payment, shipping, voucher, customer, address, analytics, reviews and wishlists were all missing. Reporting a project's test fixtures as its schema is worse than reporting none of it. The cause is the swappable-model idiom every pluggable Django framework uses —if not is_model_registered(...):and then an indentedclass— against class discovery anchored on^class. A class is now matched on its dedented view and accepted when everything enclosing it is a block statement (if,try,with,for); adeforclassoutwards still rejects it, soMeta, nested helpers and factory-local models stay out. django-oscar now yields 82 models. All six golden snapshots are byte-identical: a column-0 class parses exactly as before.
- A sixth golden fixture: Read the Docs. The vendored
projects/models.py, its generated snapshot, reproducible fetch metadata and MIT attribution, next to Zulip, Saleor, Wagtail, django-CMS and Mezzanine — 16 models and 142 fields more, putting the parser under 75 models and 537 fields of real-world Django. Verified byte-identical to upstream blobcf1e913dbefore merge: fixtures are parser input for byte-stable snapshots, so an edited copy would quietly rewrite what those snapshots assert. Contributed by @JJordan0C in #62, closing #51.
Extension release, marketplace metadata only — no code change. The listing
still described the extension as a sidebar and an ER diagram, which is what it
was two waves ago: impact analysis, blast radius and schema drift had shipped
and nothing on the store page said so. Someone searching for those never found
it. The description now names them and says plainly that it is free and MIT
with no Pro tier, and seven keywords were added (schema drift, impact analysis, blast radius, code review, pull request, free,
open source). Both take effect only on publish, which is why they needed a
release of their own.
-
The MCP server reported the SDK's version as its own.
FastMCPtakes no version and forwards none to the low-levelServer, and the SDK then falls back toimportlib.metadata.version("mcp")— so everyinitializeresponse told the client this server was1.29.0, themcprelease number. Anything keying an integration or a bug report off the reported version was reading the wrong project's. The package version is now set on the server the SDK actually reads, and the tests assert the value that reachesinitializerather than the attribute we write, so a future SDK rename fails the suite instead of silently restoring the wrong number. -
Abstract base fields count as the child's own.
driftcompared each model's migrations against only what its class body declares, so every model whose columns come from an abstract base looked like it had dropped them. On django-guardian the two permission models reported four fields each as "migrated but no longer declared" — advisory noise on a project with no drift at all. The parser now resolves abstract inheritance the way Django does: an abstract base's fields land on every concrete descendant, a field the child redeclares wins, and a concrete base donates nothing because multi-table inheritance leaves the parent's columns on the parent's table. The fields are exposed asinherited_fields/all_fields(), kept out of the tree and ER output so those keep showing what each class literally declares. Reported by @sevdog in #58. -
suggest-indexstops proposing indexes that already exist. It readMeta.indexesand nothing else, so it recommended indexes for the primary key, fordb_index=Trueandunique=Truefields, for foreign keys (Django indexes those itself), and for column groups already covered byunique_togetheror aUniqueConstraint.filter(pk=…)andfilter(id=…)were also counted as two different fields when they are one lookup — they now fold onto the real primary-key column. A field left without a proposal for this reason appears under a newalready_indexedkey, so silence is distinguishable from the analyzer having missed the usage. Underlying all of it: the parser truncated any multi-lineclass Metavalue to a bare[, which is whyconstraintsand a list-per-lineindexeswere invisible. Both list and tuple spellings offields=are read. Reported by @sevdog in #60; @RinZ27 independently diagnosed the same cause in #61.
driftexplains its own marks. The text report tagged every entry!!or~and documented neither, so the only way to learn what they meant was to readdrift.py. A two-line legend now precedes the entries, the same two lines appear indrift --helpalongside the exit-code rule, and the legend is imported from the module that prints the marks rather than retyped, so the two cannot fall out of step. No legend on a clean run — nothing is marked there. JSON is unchanged; it always carried"blocking"outright. Reported by @sevdog in #57.
blast_radius,driftandimpactare MCP tools now. The four analyzers added in 1.7.0 shipped to the CLI only, which quietly broke the project's own promise of three surfaces over one parser core: an agent could ask what a model looked like but not what dropping a field would hit. All three are exposed with the same workspace resolution and the same structured error envelope as the existing tools, taking the tool count from ten to thirteen.blast_radiusaccepts an optionalseverity;impactrequires anameand returnsMISSING_NAMErather than an empty result when it is absent.
pip install "django-orm-lens[mcp]"produced a server that would not start. The extra asked formcp>=1.0, so a fresh install resolved mcp 2.0.0, which removedmcp.server.fastmcp— the module the server bootstraps from. Every new MCP install since that release printed "django-orm-lens MCP requires the 'mcp' package" and exited, while the package was in fact installed. Capped tomcp>=1.0,<2; adapting to the 2.x API is separate work. A regression test now pins the upper bound so it cannot be widened without the code changing too. Found because the Glama directory's build failed: it builds the repo's Dockerfile and speaks MCP to the container, which is a stricter check than anything in the test suite, all of which ran against a local mcp 1.28.1.- The published container defaulted to
--helpinstead of the MCP server. Directories that index MCP servers build the Dockerfile and then try to talk to the container over stdio; it printed usage and exited. OnlyCMDchanged, sodocker run ... scan --path .and every other documented CLI invocation behave exactly as before.
Extension release. Everything here has been on main for some time; the
webview and security work shipped to the CLI in earlier py-* releases while
the extension binary stayed at 0.9.0, so this is the build that actually puts
it in front of editor users.
- Impact Analysis grouped whole projects under the wrong layer. Layer
detection matched patterns like
/tests/and/views.pyanywhere in a file's absolute path, so a project checked out under any directory calledtests— or a monorepo withservices/tests/above it — had every one of its files reported as that layer:views.pyas a test,admin.pyas a test. Classification now runs on the path relative to the workspace root, vialayerOffed fromworkspace.asRelativePath, so only the project's own layout counts. The CLI half of this fix shipped in py-1.7.0; this is the editor half. - Webview messages are validated by origin rather than by source.
- Security review follow-ups — the findings from the full-repo review and
the open CodeQL alerts, carried over from
6262963and984aa22.
- Conflicting leaf migrations are detected — two migrations claiming the
same parent, which Django only complains about at
migratetime.
blast-radius— the review-time question the tool could not answer before: what does this schema change actually hit? Every destructive migration operation (RemoveField,DeleteModel,RenameField,RenameModel,AlterField) becomes a target carrying its migration risks, every place in the codebase that still references it, and — for whole-model operations — the cascade fallout. The three analyzers behind it already shipped separately; nobody joined them by hand, so the tool does it now.--format markdownemits a ready-to-post PR comment,--format githubemits annotations naming the reference count in the title, and--onlynarrows the scan to a PR's changed migration files. Exit code1on remaining critical risks, matchingmigration-risk. Available through the GitHub Action ascommand: blast-radius.drift—makemigrations --checkwithout booting Django. Each app's migrations are replayed in numeric order into the field set they imply, and compared against whatmodels.pydeclares. Django's own check needs a working settings module, an importable app registry and every dependency installed, so it is unavailable on a cold clone or a broken venv — exactly when acting on the answer is cheapest. Only the dangerous direction fails the build: a field declared but never migrated (the column will not exist, and the first query touching it errors), or a model with noCreateModelanywhere. Columns present in the migrations but absent frommodels.pyare reported without blocking, because static analysis cannot see fields injected by mixins or metaclass-resolved abstract bases, and failing on those would teach people to pass--exit-zeropermanently.nplusonenow resolves across functions — the detector used to give up whenever a loop's source was a call rather than an inline chain or a local variable, which is how a large share of real Django code is written: the queryset is built in a helper or inget_queryset(), and the loop lives elsewhere.for post in recent():is now analysed. Resolution is one hop within the module and covershelper(),self.get_queryset(),cls.build(), a helper returning a local binding, a return from inside anif, and a helper defined after its caller. Fixes count from either side of the call —select_relatedinside the helper andrecent().select_related(...)applied by the caller are both silent, since the two chains are spliced before the check. A nesteddef's return is never attributed to the function enclosing it, and a call to a name the module does not define is left alone rather than assigned an invented model.blast-radius --statsandstats-sql— optional production table statistics, with no database connection.stats-sqlprints a read-only query (pg_stat_user_tables+pg_total_relation_size, no locks, no user data); you run it against a replica and pass the JSON to--stats. The report then saysblog_post: ~41 000 000 rows, 12.0 GB, 4 index(es)instead of leaning on the "anything after0001_is populated" heuristic. A credential never enters CI config and there is nothing to leak; the file can be committed and reviewed like any other input. Numbers are always labelled as estimates —n_live_tupdrifts betweenANALYZEruns — and a table absent from the file is reported as unknown, never as zero, so a model production has never seen cannot read as "safe to drop".Meta.db_tableis honoured when resolving a model to its table.blast-radiusas a real PR bot — the Action gainedcomment: true, which posts the markdown report and then updates that same comment on every later push, so a twenty-push PR carries one report rather than twenty. Matching is by the<!-- django-orm-lens: blast-radius -->marker the renderer emits as its first line.only-changed: truenarrows the report to migrations the PR actually touches and exits early when it touches none; the file list comes from the API rather thangit diff, becauseactions/checkoutdefaults tofetch-depth: 1and the base commit is simply not in the local history. The comment is posted before the job fails, so a blocked PR still carries the explanation — the exit code is preserved either way. Onpushevents both flags skip with a notice instead of failing, so one workflow covers both triggers.impact <name>— "what still references this field or model?", grouped by Django layer with acertain/likely/possiblyconfidence tag. The analysis existed only inside the VS Code extension; it now ships in the CLI too, which is what makesblast-radiuspossible in CI.er --format dot— Graphviz DOT export alongside Mermaid, DBML, D2 and PlantUML, exposed through both the CLI and the MCPer_diagramtool. Apps becomesubgraph cluster_*blocks and model bodies are HTML tables rather than record labels, so field names containing|or<cannot break the render. Useful in particular for projects migrating fromdjango-extensions graph_models, which emits the same format. Contributed by @JJordan0C in #53 — the project's first outside contribution. Closes #48.- Vietnamese rule reference —
docs/i18n/rules/vi/covers the queryset familyDOL001–DOL007, the first translation of the rule pages into any language. Code samples, rule codes and suppression syntax stay identical to the English source so they remain copy-pasteable and greppable. Contributed by @RinZ27 in #54, against #52.
- Layer detection no longer reads the checkout path — impact analysis
classifies a file by matching patterns like
/tests/and/views.pyagainst its path, and it was matching the absolute path. A project checked out under any directory calledtests(orapi,forms, …) had every one of its files classified into that layer —views.pyreported astests, and so on, in the Impact Analysis panel. Classification now runs on the path relative to the workspace root, so only the project's own layout counts. Fixed in both implementations: the CLI analyzer and the VS Code extension (layerOf, which the extension feeds fromworkspace.asRelativePath). Caught by the first end-to-end run ofblast-radius, whose fixture happens to live undercli/tests/. - DOL005 and DOL006 documentation — DOL005 claimed the
Q(...)rewrite buys "one pass for the query planner"; Django already compiles the chained form into a single query, so the rule is a legibility hint and now says so. DOL006 claimedlist(qs)builds "a second in-memory copy of every row"; it does not duplicate the model instances, and dropping the wrapper does not give you streaming — that still needs an explicit.iterator(). Both had been wrong since the rules shipped, and surfaced during review of #54.
conflicting_migration_leaves(migration-risk rule 16) — flags an app whose migration graph has more than one leaf, the state Django rejects with "Conflicting migrations detected; multiple leaf nodes in the migration graph". Two branches each adding a migration on the same parent produce it. Detected from thedependenciestuples alone, so it fires on a cold clone and in CI rather than waiting for someone to runmigrateagainst a real database. Reported once per conflicting leaf, and inherits the existing SARIF and GitHub-annotation output. The Django app label is recovered from the dependencies, sinceAppConfig.labelmay differ from the package directory; the rule stays silent when that is ambiguous.- Sidebar visibility toggles — checkboxes on apps and models. Unchecking hides the item from the ER diagram, cascading from an app to its models, and relations pointing at a hidden model are dropped so no edge dangles. The state persists per workspace, and stores what is hidden rather than what is visible, so a model added later shows up instead of silently disappearing.
- The ER webview reloaded a cached
graph.js: the script URI never changed between builds, so a rebuilt bundle could not reach the panel. Cache-busted on the bundle's mtime.
python -m django_orm_lensnow works. The package shipped without a__main__.py, so the module invocation failed with "No module named django_orm_lens.main" even though thedjango-orm-lensconsole script was fine.python -mis the invocation that does not depend on the scripts directory being on PATH, which is what CI images, tox environments and fresh venvs rely on. Two regression tests cover it.
The "one core, three surfaces" wave: the CLI gains CI-native output formats, four analyzers that were previously MCP-only, community-standard diagram exports, and a documentation page for every rule.
- CI output formats —
nplusoneandmigration-riskaccept--format sarif(SARIF 2.1.0 for GitHub Code Scanning viaupload-sarif) and--format github(workflow-command PR annotations, zero extra permissions). New moduleci_formats.py; 20 tests. - Four new CLI subcommands exposing analyzers that existed only behind
MCP:
suggest-indexes <model>(Meta.indexes proposals from observed QuerySet usage),signals(sender→signal→handler graph),migration-deps <app>(per-app migration DAG — text/json/mermaid),cascade <model>(delete blast-radius grouped by on_delete). Cascade logic moved into sharedmodels.cascade_previewso the CLI and the MCP server can never drift. - ER diagram export formats —
er --format dbml | d2 | plantumlalongside the Mermaid default; the MCPer_diagramtool takes the same choice via a new optionaldiagram_formatargument. DBML mapson_deleteonto ref settings and apps onto schemas; D2 usessql_tableshapes with apps as containers; PlantUML uses crow's-foot entities. Explicitprimary_key=Trueis respected; Django's implicitidis synthesized otherwise. 11 tests. - Three new migration-risk rules (15 total):
runpython_no_reverse(data migration without reverse_code),alter_unique_together_lock(unique index build/validation on a populated table — recognises all four clearing forms including Django's serializedset()), andalter_index_together_deprecated(operation removed in Django 5.1). - pre-commit hooks —
.pre-commit-hooks.yamlwithdjango-orm-lens-nplusoneanddjango-orm-lens-migration-risk, plus a root pyproject shim so pre-commit can install the repo directly. - GitHub Action — composite
action.yml(uses: FROWNINGdev/django-orm-lens@<ref>) wrapping the CLI with annotation/SARIF-friendly defaults. - docs/rules/ — 19 documentation pages: one per DOL rule (16), the
migration-risk catalogue, the N+1 analyzer, and an index. Fixes the 16
dead
docsUrllinks the Problems panel has been shipping. - Golden snapshot suite — full parser-output snapshots for the five
vendored real-world projects (59 models, 13,478 LOC); parser regressions
now fail with a diff instead of passing silently.
UPDATE_GOLDEN_SNAPSHOTS=1regenerates. - Manifest-sync test — pyproject / server.json / smithery.yaml versions and the MCP tool count vs docstring can no longer drift.
- CI lint job — ruff + mypy on every push/PR, both starting green (433 ruff findings fixed, 9 mypy errors fixed across 5 files).
AlterUniqueTogetherclearing formunique_together=set()— the formmakemigrationsactually writes — no longer raises a falsealter_unique_together_lock.- MCP tool-count drift: 9 → 10 tools everywhere (module docstring,
section comment,
smithery.yaml1.2.7 → 1.4.0 with "10 read-only tools",cli/README.md). - README accuracy: MCP table lists all 10 tools (was 5), migration-risk rule count corrected ("7 classes" → 15 rules), stale v0.8 labels removed.
- DBML export notes are workspace-relative — no absolute machine paths in shareable diagrams.
- README repositioned as "The schema intelligence layer for Django":
10-second
uvxquickstart, a Gate-your-CI section, a measured performance section (59 real-world models / 13,478 LOC parsed in ~21 ms best-of-3 locally), honest "when you want something else" boundaries, and the rule catalogue moved todocs/rules/. migration-riskseverity filtering computes its threshold only for explicit severities instead of relying on a.get(..., 99)fallback for--severity all.
media/vendor/mermaid.min.js(3.34 MB) — dead weight since the webview moved to React Flow; the VSIX shrinks accordingly.
Adds the tenth MCP tool: nplusone_scan — a static scan for Django ORM
N+1 anti-patterns across the workspace. Wraps the existing
query_analyzer.scan_for_nplusone (which had 100% CLI-level test coverage
via test_nplusone_detector.py) as an MCP handler so AI coding agents can
ask "are there N+1 problems in this project?" and get actionable answers
with path:line, the queryset variable, which related fields were
accessed, and a suggested select_related / prefetch_related fix.
nplusone_scanMCP tool — walks every.pyfile, findsfor x in <queryset>:loops, and flags attribute-chain accesses against the loop target that touch a related object (FK / O2O / M2M / reverse FK) without a matchingselect_related/prefetch_relatedclause on the source queryset. Uses the parsedWorkspaceIndexto classify relations; falls back to a schema-less heuristic when a model is unknown.- Structured findings — returns
[{file, line, loop_var, queryset_var, accessed, suggested_fix, confidence}]whereconfidenceis"high"or"medium". SameWorkspaceErrorenvelope pattern as the other 9 tools on workspace-resolution failure. - Tests — 2 new tests in
test_mcp_server.py(envelope on invalid workspace + happy-path detection against a textbookfor book in Book.objects.all(): print(book.author.name)pattern). Full Python suite: 238 passed (was 236), zero regressions. Existing 40+ unit tests intest_nplusone_detector.pycontinue to pin scanner behaviour.
server.jsondescription — bumped tool count from 9 to 10 with the N+1 scanner mention, so MCP Registry + Smithery listings advertise the new capability accurately.
Professional-grade fix for the workspace-resolution silent-drop bug. Before
1.3.0 every MCP tool function was registered without a workspace_root
parameter in its Python signature — FastMCP therefore treated any
workspace_root kwarg passed by an AI agent as an unknown argument and
silently discarded it, then fell back to $DJANGO_ORM_LENS_ROOT or
os.getcwd(). Cursor, Claude Desktop and Aider intuitively call
list_apps(workspace_root="..."); the argument vanished, the tool returned
[], and the agent concluded the server was broken. Root cause verified
against microsoft/AL#8273, openai/codex#9989, aws-toolkit-jetbrains#6173,
kirodotdev/Kiro#5662 and modelcontextprotocol/python-sdk#1097 — every one is
the same class of silent-workspace-drop symptom.
workspace_rootargument on all 9 MCP tools. Declared in every handler signature so FastMCP includes it in theinputSchemaserved totools/list— agents now see it as a first-class parameter and its value reaches the resolution helper unchanged.- New
django_orm_lens.workspacemodule. Single home for resolution, path hardening, and caching. Priority chain: explicit argument →$DJANGO_ORM_LENS_ROOT→ current working directory. A failure at any chosen source is surfaced immediately instead of falling through — that silent fall-through is exactly what produced the pre-1.3.0 bug. - Path hardening.
os.path.expanduser+expandvars+Path.resolve()collapses..traversal and follows symlinks before any validation. Windows reserved device names (CON,PRN,AUX,NUL,COMn,LPTn) are rejected on every OS because paths flow between machines. Django-marker requirement (manage.py/manage.pyw/djangoinpyproject.toml/ anymodels.pyin the tree) rejects agent typos before they scan the wrong directory. - Optional allowlist.
DJANGO_ORM_LENS_ALLOWED_ROOTS(;-separated on Windows,:-separated elsewhere) restricts which prefixes the agent may resolve to. Empty / unset preserves the historical unrestricted behaviour. - Structured error envelope. Every failure now returns
{"error": "WORKSPACE_...", "message": "...", "hint": "...", "path": "..."}as the tool result — agents get an actionable message instead of[]. Stable error codes:WORKSPACE_EMPTY,WORKSPACE_NOT_FOUND,WORKSPACE_NOT_A_DIRECTORY,WORKSPACE_NOT_DJANGO,WORKSPACE_NOT_ALLOWED,WORKSPACE_WINDOWS_RESERVED,WORKSPACE_RESOLVE_FAILED. - Compound cache key. Index cache is now keyed by
(resolved_absolute_path, manage.py mtime)— touchingmanage.pyinvalidates on the next call without waiting for the 30 s TTL. Cache is LRU-capped at 8 workspaces so multi-project agents (Cursor with several folders open) can't unbounded-grow memory. - Tests:
test_mcp_workspace.py(23 unit tests: hardening, priority chain, allowlist, cache) +test_mcp_server.py(22 integration tests + 18 subtests: registry contract, error envelope on every tool, happy path equivalence). Full Python suite: 236 passed (up from 191), zero regressions. TS suite: 104/104 green (unchanged).
- https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem — reference implementation of workspace hardening and containment (path resolve + allowlist), whose semantics this fix ports to Python.
- https://github.com/modelcontextprotocol/servers-archived/tree/main/src/git
— reference example of per-tool
repo_pathargument that inspired the explicit-argument-first priority chain. - microsoft/AL#8273, openai/codex#9989, aws/aws-toolkit-jetbrains#6173, kirodotdev/Kiro#5662 — real-world reports of the identical silent-workspace-drop bug in adjacent MCP servers.
- modelcontextprotocol/python-sdk#1097 — the
roots/listdeadlock that motivated the deliberate choice to keep this release synchronous and defer MCP-roots integration.
Schema-diff gets a first-class partial UniqueConstraint tracker. Inspired
by django-extensions #1813
(BoPeng, 2023) — sqldiff drops the condition= predicate from
UniqueConstraint output, so migration reviewers never see what actually
changed. Django ORM Lens now surfaces these as typed events so PR diffs stop
lying.
PartialUniqueConstraintschema-diff event with four ops:add— new conditionedUniqueConstraintappeared in the new snapshotdrop— constraint existed only in the old snapshotchange— same name, condition (or field list) mutated;fromConditioncarries the pre-change predicate so the review comment can showQ(is_primary=True) → Q(is_primary=True, deleted=False)rename— same fields + same condition, only the name changed; renamed constraints no longer show up as a lossyadd + droppair
- Anonymous-safe. Multiple unnamed constraints on the same model no longer
collapse into a single event — internal keying falls back to
#anon-<index>. - Markdown rendering grew four dedicated bullet variants (
**added** …,**dropped** …,**changed** …,**renamed** …) so the PR description reads like a review comment, not a raw diff. - Tests: 4 new snapshot tests for each op variant (add / drop / change / rename). Schema-diff suite: 17/17 green; full TS suite: 104/104 green.
- django-extensions/django-extensions#1813 — root-cause description + reproducer + workaround acknowledged in comment #5062767387.
Marketplace SEO polish — no runtime changes, no behaviour changes. VS Code extension only (CLI/MCP unchanged, still on py-1.2.7).
- VS Code Marketplace metadata refreshed for better discoverability.
descriptionrewritten to lead with the value ("See your entire Django schema in your editor") instead of the feature list.categoriesexpanded from[Visualization, Other]→[Visualization, Programming Languages, AI, Other]to surface in more Marketplace filters.keywordsexpanded from 7 → 29 with real search terms users type:erd,entity relationship,schema visualizer,model explorer,foreign key,n+1,query optimization,mcp,ai agent,cursor,vscodium,postgresql,django-rest-framework, and more.- Added
galleryBanner(#0c4b33Django-green) — the extension page now has a themed header instead of default grey. - Added
qna: "marketplace"(enable built-in Q&A) andsponsor.url(GitHub Sponsors link surfaced in the sidebar).
- CI: GHCR
Wait for PyPIstep widened 5 min → 15 min. Thepy-v1.2.7publish raced the PyPI CDN and the container step failed at the pip install; wider window makes future releases robust to slow PyPI propagation.
- UTF-8 BOM (
U+FEFF) at the start of a models.py file no longer eats the whole class. Windows editors — Notepad, older Sublime, VS Code with certain encoding settings — save files with a byte-order mark. Without this fix the first line becomes"class Foo..."andCLASS_REfails to match, so every model in the file silently disappears. Both parsers now strip the leading BOM in the content-read step.
test_bom_prefix.py— 2 Python regression tests (BOM-prefixed file parses; no-BOM backward-compat).
- Tab-indented model bodies now parse. Editors that default to tabs (or projects that use PEP-8 exceptions with tabs) had every field silently dropped.
FIELD_REuses\s{indent}as its column prefix and_detect_class_indentcorrectly returns width 4 for tabs, but a single\tcharacter is only one\smatch, not four. Fixed by pre-expanding tabs to 4 spaces in the line buffer before regex matching. Applies to both parsers. Line numbers preserved (per-line expansion only).
test_tab_indented_models.py— 3 Python regression tests (pure-tab body, mixed tab+space, and space-indent backward-compat).
Two more field-detection gaps closed via targeted fuzz of realistic Django user code.
- Aliased
modelsmodule.from django.db import models as mfollowed byclass X(m.Model): x = m.CharField(...)used to lose every field becauseFIELD_REhardcoded themodels\.prefix andBARE_FIELD_REhad no prefix allowance. Both parsers now accept any single-identifier prefix on the RHS inBARE_FIELD_RE. - Third-party field packages. Fields declared via a namespaced third-party import (
x = jsonfield.JSONField(...),x = timezonefield.TimeZoneField(...),x = arrayfield.ArrayField(...)) fell through the same gap. Same fix covers both cases in one edit — the type name is still restricted to Django's known field whitelist, so random non-Django calls likefoo.CharField(...)in an unrelated module don't leak in.
test_aliased_module_and_third_party_fields.py— 4 Python regression tests (aliased-only, mixed aliased+plain+bare interleaved, jsonfield-style third-party, and backward-compat bare imports).
Follow-up to the modern-Python audit that produced 0.7.3 — another shape of typed code that used to silently vanish, surfaced by a targeted fuzz of the class-header regex.
-
PEP-695 generic class headers (Python 3.12+) now parse. The class-header regex was:
^class\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)\s*:which had no allowance for a
[T]group between the class name and the opening(. Any model declared asclass Container[T](models.Model):failed to match — the parser walked past the whole class, no models reported, empty sidebar / ER diagram / n+1 output. Applies to all four common PEP-695 shapes: single-param[T], multi-param[K, V], bounded[T: str], and variadic + paramspec[*Ts, **P].Fixed by inserting an optional
(?:\s*\[[^\]]*\])?group in bothCLASS_REandCLASS_START_RE, in both parsers (Python + TypeScript). Non-generic classes match identically as before.
test_pep695_generic_classes.py— 5 Python regression tests covering the four PEP-695 shapes plus a backward-compat guard for plain class headers.
Bugfix release for #25 — thanks to @jsabater for the reproducible report against a Django Ninja 1.6 codebase.
-
Fields with PEP-526 type annotations (typed Django / Django Ninja style) no longer disappear from the parser output. When code uses the modern typed pattern:
jti: CharField[str] = models.CharField(max_length=32, unique=True)
the field regex expected
name = models.X(and had no allowance for a: <type>group between the name and the=. Result: fields silently vanished from the sidebar tree, the ER diagram entity, and every downstream tool (n+1 detector, MCPdescribe_model,find_relations). Adding fields one-by-one made the whole model progressively empty — the exact reproduction jsabater observed.Fixed in both parsers (
parser.py::_build_body_regexes,src/parser.ts::buildBodyRegexes) by adding an optional(?:\s*:[^=]+)?group after the field name and before=.[^=]+is safe because=never appears inside a Python type expression (subscripts, unions, dotted refs, and generics all use other punctuation). Applied consistently toFIELD_RE,BARE_FIELD_RE, andMETA_ITEM_RE.
test_pep526_type_hints.py— 6 Python regression tests covering the exact snippet from the bug report, the bare-import form (IntegerFieldwithoutmodels.prefix), simple non-generic annotations (label: str = ...), untyped backward-compat, and a Meta block with an annotated attribute (ordering: list[str] = ["title"]).test/pep526.test.js— matching TS regression so the VS Code extension stays behaviour-compatible with the CLI.
CLI welcome UX — bidirectional discoverability between PyPI and the editor extensions. Data motivation: PyPI is doing ~1,663 installs/week while the VS Code Marketplace sits at ~10 installs total. Users who install the CLI don't know the extension exists (and vice-versa in the extension welcome view). This closes the gap.
-
django-orm-lenswelcome (no-arg run) now prints a "prefer a visual sidebar + ER diagram in your editor?" block after the docs link, with copy-paste install commands for both marketplace paths:code --install-extension frowningdev.django-orm-lens— VS Code / Cursor / Windsurfcodium --install-extension frowningdev.django-orm-lens— VSCodium / code-server / Gitpod / any OSS Code fork (via Open VSX)
Rest of the welcome (commands table + star CTA) is unchanged.
Patch release — two crash bugs in the CLI wrapper that unit tests missed because they exercised the underlying helpers directly instead of going through argparse dispatch. Both surfaced during an end-to-end smoke run against a real Django codebase.
django-orm-lens nplusonecrashed withNameError—_cmd_nplusonecalled_build_schema_from_indexandscan_for_nplusonewithout importing either at module scope.scan_for_nplusonenow imported at the top ofcli.py; the intermediate schema flattening is dropped (the underlying function already accepts aWorkspaceIndexand normalises internally via_normalise_schema).django-orm-lens migration-risk(text format) crashed withAttributeError: 'MigrationRisk' object has no attribute 'filePath'— the print statement used camelCase attrs (filePath/lineNumber) but the dataclass fields are snake_case (file_path/line_number). The camelCase form only exists insideto_dict()(JSON path); text output now reads the snake_case fields directly.analyze_migration_riskswas not imported at module scope — same pattern as the nplusone bug, fixed at the same time to prevent a latentNameErroron the next code path change.
test_cli_subcommands_smoke.py— 8 tests that dispatch every subcommand (scan,list,er,describe,hover,nplusone,migration-risktext + JSON) throughmain([...])against fixture data. AnyNameError/ImportError/AttributeErrorin a subcommand body now surfaces as a test failure rather than a crash the next user sees. This closes the coverage gap that let both v0.7.0 bugs ship.
Correctness release focused on settings.AUTH_USER_MODEL resolution across every layer (parser / signals / query analyzer / ER diagram / MCP server / VS Code webview) and kwarg-order-independent field parsing. 10 bugs closed + 4 DRY/perf refactors + 36 new regression tests. All 157 Python tests + 3 TypeScript tests green.
ForeignKey(on_delete=CASCADE, to='User')now resolves correctly regardless of kwarg order. Both the Python (_extract_relatedinparser.py) and TypeScript (extractRelatedinsrc/parser.ts) parsers previously used a positional-first regex that either returnedundefinedor misreadon_deleteas the target whento=wasn't the first kwarg. Python side now usesast.parseon the wrapped arg block; TypeScript side prefers\bto=anywhere and falls back to positional-first with a negative lookahead against kwargs. Similar order-independence applied to_extract_on_delete/_extract_related_name/_extract_through_model.settings.AUTH_USER_MODELresolves to the workspace User model in every consumer, not just the MCP layer. Previously,.split('.')[-1]produced"AUTH_USER_MODEL"— a name no workspace model carries — dropping User-model edges from: reverse-relation schema used by the n+1 detector (query_analyzer._build_schema_from_index+_build_schema_from_index_dict), signal receiver resolution (signals_parser._resolve_sender), Mermaid ER diagram (cli._build_mermaid), VS Code Mermaid webview (src/graphWebview.ts), VS Code inbound-relation panel (src/extension.ts), and the React ER webview (src/webview/graph.tsx). All six paths now delegate to sharedresolve_related_tail(Python) /resolveRelatedTail(TypeScript). The webview additionally receives the pre-resolved User name via the wire payload so it doesn't needbaseClassesshipped over.--verboseno longer walks the workspace twice. The scan summary previously called_iter_python_filesa second time just to count files.WorkspaceIndexnow carriesscanned_files(populated byscan_workspace), so verbose mode reads the count instead of re-iterating — noticeable on large monorepos.formatters._render_tableno longer crashes on short rows. A row shorter than the header triggeredIndexErrorinside the width-computation genexpr. Rows are now padded with""to header width before rendering.
- Shared Python helpers under
django_orm_lenspackage root:find_user_model,find_user_model_from_dict,resolve_related_tail,find_model,iter_workspace_py_files,BROAD_SKIP_DIRS. Downstream tools that build on the parser can import these without duplicating the User-model detection heuristic or the walk-skip-list. - Shared TypeScript helpers in
src/parser.ts:findUserModel(index),resolveRelatedTail(related, userName). Same shape as the Python side so the VS Code extension and the CLI stay in sync. WorkspaceIndex.scanned_files: int— number ofmodels.py-style files inspected during the scan. Surfaced into_dict()asscannedFiles. Backward-compatible (default 0, extra JSON key).WireIndex.userModelName?: stringinsrc/webview/types.ts— resolved User model name shipped to the React webview so it can rewritesettings.AUTH_USER_MODELedges to point at the right node.- 36 regression tests:
test_kwarg_order_and_auth_user.py(35 unit tests covering kwarg-order-independent extractors, User-model discovery, tail resolution, schema-building with AUTH_USER_MODEL,_resolve_sendercases) +test_signals_parser::test_receiver_settings_auth_user_sender_resolves(E2Esignal_graph) +test/kwarg-order.test.js(TS regression).
_iter_py_filesconsolidated across three modules.signals_parser,query_analyzer._iter_py_files, andquery_analyzer._iter_py_files_broadwere three near-identical copies of the same directory walk with slightly different skip-lists. All three now delegate toparser.iter_workspace_py_files(root, extra_skip=frozenset())._find_model/_findconsolidated.cli._find_modelandmcp_server._findwere near-identicalWorkspaceIndexlookups by"app.Model"or bare"Model". Both now delegate tomodels.find_modelso lookup semantics stay in sync.mcp_server._workspace_user_modeland_rel_matches_targetare now thin wrappers over the sharedfind_user_model+resolve_related_tail. Semantics unchanged; the tuple-returning signature of_workspace_user_modelis preserved for existing callers.
Feature release — three new CLI subcommands (nplusone, migration-risk, diff), webview polish, and three README translations. 127 tests passing.
- CLI
nplusonesubcommand — static N+1 detector. Walks.pyfiles and flags queryset iteration where FK / M2M attributes are accessed inside the loop without a matching.select_related(...)/.prefetch_related(...). Schema-backed high-confidence classification when a workspace index is available, heuristic medium-confidence fallback otherwise. Reportsfile:line, loop variable, queryset variable, accessed relations, and a suggested fix. Flags:--path,--format text|json,--confidence high|medium|all,--exit-zero. Exit code 1 on findings (CI-friendly). 18 tests. - CLI
migration-risksubcommand — production-safety linter for Django migrations. Analyzes<app>/migrations/*.pyfiles and flags seven classes of risky operations:AddField(NOT NULL, no default),RemoveField/DeleteModelstill referenced by live code,RenameField/RenameModel(breaks rolling deployments),AddIndexwithoutCONCURRENTLY, lossyAlterFieldtype changes,RunSQLwithoutreverse_sql, andAddField(unique=True)without a row-unique default. Cross-references the current models schema. Per-finding severity (critical/warning/info) and confidence (high/medium/low). Flags:--path,--format text|json,--severity critical|warning|info|all,--exit-zero. 31 tests. - CLI
diffsubcommand — compare two schema JSON dumps.django-orm-lens list --format=json > before.jsononmain, then again on a PR branch →after.json, thendjango-orm-lens diff before.json after.jsonprints added / removed models, added / removed / changed fields, and added / removed / changed relations. Text and JSON output. Exit 1 on any delta (git-diff-like),--exit-zerofor advisory CI stages. 21 tests. - Webview: color-code minimap dots by app. ER-diagram minimap now tints nodes by their owning Django app using a deterministic FNV-1a-based hue, so large schemas can be visually grouped at a glance. Focused node retains the accent color.
- CLI
listsubcommand now supports--format json.django-orm-lens list --format jsonemits a pipe-friendly JSON array[{"app": "...", "model": "..."}, ...]. The defaulttextoutput remains unchanged for backward compatibility. --verbose/-vflag on every scan-backed CLI subcommand (scan,describe,hover,list,er). Prints a one-line summary to stderr after the scan —scanned 12 files in 34ms, found 8 apps / 47 models— sourced from the actual file walk, atime.perf_counter()measurement around the scan, and the real app/model counts on the returned index. Stdout is untouched either way. (#14)- CLI: friendlier hint when no
models.pyis found.scan/describe/hover/list/erpreviously printed a silently-empty result. When zero models are found and nomodels.py/models/*.pyfile was walked at all, ahint: no models.py found under <path>...line is now printed to stderr (stdout stays clean, exit code stays0). New--quiet/-qflag suppresses the hint. (#12)
- README translations:
README.ru.md(Russian),README.es.md(Spanish),README.zh.md(Chinese). Language switcher added at the top of each file. Closes #9, #10, #11. - README: added Screenshots section between Install and The problem, with capture instructions for six product screenshots covering VS Code (sidebar / ER diagram / hover card), CLI (
list,er), and MCP (Cursor conversation). Closes #15.
Bugfix release focused on parser accuracy and MCP correctness. E2E audit against a synthetic Django project with abstract mixins, custom user model, multi-file models/ packages, real migrations, signals, and views uncovered five real bugs that would have hit the ~60 % of production Django codebases that use TimeStampedModel-style mixins or settings.AUTH_USER_MODEL. All fixed here without any behavioural regressions (44 pytests pass — 13 new + 31 pre-existing).
- Abstract-mixin subclasses now correctly identified as models.
class Profile(TimeStamped)whereTimeStamped(models.Model)is a user-defined abstract base was previously invisible to every tool — parser only walked one inheritance level and only against a small hardcoded list of known-Model tails.parse_models_fileis now two-pass: first collects every class definition with its bases + Meta, then resolves transitive inheritance via fixed-point iteration. Any concrete subclass of any class that transitively inherits frommodels.Model(through arbitrary user-defined abstracts) is now returned. Meta.abstract = Trueis now respected. Abstract mixins previously appeared inlist_models,describe_model, ER diagrams, and the VS Code sidebar as if they were concrete tables. They are now filtered out, matching what Django itself does at migration time.settings.AUTH_USER_MODELnow resolves to the workspace User model in relation lookups.cascade_preview(accounts.User)andfind_relations(accounts.User)previously returned empty inbound arrays even when other models hadForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE)— the recommended Django pattern. MCP layer now detects the workspace's User model (first class inheritingAbstractUser/AbstractBaseUser, else literalUser) and treatsAUTH_USER_MODELrefs as pointing at it.--versionshows the real installed version. Previously hardcoded1.0.7across nine releases regardless of which package version was actually installed — confusing bug reports. Now sourced fromimportlib.metadataat import time.- CLI subcommands error on a non-existent
--path.list --path /nonexistentpreviously exited0with empty output — silent failure. Now printserror: --path 'X' is not a directoryto stderr and exits2. list_models(app='wrong_name')returns a helpful error instead of(no models). New response:(app 'wrong_name' not found in workspace; available apps: a, b, c).
- MCP Registry description updated:
"5 read-only tools"→"9 read-only tools (models, relations, cascade, migrations, indexes, signals, ER diagram)". Registry entry was stuck at 1.0.7 and had never reflected the four v0.4/v0.5 additions (describe_migration_dependency,suggest_indexes,signal_graph,er_diagram).
- 13 regression tests for the fixes above under
cli/tests/test_abstract_and_auth_user.py— abstract-drop, two-level abstract chain, plainUserreference,AbstractUsersubclass detection,settings.AUTH_USER_MODELresolution, and negative cases (no user model, wrong target). Guards against regressions in the parser's inheritance logic and the MCP resolution helpers.
MCP tools for AI-agent Django expertise. Ships three flagship additions built on the zero-runtime static-analysis moat: two new MCP tools that solve the top pain points in the Django tooling ecosystem — index recommendations and signal graph visualisation — plus a golden-fixture test suite that proves the parser survives real-world Django code (63 models across Zulip, Saleor, Wagtail, django-CMS). Positioning shifts from "ER diagram + navigation" to "the static-analysis brain that Django AI agents plug into."
suggest_indexes(app_label, model_name)MCP tool — static analysis of every filter/exclude/order_by/get/aggregate usage across the workspace, returns field-usage frequency and proposesMeta.indexescovering entries. Zero-runtime, no DB, no Django boot. Solves the top Django performance blind spot for AI coding agents.signal_graph()MCP tool — parses every@receiver()decorator andSignal()definition in the workspace, returns the sender→signal→handler DAG plus custom-signal send-sites. Surfaces the invisible connections between models that cause the majority of enterprise Django bugs.- Golden fixture suite — parser now tested against real open-source Django projects: Zulip (Apache-2.0, 33 models across
zerver/models/), Saleor (BSD-3, 19 models across product/order/discount/warehouse), Wagtail (BSD-3, 8 models fromwagtail/models/), and django-CMS (BSD-3, 3 models fromcms/models/). Pytest asserts every project scans without error, finds at least 1 model, and the aggregate scan of all vendored fixtures completes under 2 seconds (currently ~11 ms). Credibility:django-orm-lensis proven against 63 total models parsed from real-world Django deployments, not synthetic examples. Fixtures live undercli/tests/fixtures/golden/<project>/<original-path>/models.pywith attribution + fetch date in that directory's README.md.
Hotfix. Extension only.
- Activity-bar icon actually ships in the VSIX now. A stray
media/*.svgline in.vscodeignore(introduced during the 0.4.0 React Flow packaging pass) silently excludedmedia/activitybar.svgfrom the published extension. The branded icon rendered in Extension Development Host (files read from repo path directly) but was completely absent from the Marketplace VSIX — VS Code had nothing to draw, so the sidebar slot appeared blank. Ignore pattern replaced with an explicit whitelist (!media/**/*.png,!media/**/*.svg,!media/webview/**).
VS Code extension only. Python CLI unchanged.
- Branded activity-bar icon — replaced the generic Material database cylinder (which rendered as an apparently blank slot at 24×24 in some VS Code themes) with a three-connected-tables silhouette that reads unambiguously as "ORM schema" at any size. Uses
stroke="currentColor"so the icon inherits VS Code's activity-bar foreground colour on every theme (dark/light/high-contrast). Reload the VS Code window after updating to pick up the new icon — VS Code caches activity-bar SVGs per extension host.
Major visual upgrade for the ER diagram — the VS Code webview now renders every model as an interactive React Flow node instead of a static Mermaid SVG. Drag models around to lay the diagram out the way you think about it, click a node to highlight its inbound and outbound relations, double-click to jump straight to the class in models.py. Edges are colour-coded by relation semantics (ForeignKey CASCADE / SET_NULL / PROTECT, OneToOne, ManyToMany with through= label) so the on_delete blast radius is visible at a glance. Ships with a minimap, zoom controls, and PNG / SVG export. Automatic hierarchical layout via elkjs. Python CLI is unchanged in behaviour — the version bump keeps the extension and CLI shipping together, and the Mermaid emitter (build_mermaid) remains available for the CLI --mermaid output.
- Interactive ER diagram (React Flow) — replaces Mermaid rendering in the VS Code webview. Draggable nodes with rounded corners, drop shadow, and Inter-family typography. Each node shows
app · Modelheader and every field with a colour-coded badge (FK / 1:1 / M2M / plain). Edges are laid out withelkjslayered algorithm (RIGHTdirection, orthogonal routing) so hierarchies read naturally instead of the force-directed mush the previous MermaiderDiagramrendered as workspace size grew. - Click-to-focus highlighting — clicking any model dims unrelated nodes to 35 % opacity, keeps the selected node plus every connected neighbour at full brightness, and animates its edges. Click empty canvas or the same node again to clear.
- Double-click to jump to source — double-clicking a node posts
jumpToModelback to the extension, which opens themodels.pyfile in the primary editor column at the model's class line. Reuses the existing message handler contract from the Mermaid webview. - PNG + SVG export — new Export dropdown in the header uses
html-to-imageto serialise the diagram viewport at 2× pixel ratio (PNG) or as inline SVG. Falls back to VS Code'sshowSaveDialogfor the target path. - MiniMap + zoom controls — bottom-right minimap (150×100) with the current selection highlighted in the accent colour; bottom-left React Flow zoom controls (zoom in / out / fit view). Both surfaces inherit VS Code panel colours via CSS variables and use a subtle
backdrop-filter: blur(6px)for the Linear/ChartDB aesthetic. - Relation-kind legend — small footer chip explains the FK CASCADE / SET_NULL / PROTECT / 1:1 / M2M colour palette so you don't have to guess.
- Webview build pipeline now uses esbuild. New
npm run build:webviewbundlessrc/webview/graph.tsx→media/webview/graph.jsas a minified IIFE (~1.85 MB raw / ~565 KB gzipped, well under the 3 MB VSIX budget).npm run buildruns bothbuild:extension(tsc) andbuild:webview(esbuild). The webview source undersrc/webview/is excluded from the extension'stscproject viatsconfig.json. - Webview header rebuilt to Linear/Vercel spec — 48 px tall, subtle 2 px gradient bottom border (
transparent → focus → transparent), workspace name in the title, app + model count badge, refresh button, and export dropdown. diagramThemeconfig valuesdefault/forest/neutralnow collapse to thelightReact Flow palette. The old Mermaid-specific enum values are still accepted for setting compatibility but no longer produce distinct visuals — Mermaid is the only renderer that had those.autoanddarkbehave as before.
- Mermaid vendored file (
media/vendor/mermaid.min.js) stays in the VSIX in case a user needs to roll back to v0.3.8 by re-enabling the old renderer. - CLI
build_mermaidemitter is unchanged. The Python CLI still exposes--mermaidfor terminal / CI consumers, so agents and pipelines that scrape the Mermaid output are unaffected.
Second hotfix on the two 0.3.6 regressions that 0.3.7's partial fix did not fully resolve for users running a single-app workspace ("hello/" opened at the app root). Icon rendered blank on some installs even after the theme-aware SVG rewrite; the workspace scan still came up empty on cold-start when vscode.workspace.findFiles returned no results before the file index was warm. This release simplifies the icon glyph and adds a direct filesystem walker fallback so the scan never depends on the file index being ready.
- Activity-bar icon is now a single centred database glyph that fills the 24×24 canvas. The v0.3.7 SVG was already
fill="currentColor", but the two-shape database + magnifier composition left large empty margins on the left and top, which on some themes (and at some HiDPI scale factors) rendered as an apparently blank slot in the activity bar. Replaced with a codicon-shaped monochrome database drawn from x=4 to x=20, y=2 to y=22 — visually dense across the whole viewbox and unambiguously visible on every theme. - Workspace scan now falls back to a direct filesystem walk when
findFilesreturns empty. Root cause:vscode.workspace.findFilesdepends on VS Code's internal file index. OnonStartupFinishedactivation the index can be cold, and on some Windows single-folder-workspace setups the**/models.pyglob silently misses depth-0models.pyregardless. v0.3.7 tried to work around this by adding a baremodels.pyRelativePatternand a 1.5s startup-retry backstop; both still failed for users who open the Django app folder itself.scanWorkspacenow runsfindFilesfirst as the fast path, and if it returns zero URIs walks each workspace folder withfs.readdirSyncdirectly, honouring the same exclude-glob defaults (**/migrations/**,**/venv/**, ...). Fallback runs once per empty scan — no cost when the file index is warm.
- Added
cli/tests/test_scan_root.py— three regression tests covering "app-as-workspace-root" (models.py at depth 0), "project-as-workspace-root" (app one level down), and the**/migrations/**exclude at root depth. Locks in that the shared parser behaviour never regresses on the single-app layout.
Hotfix release — closes the two regressions reported against 0.3.6: the activity-bar icon rendered blank (stroke-based SVG that VS Code's activity-bar CSS couldn't theme reliably), and single-app workspaces opened at the app root ("hello/" containing models.py directly) came up empty because the include-glob and initial-scan timing both failed the root-file case. TypeScript extension fixes only; Python CLI + MCP server are re-published at the same version for combined-release parity.
- Activity-bar icon is now visible on every theme. The previous SVG was a stroke-based line drawing with
fill="none"on both the root and every shape. VS Code's activity-bar renderer applies its own theme foreground viafill: currentColor, which the inlinefill="none"overrode — the icon rendered as a blank slot on most themes ("I see the panel open but the sidebar icon is gone" — real user report). Icon is now a fill-based database + magnifier drawn withfill="currentColor", matching the codicon convention for activity-bar entries. - Workspace with
models.pyat the root now scans on activation. Two root causes: (1)vscode.workspace.findFiles('**/models.py', ...)was expected to match root-level files, but on some setups (notably single-folder workspaces on Windows) the leading**/requires at least one path segment and silently skipped<root>/models.py. Now uses avscode.RelativePatternper workspace folder with an explicitmodels.pyinclude alongside**/models.pyand**/models/*.py, de-duplicated onfsPath. (2) WithonStartupFinishedactivation (added in 0.3.6) the initialrefresh()can race the workspace file index and return zero results before it's warm. Now also re-scans onTreeView.onDidChangeVisibility(user clicks the sidebar), onworkspace.onDidChangeWorkspaceFolders, and once as a 1.5s startup backstop if the first scan came back empty against a non-empty workspace. ManualRefresh scanfrom the welcome view is unaffected and continues to work.
Combined release — migration-dependency debugger for AI agents, always-visible activity-bar icon with empty-state welcome, and a subtle star-ask on MCP startup. Ships the three feature commits accumulated since 0.3.5: the migration DAG tool closes a gap no other Django MCP server addresses (agents can now trace conflict chains without booting Django), while the UX and star-ask improvements close the discoverability/conversion loop between install and star.
describe_migration_dependencyMCP tool — return per-app migration DAG (dependencies, roots, leaves, cross-app deps) from static AST parse, no Django boot. Standout differentiator: no other Django MCP server or graph tool (django-schema-graph, django-extensions graph_models, gts360/django-mcp-server, kitespark/django-mcp, admin-mcp-api) offers migration-conflict introspection without a running Django process.
- MCP server prints a one-line star-ask on startup (stderr). Mirrors the CLI welcome convention from py-1.0.9. Zero effect on the JSON-RPC protocol (stderr is out-of-band); surfaces in Cursor, Aider, mcp-inspector, and any client that shows server logs.
- Activity-bar icon now appears on any workspace, not only Django ones. Previously the extension activated exclusively on
workspaceContains:**/manage.pyorworkspaceContains:**/models.py, so a user who installed the extension without a Django project open saw no icon and no way to discover the tool ("I installed it and see nothing" — real user report). AddedonStartupFinishedtoactivationEventsso the icon always renders; added aviewsWelcomeempty-state message explaining the tool looks formanage.py/models.py, with quick actions to open a folder, refresh the scan, or read docs on GitHub. Django auto-activation on those files is unchanged.
Combined release — Django 5.2 support, cascade blast-radius preview for AI agents, and a reverse-references sidebar action. Feature-set derived from a competitive analysis of meshy/django-schema-graph (stale since 2023, Django ≤ 4.1) and MCP peers (gts360/django-mcp-server, kitespark/django-mcp) — all of which require a running Django process; django-orm-lens keeps the zero-runtime moat.
- Django 5.2 support — new
Framework :: Django :: 5.2classifier and a CI matrix job (python-cli) that runs pytest against Python 3.10-3.12 × Django 4.2-5.2 in parallel with the existing Node/TS build. cascade_previewMCP tool — new toolcascade_preview(app_label, model_name)returns inbound relations grouped byon_deletebehavior intocascade_kills/set_null/protectedbuckets. Lets AI agents preview a delete's blast radius before acting, using only static parse (no DB, no boot).on_deleteon inbound relations —find_relationsinbound entries now include theon_deletevalue (CASCADE,SET_NULL,PROTECT,SET_DEFAULT,DO_NOTHING,RESTRICT, orSETfor callable form) extracted via the existing parser helper.- VS Code: "Find Reverse References" context action — right-click any model in the sidebar tree →
Find Reverse References→ QuickPick of every FK/OneToOne/M2M pointing at this model, using the in-memory workspace index (no re-parse). [full]optional-dependencies alias —pip install "django-orm-lens[full]"is now equivalent to[mcp]; documents the default install as zero-dependency in a pyproject header comment.
- README hero — added
Works offline. Works on a broken venv. Works on someone else's laptop. Works in CI.positioning line under the problem section. - README comparison table — added Django version support row (ours 4.0-5.2 · schema-graph 3.2-4.1 stale since 2023 · django-extensions latest) with an explicit stale-since-2023 note for
django-schema-graph.
Combined release — the parser hardening ships identically in both the VS Code extension and the Python CLI. Product of a 3-round security + stability + type-design + Django-semantics audit.
on_delete=models.SET(default_value)callable form now recognised — previous regex[A-Z][A-Z_]+matched only bare identifiers (CASCADE,SET_NULL,PROTECT, etc.) and silently dropped the callable form used to inject a default value on delete. Parser now falls back to matchingon_delete=SET(and records"SET", so consumers know the field has a dynamic on_delete rather than treating it as absent.
- ReDoS clamp on class-indent detection —
_detect_class_indentnow clamps the reported indent width to 32 and expands tabs to width 4 before use. A craftedmodels.pywith an absurd number of leading spaces (10k+) or a tab that produced a width-mismatched regex could previously build patterns like\s{20000,}for the meta-body match and trigger catastrophic backtracking. Fixes both the ReDoS vector and the tab-indent correctness bug (Meta blocks in tab-indented codebases were silently unparsed).
- Workspace scan no longer aborts on a single broken
models.py—scan_workspace(Python CLI) andscanWorkspace(VS Code extension) previously wrapped both the read AND the parse in one broad try/catch. A parser exception in a single file (e.g. missing(after a matched field, malformed multi-line class header) would either abort the whole workspace scan with exit 1 (Python) or silently drop that file's models from the tree (TypeScript). Now the read and parse are caught separately: parse errors log a per-file warning to stderr (Python) or dev-tools console (TypeScript) and scanning continues on the next file. - Multi-line class header parser no longer near-loops on malformed signatures —
_read_multiline_classused to returnNonewhen parens closed but the joined buffer didn't matchCLASS_RE. The caller would then advance by only one line, causing every continuation line of a malformed wrap to be re-evaluated as a potential class header. Now returns(None, end_index)so the caller skips past the whole section. _read_balanced_argsguard for missing(— if a matched field somehow doesn't have(on its starting line,.index("(")used to raiseValueErrorthat propagated out ofparse_models_fileand aborted the entire scan. Now returns an empty args block and the field is captured with no relation metadata.- Message handler disposable leak in the ER-diagram webview —
panel.webview.onDidReceiveMessage(...)registered its subscription incontext.subscriptions, but the handler is scoped to the panel's lifetime, not the extension's. Every close/reopen of the diagram panel appended a dead listener tocontext.subscriptionsforever. Handler now scoped to the panel'sonDidDisposecleanup. - Watcher listener leak on
autoRefreshconfig change + split-module files not watched —setupWatcherdisposed the old watcher on config toggle but left the threeonDidChange/Create/Deletelisteners registered incontext.subscriptionspermanently, firing against a disposed watcher. Now tracks a module-levelwatcherDisposablesarray that fully disposes before re-registering. Same pass also adds a second watcher for**/models/*.py— split-module Django apps' sub-files now trigger auto-refresh on save (previously ignored, tree silently went stale on those files). - Model-name collision in filtered tree — two models sharing a name in different apps (a valid Django pattern) collided in the filtered-tree child lookup because identity was matched by
label + kindonly. Now also comparesfilePath, so children resolve to the correct model.
- Mermaid bundled locally instead of fetched from a CDN — the ER-diagram webview now loads
mermaid.min.jsfrom a vendored copy atmedia/vendor/. Thescript-srcCSP no longer allowshttps://cdn.jsdelivr.net, andlocalResourceRootsrestricts the webview to files undermedia/. Removes a third-party network dependency, works offline, and eliminates supply-chain risk from the CDN.
jumpToModelworkspace check hardened — the previous manual.toLowerCase()prefix comparison was Windows-oriented and could false-positive on case-sensitive filesystems (Linux). Switched tovscode.workspace.getWorkspaceFolder(uri), which VS Code resolves with OS-appropriate case handling. Simpler code, correct on every platform.
Python package only. VS Code extension unchanged at 0.3.3.
- Subtle star ask in the welcome output —
django-orm-lens(no args) now closes with a two-line invitation to star the repo if the tool saved a search. Rationale: 134 unique cloners on the 14-day traffic window converted to only 2 stars — infrastructure tools bleed stars silently because users never revisit the repo afterpip install. A single sentence at the point of first-run gratitude is the smallest touch that closes the loop without becoming spam. No CLI behaviour change.
Python package only. VS Code extension unchanged at 0.3.3.
- Friendly welcome when
django-orm-lensruns without a subcommand — previously bare invocation printed a crypticargparse: the following arguments are required: commanderror, killing the pip-install-and-poke-around funnel. Now shows a compact commands table + docs link so a new user immediately sees what to try next.
Python package only. VS Code extension unchanged at 0.3.1.
- Mermaid ER edge labels — Python ↔ TypeScript parity —
django-orm-lens erand the MCPer_diagramtool now emit the sameon_delete,through, andrelated_namemetadata as the VS Code diagram. Example:Book }o--|| Author : "author [CASCADE, as books]"andBook }o--o{ Tag : "tags [through BookTag]". Previously the Python side stripped all metadata to just the field name. - MCP index cache (30s TTL) — agents chaining multiple tool calls (
list_apps→describe_model→find_relations) no longer re-walk the filesystem and re-parse everymodels.pyper call. Cache keyed by workspace root; short TTL keeps manual edits visible.
Python package only. VS Code extension unchanged at 0.3.1.
- MCP tool error semantics —
describe_modelandfind_relationsnow raiseValueErroron missing-model instead of returning a"error: ..."string. FastMCP maps this to a protocol-levelisError: trueresponse, so MCP-compatible agents recognize it as a tool error rather than a successful call with error text.
- Parser perf —
_read_balanced_argswas building the args buffer with per-charstr += chinside a nested loop (quadratic on multi-line field bodies). Now uses a list + single"".join.
Hotfix release. Python package only. VS Code extension unchanged at 0.3.1.
- Crash on
ManyToManyField(through=...)—_extract_through_modelwas called fromparse_models_filebut never defined, andthrough_modelwas assigned onParsedFieldwithout a matching dataclass field. Any Django project with an M2Mthrough=argument would raiseNameError/AttributeErrorand return an empty index. Both are now declared. Discovered by QA sweep of 1.0.4 with type-design and Python reviewers.
throughModelon the M2M edge — Mermaid ER diagrams now renderthrough=onManyToManyFieldrelations, e.g.authors [through Authorship]. First-time external contribution by @kingrubic in #5.- Listed on Glama.ai MCP directory — third discovery channel alongside VS Code Marketplace and the official MCP Registry.
Python package parity release. Extension bumped in parallel to 0.3.1.
through_modelonParsedField— Python parser now extractsthrough=fromManyToManyField(...)and emits"throughModel": "..."in JSON. Matches the TypeScript port field-for-field.
Python package only. VS Code extension unchanged at 0.3.0.
- Listed in the official MCP Registry — the server is now discoverable through the canonical Model Context Protocol directory. MCP-compatible clients can find it by name (
io.github.FROWNINGdev/django-orm-lens). cli/server.json— MCP Registry metadata (PyPI package, stdio transport, uvx runtime hint).- Ownership-verification marker in
cli/README.md(hidden HTML comment) so the registry can prove the PyPI package is ours.
- 1.0.1: added
on_deleteandrelated_nameextraction — kept for parity with the VS Code extension.
Ships the terminal + AI-agent story and a batch of ER-diagram / editor polish.
- Python CLI + MCP server — companion package
django-orm-lenson PyPI. Zero-dep CLI (scan,describe,hover,list,er) and an optional MCP stdio server exposing five read-only tools to Cursor, Aider, Continue.dev, Zed, and any MCP client. Install:pip install "django-orm-lens[mcp]". - CodeLens above every model class — shows field count, relation count, and an "Open ER diagram" action. Toggle with
djangoOrmLens.showCodeLens. - Edge labels on the ER diagram — relation arrows now include
on_delete(CASCADE / SET_NULL / PROTECT) andrelated_namewhen present, e.g.author [CASCADE, as posts]. - Diagram theme picker —
djangoOrmLens.diagramThemeacceptsauto(default, follows VS Code theme),default,dark,forest, andneutral.
- CI publish workflow was silently failing because of a YAML quoting bug — restored to green; adds a parallel PyPI publish job.
- README rewritten around three surfaces: VS Code extension, Python CLI, and MCP server. New Integrations table, updated roadmap, and Support section.
The polish release. Consolidates hover, filter, welcome, security hardening, and diagram export into a single minor bump.
- Export ER diagram as SVG — new button in the diagram panel header saves the rendered graph to a file inside your workspace.
- Welcome view — when no Django models are found, the sidebar now shows a friendly explanation and a Refresh action instead of a blank panel.
- Smart tree expansion — apps start expanded on small projects (<= 40 models) and collapsed on larger ones; a filter always expands to reveal matches.
- Multi-line class inheritance — the parser now handles Black-formatted classes where the base list wraps across two or three lines.
- jumpToModel path scoping — the jump command now rejects any target outside the current workspaceFolders. Prevents a crafted models.py from opening arbitrary local files.
- Hover markdown sanitization — parser-derived strings are escaped and the trusted-command scope is narrowed to djangoOrmLens.jumpToModel only. Blocks command-URI injection through model or field names.
- Filter tree — new sidebar buttons and command palette actions (
Django ORM Lens: Filter Models,Clear Filter) let you type a substring and narrow the tree to matching apps, models, and fields in real time. Parent nodes stay visible when a descendant matches.
- Hover cards over
ForeignKey('app.Model'),OneToOneField(...), andManyToManyField(...)references. Hovering a related-model string in the editor now shows a preview of that model (fields, relations, base classes) and a one-click jump link.
- Support for split
models/package directories (multi-file apps). - Support for bare field imports (
from django.db.models import CharField). - Output channel "Django ORM Lens" for surfaced scan errors.
- Parser now detects indentation width per class instead of assuming 4 spaces (2-space codebases were showing zero fields).
- False-positive base-class detection:
ModelAdmin,ModelSerializer,ModelForm,ResponseModel, and similar classes are no longer treated as database models. - Race condition in the workspace scanner: concurrent saves could leave stale results in the tree.
Jump to Modelcrashed when the target file had been deleted between scan and click — now shows a warning and refreshes.
- Webview nonce is now generated via
crypto.randomBytesinstead ofMath.random(). - Mermaid CDN reference pinned to
10.9.4(was floating onmermaid@10).
- Initial release.
- Sidebar TreeView grouping apps → models → fields → Meta.
- Field-type-aware icons for CharField, ForeignKey, ManyToManyField, and 20+ built-ins.
- Mermaid-rendered ER diagram in a side webview panel.
- Jump-to-definition on any tree node.
- Auto-refresh via
models.pyfile watcher. - Configurable exclude globs (defaults skip
migrations/,venv/,node_modules/). - Status-bar item showing scanned model count.