← All Case Studies

Tandoor Recipes

tandoorrecipes/recipes · Self-hosted recipe manager · Django + Vue 3

Python TypeScript Vue Django Docker
31
Grade F · 1,652 files
15
Hardcoded secrets
21
Infrastructure issues
110
Unchecked exceptions
9%
Test-to-source ratio

Tandoor is a mature, well-loved self-hosted application — 8,756 commits over eight years, 532 contributors, a real Django + Vue 3 stack with a real user base. The grade is F because the same risks recur in layers: secrets are committed in the Kubernetes install docs, the Docker and K8s manifests run as root without resource limits, the application code swallows 110 exceptions across the Python backend, and the complexity check — now run in-process across every language — scores 0. Test coverage is 9% of source files. The structural debt is concentrated in three files everyone will recognise: cookbook/models.py, cookbook/views/api.py, and the Vue OpenAPI client.

What changed since our last scan

Our first analysis scored Tandoor 44. The latest inkode scores it 31. The codebase barely moved — the drop is almost entirely the scanner getting more thorough:

  • Complexity went from 50 to 0. Last time the complexity category was a near-pass, measured for Python only. inkode now analyses cyclomatic complexity, nesting depth, function length and parameter count in-process for Python, TypeScript, Vue and JavaScript. The result: 97 functions over the threshold, 149 deep-nesting sites, 34 over-parameterised functions. Much of this is the vendored PDF.js bundle (one function clocks complexity 2,699), but the application code carries its share too.
  • Magic-number detection got precise. Now driven by pylint and eslint, the count dropped from 495 to 84 — the old number was mostly Python false positives.
  • Git history is read in full. We now see the real shape of the repo: 8,756 commits across eight years and 532 contributors, where the earlier pass undercounted.

The security and structural findings — 15 secrets, 21 infra issues, the 244-degree god package — are essentially unchanged. What's new is that inkode now sees the complexity that was always there.

Category Scores

Security 20
30% weight
Testing 0
20% weight
Maintainability 63
20% weight
Complexity 0
15% weight
Change Risk 85
15% weight

Stack profile

A clean Django backend + Vue 3 frontend split. HTML test fixtures (47.6% of bytes) and the JSON localisation bundles dominate the byte mix; Python and TypeScript carry the application logic.

Repo age 3,048 days, 8,756 commits, 532 contributors
Backend Python 10.7% / Django, pytest detected
Frontend TypeScript 12.2% + Vue 5.2% + CSS 5.1%
AI co-author trailers 1 commit (Claude, ~0.1% of history)

Key Findings

Secret Scanning — in the install docs and test fixtures

Detects hardcoded API keys, tokens, and credentials

15 findings

Fifteen hardcoded secrets — 5 in the Kubernetes install documentation and 10 in the scraped-HTML test fixtures. The single worst offender is docs/install/k8s/15-secrets.yaml: four generic API keys, plus the same file flagged again by the kubernetes-secret-yaml rule. The remaining 10 are API keys embedded in scraped HTML pages from recipe sites — tasteofhome.html (4), foodnetwork.html (2), and one each from delish, thespruceeats, journaldesfemmes, giallozafferano. Those deserve an allowlist, not a rotation; the K8s ones deserve a rotation and a placeholder commit.

Why this is the highest-severity finding. A committed secret is a one-way door. Once it's in the git history it's archived by GitHub, by scrapers, and by anyone who cloned the repo — deleting it from HEAD doesn't unpublish it. The K8s manifest is flagged twice because the scanner recognises both the literal key pattern and the Kubernetes Secret kind that wraps it. The HTML-fixture secrets are a different story: they're keys that already leaked from public recipe websites years ago, captured here as test data. Both still trigger the scanner; only the K8s ones are an actionable security risk. (inkode redacts the matched value before anything leaves the machine, so the secret itself is never uploaded.)

Highest-risk file docs/install/k8s/15-secrets.yaml (5 findings)
Rules generic-api-key, kubernetes-secret-yaml
Recommendation Rotate K8s keys, allowlist the HTML fixtures

Error Handling — 110 bare excepts on the Python side

Detects bare except:, empty catches, and other error-swallowing patterns

110 findings

110 bare except: clauses in the Django backend. Bare excepts swallow KeyboardInterrupt and SystemExit alongside real bugs; in a long-running web service, they're the most common path from this-should-have-thrown to silent data corruption. Pin each to a specific exception type or, at minimum, except Exception.

