-
Notifications
You must be signed in to change notification settings - Fork 0
Loadpath: architecture-typed impact graphs for Django + React PRs #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bc68c7c
32850a0
f8d2430
cf75052
7b2548e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: "22" | ||
| cache: npm | ||
| cache-dependency-path: ui/package-lock.json | ||
| - name: Python tests | ||
| run: | | ||
| python -m pip install -e ".[dev]" | ||
| python -m playwright install chromium | ||
| pytest | ||
| - name: UI tests | ||
| working-directory: ui | ||
| run: | | ||
| npm install | ||
| npm test | ||
| npm run build |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| .venv/ | ||
| venv/ | ||
| __pycache__/ | ||
| *.py[cod] | ||
| *.egg-info/ | ||
| .pytest_cache/ | ||
| .mypy_cache/ | ||
| .ruff_cache/ | ||
| .coverage | ||
| htmlcov/ | ||
| dist/ | ||
| build/ | ||
| node_modules/ | ||
| ui/dist/ | ||
| *.db | ||
| *.sqlite3 | ||
| .loadpath/ | ||
| .env | ||
| .DS_Store | ||
| .idea/ | ||
| .vscode/ | ||
| *.log | ||
| .playwright-mcp/ | ||
| ui/playwright-report/ | ||
| ui/test-results/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,189 @@ | ||
| # PR-Reviewer | ||
| # Loadpath | ||
|
|
||
| Review as **load-path inspection** on a Django + React architecture graph. Not another hunk-comment bot. | ||
|
|
||
| A change is a force. Loadpath traces where that force travels until it hits a sink — HTTP response, UI, Celery/Dramatiq job, migration, permission — then scores whether you have enough evidence to merge. | ||
|
|
||
| ``` | ||
| Loadpath: MEDIUM — Invoice.total field change | ||
| Sinks: GET/POST /api/invoices/{id}; Celery send_invoice_email, apply_credit; Dramatiq rebuild_ledger; React InvoicePage + InvoiceForm | ||
| Tests: pytest hits serializer and view; no RTL test on InvoiceForm | ||
| Architecture: stays inside billing | ||
| Residual: total also formatted in a Signal update_ledger — no test | ||
| Suggested reviewers: billing-team | ||
| ``` | ||
|
|
||
| On the demo monorepo that path is: | ||
|
|
||
| `Invoice.total → InvoiceSerializer → InvoiceViewSet → /api/invoices/{id} → OpenAPI → fetch → useInvoice → InvoicePage → InvoiceForm / Zod` | ||
|
|
||
| plus the jobs the view enqueues (`send_invoice_email.delay`, `rebuild_ledger.send`). | ||
|
|
||
| ## App | ||
|
|
||
| `loadpath serve --port 7345` opens a local desktop-style UI. Tokens stay on the machine in `~/.loadpath/settings.json`. AI is used **only** for residual uncertainty the graph cannot close. | ||
|
|
||
| ### Review | ||
|
|
||
| Confidence brief, read-order, clusters, architecture findings on the impact path, residual list, and the subgraph for the git range. Review walks the **indexed** graph (incremental refresh by default). | ||
|
|
||
|  | ||
|
|
||
| ### Architecture | ||
|
|
||
| Index a repo first. The architecture tab is the full typed graph plus `loadpath.yml` contexts and rules — not a PR diff. Findings here are repo-wide; review then scopes them to the change. | ||
|
|
||
|  | ||
|
|
||
| ### Impact graph | ||
|
|
||
| Toggle **This review** (impact subgraph) vs **Indexed architecture** (the repo map). Dashed edges are inferred (URL/Zod overlap); solid edges are extracted or generated-client stitches. | ||
|
|
||
|  | ||
|
|
||
| ### Pull requests | ||
|
|
||
| GitHub and Bitbucket via API tokens from Settings. Pick a PR and jump to a branch-range review. | ||
|
|
||
|  | ||
|
|
||
| ### Settings | ||
|
|
||
| GitHub / Bitbucket tokens; AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama). Residual analysis only — Loadpath does not comment every hunk. | ||
|
|
||
|  | ||
|
|
||
| ## Install | ||
|
|
||
| Python 3.12+ and Node 22+ (UI). | ||
|
|
||
| ```bash | ||
| pip install -e ".[dev]" | ||
| python -m playwright install chromium # optional, for UI screenshot tests | ||
| cd ui && npm install && npm run build && cd .. | ||
| loadpath --help | ||
| ``` | ||
|
|
||
| ## CLI | ||
|
|
||
| ```bash | ||
| # Index a monorepo (SQLite graph at .loadpath/graph.sqlite3, incremental on file hashes) | ||
| loadpath index /path/to/repo | ||
|
|
||
| # Inspect bounded contexts, rules, and type counts from that index | ||
| loadpath architecture /path/to/repo | ||
|
|
||
| # Review a git range against the index (incremental refresh; --no-reindex to reuse as-is) | ||
| loadpath review /path/to/repo --base origin/main --head HEAD | ||
| loadpath review /path/to/repo --base origin/main --no-reindex | ||
|
|
||
| # Cross-platform app (API + visual graph + PR list) | ||
| loadpath serve --port 7345 | ||
| ``` | ||
|
|
||
| **Flow:** `index` builds the architecture graph → `architecture` shows contexts and rule hits on the whole repo → `review` walks that same graph for a git range. The app mirrors this: Index registers a workspace, Architecture inspects it, Review traces a change through it. | ||
|
|
||
| Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example) and [`fixtures/demo_monorepo/loadpath.yml`](fixtures/demo_monorepo/loadpath.yml)). The tool is opinionated about *your* architecture, not a generic module graph. | ||
|
|
||
| ## Django support | ||
|
|
||
| AST is the default extractor. It is a **framework overlay**, not an import graph. | ||
|
|
||
| | Surface | What Loadpath extracts | | ||
| | --- | --- | | ||
| | Models | Fields, FK / M2M / O2O, `on_delete`, string refs (`ForeignKey("accounts.User")`) as residuals | | ||
| | Serializers | `Meta.fields` / `exclude`, declared fields, `serializes` edges, queryset-in-serializer flag | | ||
| | Views | DRF ViewSets / APIViews, `serializer_class`, `get_serializer_class` (residual), `permission_classes`, `get_queryset`, `filterset_class`, `authentication_classes`, `pagination_class` | | ||
| | Function views | `@api_view`, `@login_required`, `@csrf_exempt`, … | | ||
| | Django Ninja | `@router.get/post/…` routes and views | | ||
| | URLs | `path` / `re_path`, DRF `router.register`, `include()` mount composition (`/api` + `invoices/<id>/` → `/api/invoices/{id}`) | | ||
| | Signals | `@receiver`, `signal.connect()` residual, `AppConfig.ready()` residual | | ||
| | Management commands | `BaseCommand` + `handle()`, including `.delay(` / `.send(` enqueue edges | | ||
| | Migrations | `CreateModel` / `AddField` / `RemoveField` / `DeleteModel` / `RunPython` as `destructive_migration` | | ||
| | Tests | `test_*` in `tests.py` / `tests/` as `tested_by` | | ||
|
|
||
| ### Celery | ||
|
|
||
| - `@shared_task`, `@app.task`, `@periodic_task` | ||
| - `celery.Task` subclasses (`run(self, invoice_id)`) | ||
| - Enqueue: `.delay(`, `.apply_async(` | ||
| - Signatures / canvas: `.s(`, `.si(`, `chain` / `group` / `chord` (canvas is a residual; inner signatures are `enqueues` edges) | ||
| - `current_app.send_task("billing.tasks.send_invoice_email")` — residual + inferred enqueue | ||
| - `transaction.on_commit(lambda: task.apply_async(...))` — residual, nested enqueue walked | ||
| - `CELERY_BEAT_SCHEDULE` / `beat_schedule` in settings | ||
|
|
||
| ### Dramatiq | ||
|
|
||
| - `@dramatiq.actor` | ||
| - `dramatiq.GenericActor` subclasses (`perform(self, invoice_id)`) | ||
| - Enqueue: `.send(`, `.send_with_options(` (heuristic: dramatiq import or `actors` / `tasks` module) | ||
|
|
||
| Call-site placeholders (`rebuild_ledger.send` in a view) do **not** overwrite the actor definition’s file. The graph keeps `actors.py` / `tasks.py` as the node home. | ||
|
|
||
| ### Idempotency rule | ||
|
|
||
| `celery_tasks_must_be_idempotent_on_model_pk` (alias `async_tasks_must_be_idempotent_on_model_pk`) warns when a Celery **or** Dramatiq task takes a full object payload instead of `pk` / `*_id`. Message names the broker. | ||
|
|
||
| ### Optional `django.setup()` overlay | ||
|
|
||
| AST is enough for review. If you need live `_meta` (db_table, resolved relations), set `boot_django: true` in `loadpath.yml`. Loadpath then imports Django, calls `django.setup()`, and merges model/field nodes. Failures become residuals; the AST graph still stands. Leave it `false` in CI unless the fixture is a bootable project. | ||
|
|
||
| ## React + stitch | ||
|
|
||
| **React:** react-router tables, composition, TanStack Query `queryKey` + fetch/axios URL templates, Zod schemas, feature-folder imports, RTL `render(<Page/>)` as `tested_by`. | ||
|
|
||
| **Stitch (the moat):** OpenAPI from Spectacular/schema files first; generated clients (`generated/`, orval, openapi-typescript) as high-confidence `consumed_by_client`; fallback URL-template matching and serializer/Zod field overlap marked **inferred**. | ||
|
|
||
| ## Architecture rules (`loadpath.yml`) | ||
|
|
||
| | Rule | Meaning | | ||
| | --- | --- | | ||
| | `views_cannot_import_other_context_models` | A billing view must not import `accounts.UserProfile` | | ||
| | `react_feature_may_only_call_own_or_shared_api` | Billing UI may not `fetch('/api/me')` | | ||
| | `serializers_are_the_only_published_contract` | Zod/form fields must not drift from the serializer | | ||
| | `no_queryset_in_serializer` | Serializers must not run querysets | | ||
| | `celery_tasks_must_be_idempotent_on_model_pk` | Celery and Dramatiq tasks take a model pk | | ||
|
|
||
| Waivers live under `waivers:` in the same file. Reviewers are the `owners` of the bounded contexts in the impact subgraph. | ||
|
|
||
| Impact walk skips permission/app/context hubs, does not climb `renders` into the App shell, and does not follow cross-context `relates_to` (an Invoice FK to UserProfile does not pull identity into a billing review). | ||
|
|
||
| ## How confidence is scored | ||
|
|
||
| Line coverage on changed files is the wrong metric. Loadpath scores the **impact subgraph**: | ||
|
|
||
| | Signal | High | Low | | ||
| | --- | --- | --- | | ||
| | Tests | Sinks in the radius are hit by tests that still reach the changed symbol | Serializer changed, tests only on the view happy path | | ||
| | Contract | OpenAPI/client types track the serializer | React path/Zod field still old | | ||
| | Architecture | No new cross-context edges | `crosses_context` with no waiver | | ||
| | Graph | Resolved edges | Many inferred/dynamic edges | | ||
|
|
||
| `high` / `medium` / `low` plus three reasons. Isolated leaf UI with green tests and no rule hits is labeled `loadpath:low-risk`. | ||
|
|
||
| ## Tests | ||
|
|
||
| ```bash | ||
| pytest | ||
| cd ui && npm test | ||
| ``` | ||
|
|
||
| | Suite | What it covers | | ||
| | --- | --- | | ||
| | `tests/unit/` | Django/React extractors, architecture rules, stitch, SCM/AI providers | | ||
| | `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage; reviewers `billing-team` | | ||
| | `tests/e2e/test_cli_review.py` | `loadpath index` / `architecture` / `review` markdown, JSON, HTML | | ||
| | `tests/e2e/test_api_flow.py` | health, index, architecture, review-from-index, graph, settings, GitHub + Bitbucket PR list | | ||
| | `tests/e2e/test_index_architecture_flow.py` | index snapshot, review without index, review walking an existing graph | | ||
| | `tests/e2e/test_brokers_and_django.py` | Celery + Dramatiq sinks, actor-only PR, non-idempotent Dramatiq warning, destructive migration, cross-context blocker, boot overlay, management commands, beat/canvas | | ||
| | `tests/e2e/test_ui_screenshots.py` | Playwright: Architecture, Review, Impact graph, Pull requests, Settings → `docs/screenshots/` | | ||
|
|
||
| CI installs Chromium and runs the full suite. | ||
|
|
||
| ## Demo fixture | ||
|
|
||
| [`fixtures/demo_monorepo`](fixtures/demo_monorepo) is a billing/identity split: DRF ViewSet, FBV, Ninja ledger route, Celery tasks + beat + canvas, Dramatiq actor + GenericActor, management command, signal, FK string ref, React InvoicePage/Zod. | ||
|
|
||
| ## What this is not | ||
|
|
||
| Not CodeRabbit (comments without a closed impact set). Not CodeScene (historical coupling). Not django-orm-lens (models only). Not a generic SCIP call graph. The product is review as load-path inspection. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from django.apps import AppConfig | ||
|
|
||
|
|
||
| class AccountsConfig(AppConfig): | ||
| name = "accounts" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| from django.db import models | ||
|
|
||
|
|
||
| class UserProfile(models.Model): | ||
| email = models.EmailField() | ||
| display_name = models.CharField(max_length=120) | ||
|
|
||
| class Meta: | ||
| app_label = "accounts" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| from rest_framework import serializers | ||
|
|
||
| from accounts.models import UserProfile | ||
|
|
||
|
|
||
| class MeSerializer(serializers.ModelSerializer): | ||
| class Meta: | ||
| model = UserProfile | ||
| fields = ["id", "email", "display_name"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| from django.urls import path | ||
|
|
||
| from accounts.views import MeView | ||
|
|
||
| urlpatterns = [ | ||
| path("me/", MeView.as_view(), name="me"), | ||
| path("login/", MeView.as_view(), name="login"), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| from rest_framework.permissions import IsAuthenticated | ||
| from rest_framework.response import Response | ||
| from rest_framework.views import APIView | ||
|
|
||
| from accounts.serializers import MeSerializer | ||
|
|
||
|
|
||
| class MeView(APIView): | ||
| permission_classes = [IsAuthenticated] | ||
| serializer_class = MeSerializer | ||
|
|
||
| def get(self, request): | ||
| return Response({"email": request.user.email}) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import dramatiq | ||
|
|
||
| from billing.models import Invoice | ||
|
|
||
|
|
||
| @dramatiq.actor | ||
| def rebuild_ledger(invoice_id): | ||
| invoice = Invoice.objects.get(pk=invoice_id) | ||
| return str(invoice.total) | ||
|
|
||
|
|
||
| class NotifyCustomer(dramatiq.GenericActor): | ||
| def perform(self, invoice_id): | ||
| invoice = Invoice.objects.get(pk=invoice_id) | ||
| return invoice.status |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| from django.contrib import admin | ||
|
|
||
| from billing.models import Invoice | ||
|
|
||
|
|
||
| @admin.register(Invoice) | ||
| class InvoiceAdmin(admin.ModelAdmin): | ||
| list_display = ["id", "total", "status"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| from ninja import Router | ||
|
|
||
| from billing.models import Invoice | ||
|
|
||
| router = Router() | ||
|
|
||
|
|
||
| @router.get("/invoices/{invoice_id}/ledger") | ||
| def invoice_ledger(request, invoice_id: int): | ||
| invoice = Invoice.objects.get(pk=invoice_id) | ||
| return {"total": str(invoice.total)} | ||
|
Comment on lines
+10
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files 'fixtures/demo_monorepo/backend/*' 'fixtures/demo_monorepo/**/test*' | sed -n '1,160p'
printf '%s\n' '--- target outline ---'
ast-grep outline fixtures/demo_monorepo/backend/billing/api.py --view expanded || true
printf '%s\n' '--- target file ---'
cat -n fixtures/demo_monorepo/backend/billing/api.py
printf '%s\n' '--- relevant symbols and exception configuration ---'
rg -n -S 'invoice_ledger|Invoice\.objects\.get|DoesNotExist|get_object_or_404|exception_handler|Exception|NinjaAPI|Router|billing' fixtures/demo_monorepoRepository: Modsofthenation/PR-Reviewer Length of output: 5894 🏁 Script executed: #!/bin/bash
set -eu
for f in \
fixtures/demo_monorepo/backend/config/urls.py \
fixtures/demo_monorepo/backend/config/settings.py \
fixtures/demo_monorepo/backend/billing/urls.py \
fixtures/demo_monorepo/backend/billing/tests.py \
fixtures/demo_monorepo/backend/billing/models.py
do
printf '\n--- %s ---\n' "$f"
wc -l "$f"
cat -n "$f"
done
printf '\n--- dependency and API registration search ---\n'
git ls-files | rg '(^|/)(requirements[^/]*|pyproject\.toml|Pipfile|poetry\.lock|setup\.cfg|setup\.py|.*environment.*|.*lock$)$' | xargs -r -n1 sh -c 'printf "\n--- %s ---\n" "$0"; rg -n -i "django|ninja|exception|api\.router" "$0" || true'
rg -n -S 'billing\.api|api\.add_router|NinjaAPI|add_router|include\(.*api|urlpatterns' fixtures/demo_monorepo/backendRepository: Modsofthenation/PR-Reviewer Length of output: 3779 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- all router/API references in tracked files ---'
rg -n -S --hidden -g '!.git' 'billing\.api|invoice_ledger|NinjaAPI|ninja\.Router|add_router|include\(.*router|api\.urls' .
printf '%s\n' '--- target module references ---'
git grep -n -E '(^|[^[:alnum:]_])api([^[:alnum:]_]|$)|invoice_ledger' -- fixtures/demo_monorepo/backend || trueRepository: Modsofthenation/PR-Reviewer Length of output: 710 🌐 Web query:
💡 Result: Django Ninja provides built-in exception handling for Http404, but it does not have a default automatic handler for Django's Model.DoesNotExist exception [1][2]. Regarding your specific questions: 1. Django Ninja's Http404 handling: Django Ninja registers a default exception handler for django.http.Http404 [1][2]. When an Http404 exception is raised within a Ninja operation, this handler catches it and returns a JSON response with a 404 status code (e.g., {"detail": "Not Found"}) [1][2]. 2. Does get_object_or_404 return HTTP 404: Yes, effectively. The django.shortcuts.get_object_or_404 function raises the django.http.Http404 exception when a model object is not found [3]. Because Django Ninja includes a default exception handler for Http404 [1], catching this exception inside a Ninja endpoint results in the client receiving an HTTP 404 response automatically [2][4]. 3. Handling Model.DoesNotExist: Django's Model.DoesNotExist is not automatically converted to an HTTP 404 response by Django Ninja [1][2]. If you use Model.objects.get directly and it raises DoesNotExist, it will be treated as an unhandled exception [2]. To handle this gracefully in Django Ninja, you should either catch it explicitly and raise Http404, or register a custom exception handler for DoesNotExist using the Citations:
Mount the Ninja router and handle missing invoices.
🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| from django.apps import AppConfig | ||
|
|
||
|
|
||
| class BillingConfig(AppConfig): | ||
| name = "billing" | ||
|
|
||
| def ready(self): | ||
| from billing import signals # noqa: F401 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| from django.core.management.base import BaseCommand | ||
|
|
||
| from billing.models import Invoice | ||
| from billing.tasks import send_invoice_email | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
| help = "Re-send invoice emails" | ||
|
|
||
| def handle(self, *args, **options): | ||
| for invoice in Invoice.objects.all(): | ||
| send_invoice_email.delay(invoice.id) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
| initial = True | ||
| operations = [ | ||
| migrations.CreateModel( | ||
| name="Invoice", | ||
| fields=[ | ||
| ("id", models.AutoField(primary_key=True)), | ||
| ("total", models.DecimalField(max_digits=10, decimal_places=2)), | ||
| ], | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| from django.db import models | ||
|
|
||
|
|
||
| class Invoice(models.Model): | ||
| customer_id = models.IntegerField() | ||
| total = models.DecimalField(max_digits=10, decimal_places=2) | ||
| status = models.CharField(max_length=32, default="draft") | ||
| created_at = models.DateTimeField(auto_now_add=True) | ||
| account = models.ForeignKey("accounts.UserProfile", null=True, on_delete=models.SET_NULL) | ||
|
|
||
| class Meta: | ||
| app_label = "billing" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| from rest_framework import serializers | ||
|
|
||
| from billing.models import Invoice | ||
|
|
||
|
|
||
| class InvoiceSerializer(serializers.ModelSerializer): | ||
| class Meta: | ||
| model = Invoice | ||
| fields = ["id", "customer_id", "total", "status"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Modsofthenation/PR-Reviewer
Length of output: 165
🏁 Script executed:
Repository: Modsofthenation/PR-Reviewer
Length of output: 231
🏁 Script executed:
Repository: Modsofthenation/PR-Reviewer
Length of output: 3195
Add a language to the fenced block
Line 7 starts a fenced block without a language. Mark the block as
textto satisfy MD040.🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 7-7: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
Source: Linters/SAST tools