What this looks like in production. A user imports a recipe from a third-party site. The HTML parser hits an edge case — a Unicode quirk, a malformed timestamp, anything. The bare except: catches it, the import succeeds with empty fields, and the user gets an almost-right recipe with no error message. Multiply by a thousand users and the bug-report channel fills with “sometimes my import is missing fields.” Nobody can reproduce it because the original exception was thrown away three layers down the stack. 110 of these is a lot of places this can happen.

Python 110 bare except clauses
JavaScript 0 — clean this pass

Complexity — 97 over-threshold functions

Cyclomatic complexity, nesting depth, function length, parameter count

97 findings

97 functions exceed the cyclomatic-complexity threshold of 10 (71 Python, 24 JavaScript, 2 TypeScript), plus 149 deep-nesting sites and 34 over-parameterised functions. The extreme outliers are vendored: the bundled PDF.js worker contains a function at complexity 2,699. The application code that matters is cookbook/integration/mealie1.py's get_recipe_from_file at complexity 84 — an import path, which is exactly where untested complexity is most dangerous.

Why this category scores 0. Complexity is now measured in-process across every language, not Python-only. The vendored PDF.js bundle inflates the raw count, but stripping it out still leaves dozens of genuinely branchy functions in the recipe-import integrations — the same code that has no tests (below) and swallows exceptions (above). Add cookbook/static/pdfjs/** to .ik.yaml to see the real application-code complexity clearly.

Over CC threshold 97 (71 Python)
Deep nesting 149 sites
Worst app function get_recipe_from_file — CC 84

Infrastructure — root user, no probes, unpinned images

Audits Dockerfiles, Kubernetes manifests, and Terraform

21 findings

One Dockerfile and nine Kubernetes manifests scanned, 21 issues. The Dockerfile runs as root and ships without a HEALTHCHECK. K8s manifests in the install docs are missing resource limits, liveness/readiness probes, and runAsNonRoot; one references nginx:latest instead of a pinned digest. Both the Postgres StatefulSet and the application Deployment use single replicas — fine for self-host docs, but worth flagging.

Why the install docs are the right place to fix this. Self-host install docs are the operational template for thousands of deployments. The user copy-pastes the YAML once and never thinks about it again. Every weak default in those files becomes a weak default on every Tandoor install in the wild. runAsNonRoot: false means a container breakout reaches the node as root. nginx:latest means the same install behaves differently depending on which day you ran it. Missing probes mean a wedged container keeps receiving traffic. These are template hygiene problems with large reach.

Errors 6 — root user, missing resource limits
Warnings 13 — no probes, unpinned images, no securityContext
Info 2 — single-replica deployments

Import Graph — the three load-bearing modules

Analyses package dependencies for coupling, cycles, and god packages

23 findings

Three modules concentrate most of the coupling. vue3/src/openapi/models/index.ts is a barrel re-exporting 241 OpenAPI model files (combined degree 244). vue3/src/openapi/runtime.ts is imported by 240 modules — the universal client base class. On the backend, cookbook/models.py is imported by 127 other packages, with one self-import giving it degree 128. Touch any of these and the change ripples. No circular dependencies.

The one that costs you sleep is models.py. The Vue barrel is generated; treat it like the schema file it is. The runtime base class is fine as a single source of truth. cookbook/models.py, however, is the Django ORM definitions for the whole app — 127 importers means every domain (recipes, meal plans, shopping lists, users) reaches into one file. Any change to a model field forces 127 imports to recompile and risks breaking unrelated areas. The classic Django split here is per-domain models/ packages with a shared base; the refactor itself is mechanical, but the planning matters because the imports are everywhere.

Packages 714
Edges 1,144
Cycles 0
Top god package vue3/src/openapi/models/index.ts — degree 244

Test Presence — 9% ratio, 24 untested directories

Measures test-to-source file ratio per directory

24 findings

60 test files for 645 source files — a 9% ratio. 24 directories with source code have no tests next to them. The entire Vue 3 frontend (stores, composables, utils, 240+ OpenAPI models) is untested. On the backend, cookbook/helper/, cookbook/integration/, and cookbook/views/ are largely untested. pytest is configured; the gap is content, not setup.

Why 9% is the score that should worry the maintainers most. Tandoor has the structural debt (god packages, hotspots, oversized files, complexity) and doesn't have the safety net to refactor it out. Every cleanup change becomes a coin flip: does anything still work afterwards? The 24 untested directories include the recipe-import integrations — the feature most likely to break silently because every third-party site changes its HTML over time, and the same code that tops the complexity list. Starting tests in cookbook/integration/ would pay back the fastest.

Test files 60
Source files 645
Untested directories 24
Framework pytest

Change Risk — hotspots and coupling concentrate in api.py

Files ranked by change frequency, plus pairs that co-change in git history

677 findings

Across 1,433 commits in the last year and 735 changed files, the top hotspots are cookbook/views/api.py, the German Vue locale bundle, and cookbook/serializer.py. The coupling check flags 674 file pairs — mostly the locale bundles moving together. The cross-cutting pair to watch is api.pyserializer.py: the same two files keep changing in lockstep, which is the signature of a hidden contract worth surfacing.

Why api.py + serializer.py is the diagnostic. A view file and a serializer file moving together commit after commit means the request/response shape lives in two places that nobody has unified. When somebody adds a new field they have to edit both, and when they forget one, you ship an endpoint that returns 200 with a missing key. That's the “sometimes the field is null” bug-class. The mechanical fix is to co-locate the serializer with its view (or merge them); the deeper one is to define the contract once and let both sides consume it.

Code health — magic numbers and oversized files

Inline literals and files above the line-count threshold

228 findings

84 magic-number literals (83 JavaScript, 1 Python) — down from 495 now that pylint and eslint drive the detection instead of a regex heuristic. 144 files exceed the 500-line warning threshold, 28 exceed 1,000 lines. The worst offenders are vendored or generated: pdf.worker.mjs (57k lines), ApiApi.ts, openapi.json. Among application code, cookbook/views/api.py and cookbook/serializer.py are the natural split candidates — they're also the two files at the top of the hotspot list.

Reading the pattern across these checks. The same handful of files keep showing up: api.py, serializer.py, models.py. They're large, they're imported everywhere, they change often, and they have hidden contracts with each other. None of the individual signals is severe enough to act on alone. The cross-check coincidence is what makes it actionable: this is where you spend your refactor budget.

Checks that passed

Dead Code Pass

vulture (Python) and knip (JS/TS) found no unused symbols.

Dependency Audit Pass

No known CVEs across 66 Python dependencies and the npm tree.

Duplication Pass

No token-level duplicate blocks; 2 semantic near-duplicate pairs surfaced for review.

Cycles Pass

Zero circular dependencies across 714 packages and 1,144 edges.

TODO Density Pass

134 TODOs across 581 files — well below the density threshold.

Map of the top connectors

These seven files concentrate most of the structural weight by import degree. The generated Vue OpenAPI client tops the list; cookbook/models.py is the load-bearing backend module. cookbook/views/api.py sits just below by degree but tops the hotspot list — the canonical “changes often, imported widely” file.

#FileLanguageFan-inFan-outTotal degree
1vue3/src/openapi/models/index.tsTypeScript3241244
2vue3/src/openapi/runtime.tsTypeScript2400240
3cookbook/models.pyPython1271128
4cookbook/views/import_export.pyPython22931
5cookbook/helper/ingredient_parser.pyPython26228
6cookbook/integration/integration.pyPython25328
7vue3/src/openapi/models/User.tsTypeScript25126

Top Risk Files

#FileFindingsFlagged By
1docs/install/k8s/50-deployment.yaml13infra
2docs/install/k8s/40-sts-postgresql.yaml6infra
3docs/install/k8s/15-secrets.yaml5secrets
4cookbook/views/api.py3hotspot import-graph line-count
5cookbook/models.py2import-graph line-count

Takeaways

Immediate action needed
  • Rotate the four hardcoded keys in docs/install/k8s/15-secrets.yaml and replace with placeholder values + a one-line note.
  • Allowlist cookbook/tests/other/test_data/*.html in the secrets config — those are scraped fixtures, not credentials.
  • Add a non-root USER and HEALTHCHECK to the Dockerfile; set resource limits, probes, and securityContext: runAsNonRoot on every K8s manifest in the install docs.
  • Pin the nginx:latest reference to a specific tag or digest.
  • Triage the 110 bare except: clauses by file — start with cookbook/views/ and cookbook/integration/ where they sit on user-facing code paths.
Strategic improvements
  • Add tests for the 24 untested directories, prioritising cookbook/integration/ and cookbook/helper/ — the same code that tops the complexity list.
  • Split cookbook/views/api.py and cookbook/models.py into domain modules; both sit at the top of the hotspot and import-graph lists.
  • Surface the api.pyserializer.py co-change pattern in code: either co-locate the serialiser with its view or document the contract.
  • Exclude vendored / generated files (pdfjs, openapi) from line-count and complexity checks via .ik.yaml — collapses most of the structural noise and reveals the real application-code complexity.

See how your codebase scores

Run inkode against your repo in under a minute. No account required.

Scan Your Repo