karni-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""Single-file static checks behind ``karni rules --check <file>``.
|
|
2
|
+
|
|
3
|
+
A fast pre-commit view of the enforced rules for ONE file, with no Django and
|
|
4
|
+
no project import: AST for Python, a comment-aware scan for TS/JS. It is a
|
|
5
|
+
subset of the real enforcement — ``karni check`` (architecture tests) and the
|
|
6
|
+
``@karni/ui`` ESLint preset stay authoritative; cross-file rules (duplicates,
|
|
7
|
+
registry-based admin checks) are not attempted here. Path allowances use the
|
|
8
|
+
documented defaults of ``KARNI_ARCHITECTURE`` / the ESLint preset, because
|
|
9
|
+
project settings are not readable without Django.
|
|
10
|
+
|
|
11
|
+
Opt-outs are honoured exactly as the real rules spell them:
|
|
12
|
+
``# karni: allow(<rule-id>): <reason>`` (same line, the line above, or the
|
|
13
|
+
line above a class / its first decorator) and
|
|
14
|
+
``// eslint-disable-next-line karni/<rule> -- <reason>``. A marker without a
|
|
15
|
+
reason is itself a finding (K005).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import fnmatch
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterable
|
|
24
|
+
from dataclasses import asdict, dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .rules import ENFORCEMENT_CODES, rules_by_code
|
|
29
|
+
|
|
30
|
+
#: Defaults mirrored from karni.testing._source.DEFAULTS (KARNI_ARCHITECTURE).
|
|
31
|
+
SQL_ALLOWED: tuple[str, ...] = ("*/sql/*", "*/migrations/*")
|
|
32
|
+
HTTP_ALLOWED: tuple[str, ...] = ("*/services/*", "*/providers/*", "*/tests/*", "conftest.py")
|
|
33
|
+
HTTP_BANNED: frozenset[str] = frozenset({"requests", "httpx", "urllib.request"})
|
|
34
|
+
#: Any of these as a base satisfies model-base (BaseModel or its mixins).
|
|
35
|
+
KARNI_MODEL_BASES: frozenset[str] = frozenset(
|
|
36
|
+
{"BaseModel", "UUIDModel", "TimestampedModel", "SoftDeleteModel", "AuditedModel"}
|
|
37
|
+
)
|
|
38
|
+
_DRF_VIEWSETS = frozenset({"GenericViewSet", "ModelViewSet", "ReadOnlyModelViewSet"})
|
|
39
|
+
|
|
40
|
+
PY_SUFFIXES = frozenset({".py"})
|
|
41
|
+
TS_SUFFIXES = frozenset({".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"})
|
|
42
|
+
JSX_SUFFIXES = frozenset({".tsx", ".jsx"})
|
|
43
|
+
PAGE_MAX_LINES = 50
|
|
44
|
+
|
|
45
|
+
_PY_ALLOW = re.compile(r"#\s*karni:\s*allow\(([^)]*)\)\s*(?::\s*(.*))?$")
|
|
46
|
+
# ESLint only honours a directive that opens the comment (`// eslint-disable...`
|
|
47
|
+
# or `/* eslint-disable... */`); prose that merely mentions it is not one.
|
|
48
|
+
_ESLINT_DIRECTIVE = re.compile(
|
|
49
|
+
r"^(?://|/\*)\s*eslint-disable(?P<kind>-next-line|-line)?\s+(?P<rules>[^*\n]*?)\s*(?:--\s*(?P<reason>.*?))?\s*(?:\*/)?\s*$"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Finding:
|
|
55
|
+
code: str
|
|
56
|
+
rule_id: str
|
|
57
|
+
line: int
|
|
58
|
+
message: str
|
|
59
|
+
fix: str
|
|
60
|
+
|
|
61
|
+
def to_dict(self) -> dict[str, Any]:
|
|
62
|
+
return asdict(self)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _finding(rule_id: str, line: int, message: str) -> Finding:
|
|
66
|
+
code = ENFORCEMENT_CODES[rule_id]
|
|
67
|
+
return Finding(code, rule_id, line, message, rules_by_code()[code].fix)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _matches(path: str, globs: Iterable[str]) -> bool:
|
|
71
|
+
name = path.rsplit("/", 1)[-1]
|
|
72
|
+
return any(fnmatch.fnmatch(path, g) or fnmatch.fnmatch(name, g) for g in globs)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Python
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _dotted(node: ast.expr) -> str:
|
|
79
|
+
if isinstance(node, ast.Name):
|
|
80
|
+
return node.id
|
|
81
|
+
if isinstance(node, ast.Attribute):
|
|
82
|
+
return f"{_dotted(node.value)}.{node.attr}"
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SqlVisitor(ast.NodeVisitor):
|
|
87
|
+
"""``.raw()`` / ``.execute()`` / ``.executemany()`` / ``RawSQL(...)`` calls."""
|
|
88
|
+
|
|
89
|
+
def __init__(self) -> None:
|
|
90
|
+
self.hits: list[tuple[int, str]] = []
|
|
91
|
+
self._rawsql_names = {"RawSQL"}
|
|
92
|
+
|
|
93
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
94
|
+
for alias in node.names:
|
|
95
|
+
if alias.name == "RawSQL":
|
|
96
|
+
self._rawsql_names.add(alias.asname or alias.name)
|
|
97
|
+
self.generic_visit(node)
|
|
98
|
+
|
|
99
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
100
|
+
func = node.func
|
|
101
|
+
if isinstance(func, ast.Attribute) and func.attr in {"raw", "execute", "executemany"}:
|
|
102
|
+
self.hits.append((node.lineno, f".{func.attr}(...)"))
|
|
103
|
+
elif isinstance(func, ast.Name | ast.Attribute) and (
|
|
104
|
+
_dotted(func).rsplit(".", 1)[-1] in self._rawsql_names
|
|
105
|
+
):
|
|
106
|
+
self.hits.append((node.lineno, "RawSQL(...)"))
|
|
107
|
+
self.generic_visit(node)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: Serializer bases whose Meta-only subclasses duplicate the generated serializer.
|
|
111
|
+
SERIALIZER_BASES = frozenset({"ModelSerializer", "KarniModelSerializer"})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_abstract(cls: ast.ClassDef) -> bool:
|
|
115
|
+
for stmt in cls.body:
|
|
116
|
+
if isinstance(stmt, ast.ClassDef) and stmt.name == "Meta":
|
|
117
|
+
for item in stmt.body:
|
|
118
|
+
if (
|
|
119
|
+
isinstance(item, ast.Assign)
|
|
120
|
+
and any(isinstance(t, ast.Name) and t.id == "abstract" for t in item.targets)
|
|
121
|
+
and isinstance(item.value, ast.Constant)
|
|
122
|
+
and item.value.value is True
|
|
123
|
+
):
|
|
124
|
+
return True
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _class_lines(cls: ast.ClassDef) -> list[int]:
|
|
129
|
+
first = min([cls.lineno, *(d.lineno for d in cls.decorator_list)])
|
|
130
|
+
return sorted({cls.lineno, cls.lineno - 1, first, first - 1})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_meta_only(cls: ast.ClassDef) -> bool:
|
|
134
|
+
body = [
|
|
135
|
+
s
|
|
136
|
+
for s in cls.body
|
|
137
|
+
if not (isinstance(s, ast.Expr) and isinstance(s.value, ast.Constant))
|
|
138
|
+
and not isinstance(s, ast.Pass)
|
|
139
|
+
]
|
|
140
|
+
return bool(body) and all(isinstance(s, ast.ClassDef) and s.name == "Meta" for s in body)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _assigned_names(cls: ast.ClassDef) -> set[str]:
|
|
144
|
+
names: set[str] = set()
|
|
145
|
+
for stmt in cls.body:
|
|
146
|
+
if isinstance(stmt, ast.Assign):
|
|
147
|
+
names |= {t.id for t in stmt.targets if isinstance(t, ast.Name)}
|
|
148
|
+
elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
149
|
+
names.add(stmt.target.id)
|
|
150
|
+
elif isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
151
|
+
names.add(stmt.name)
|
|
152
|
+
return names
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _lacks_access(cls: ast.ClassDef) -> bool:
|
|
156
|
+
"""A routable direct ``BaseViewSet`` subclass (``model``/``queryset``) without ``access``.
|
|
157
|
+
|
|
158
|
+
Subclasses of other viewsets inherit their parent's declaration, which a
|
|
159
|
+
single-file scan cannot see; ``karni check`` judges those.
|
|
160
|
+
"""
|
|
161
|
+
names = _assigned_names(cls)
|
|
162
|
+
routable = bool(names & {"model", "queryset"})
|
|
163
|
+
return routable and not names & {"access", "get_access"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def access_override_defs(tree: ast.AST) -> list[tuple[int, list[int]]]:
|
|
167
|
+
"""``(def line, opt-out marker lines)`` for each ``filter_queryset_for_user`` in a class.
|
|
168
|
+
|
|
169
|
+
Shared by the ``access-override`` architecture rule and ``karni rules
|
|
170
|
+
--check``. Markers count on the ``def`` line, the line above it, or the
|
|
171
|
+
line above its first decorator.
|
|
172
|
+
"""
|
|
173
|
+
hits: list[tuple[int, list[int]]] = []
|
|
174
|
+
for node in ast.walk(tree):
|
|
175
|
+
if not isinstance(node, ast.ClassDef):
|
|
176
|
+
continue
|
|
177
|
+
for stmt in node.body:
|
|
178
|
+
if (
|
|
179
|
+
isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef)
|
|
180
|
+
and stmt.name == "filter_queryset_for_user"
|
|
181
|
+
):
|
|
182
|
+
first = min([stmt.lineno, *(d.lineno for d in stmt.decorator_list)])
|
|
183
|
+
hits.append((stmt.lineno, sorted({stmt.lineno, stmt.lineno - 1, first - 1})))
|
|
184
|
+
return sorted(hits)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
#: Calls that mean "this action paginates" (DRF's hooks and BaseViewSet's helper).
|
|
188
|
+
PAGINATING_CALLS: frozenset[str] = frozenset(
|
|
189
|
+
{"paginate_queryset", "paginated_response", "get_paginated_response"}
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_action(func: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
194
|
+
for decorator in func.decorator_list:
|
|
195
|
+
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
|
196
|
+
if _dotted(target).rsplit(".", 1)[-1] == "action":
|
|
197
|
+
return True
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_view_class(cls: ast.ClassDef) -> bool:
|
|
202
|
+
return any(_dotted(base).endswith(("ViewSet", "View")) for base in cls.bases)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _many_true(call: ast.Call) -> bool:
|
|
206
|
+
keywords = {keyword.arg: keyword.value for keyword in call.keywords}
|
|
207
|
+
many = keywords.get("many")
|
|
208
|
+
return (
|
|
209
|
+
isinstance(many, ast.Constant)
|
|
210
|
+
and many.value is True
|
|
211
|
+
and "data" not in keywords # many=True input validation is not a list response
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def unpaginated_action_calls(tree: ast.AST) -> list[tuple[int, list[int]]]:
|
|
216
|
+
"""``(line, opt-out marker lines)`` per unpaginated ``many=True`` serialisation in an action.
|
|
217
|
+
|
|
218
|
+
Shared by the ``unpaginated-action`` architecture rule and ``karni rules
|
|
219
|
+
--check``. An action is an ``@action``-decorated method (or ``list`` on a
|
|
220
|
+
view class); it is flagged when it builds a serializer with ``many=True``
|
|
221
|
+
(no ``data=``) and never calls ``paginate_queryset`` /
|
|
222
|
+
``paginated_response`` / ``get_paginated_response``. Markers count on the
|
|
223
|
+
call line, the line above it, the ``def`` line, the line above it, or the
|
|
224
|
+
line above the first decorator.
|
|
225
|
+
"""
|
|
226
|
+
hits: list[tuple[int, list[int]]] = []
|
|
227
|
+
for node in ast.walk(tree):
|
|
228
|
+
if not isinstance(node, ast.ClassDef):
|
|
229
|
+
continue
|
|
230
|
+
for stmt in node.body:
|
|
231
|
+
if not isinstance(stmt, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
232
|
+
continue
|
|
233
|
+
if not (_is_action(stmt) or (stmt.name == "list" and _is_view_class(node))):
|
|
234
|
+
continue
|
|
235
|
+
calls = [call for call in ast.walk(stmt) if isinstance(call, ast.Call)]
|
|
236
|
+
if any(_dotted(call.func).rsplit(".", 1)[-1] in PAGINATING_CALLS for call in calls):
|
|
237
|
+
continue
|
|
238
|
+
first = min([stmt.lineno, *(d.lineno for d in stmt.decorator_list)])
|
|
239
|
+
for call in calls:
|
|
240
|
+
if _many_true(call):
|
|
241
|
+
lines = {call.lineno, call.lineno - 1, stmt.lineno, stmt.lineno - 1, first - 1}
|
|
242
|
+
hits.append((call.lineno, sorted(lines)))
|
|
243
|
+
return sorted(hits)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _python(path: str, text: str) -> list[tuple[Finding, list[int]]]:
|
|
247
|
+
tree = ast.parse(text, filename=path)
|
|
248
|
+
model_names = {"models.Model", "django.db.models.Model"}
|
|
249
|
+
for node in ast.walk(tree):
|
|
250
|
+
if isinstance(node, ast.ImportFrom) and node.module == "django.db.models":
|
|
251
|
+
model_names |= {a.asname or a.name for a in node.names if a.name == "Model"}
|
|
252
|
+
found: list[tuple[Finding, list[int]]] = []
|
|
253
|
+
|
|
254
|
+
for node in ast.walk(tree):
|
|
255
|
+
if isinstance(node, ast.ClassDef):
|
|
256
|
+
bases = {_dotted(b) for b in node.bases}
|
|
257
|
+
short = {b.rsplit(".", 1)[-1] for b in bases}
|
|
258
|
+
if bases & model_names and not short & KARNI_MODEL_BASES and not _is_abstract(node):
|
|
259
|
+
found.append(
|
|
260
|
+
(
|
|
261
|
+
_finding(
|
|
262
|
+
"model-base",
|
|
263
|
+
node.lineno,
|
|
264
|
+
f"{node.name} inherits django.db.models.Model directly",
|
|
265
|
+
),
|
|
266
|
+
_class_lines(node),
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
if short & _DRF_VIEWSETS and "BaseViewSet" not in short:
|
|
270
|
+
found.append(
|
|
271
|
+
(
|
|
272
|
+
_finding("viewset-base", node.lineno, f"{node.name} bypasses BaseViewSet"),
|
|
273
|
+
_class_lines(node),
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
if "ModelAdmin" in short and "KarniModelAdmin" not in short:
|
|
277
|
+
found.append(
|
|
278
|
+
(
|
|
279
|
+
_finding(
|
|
280
|
+
"admin-convention",
|
|
281
|
+
node.lineno,
|
|
282
|
+
f"{node.name} is a plain ModelAdmin, not derived from AdminMeta",
|
|
283
|
+
),
|
|
284
|
+
_class_lines(node),
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
if "BaseViewSet" in short and _lacks_access(node):
|
|
288
|
+
found.append(
|
|
289
|
+
(
|
|
290
|
+
_finding(
|
|
291
|
+
"viewset-access",
|
|
292
|
+
node.lineno,
|
|
293
|
+
f"{node.name} declares no `access` — every request is denied",
|
|
294
|
+
),
|
|
295
|
+
_class_lines(node),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
if short & SERIALIZER_BASES and _is_meta_only(node):
|
|
299
|
+
found.append(
|
|
300
|
+
(
|
|
301
|
+
_finding(
|
|
302
|
+
"redundant-serializer",
|
|
303
|
+
node.lineno,
|
|
304
|
+
f"{node.name} is a Meta-only ModelSerializer — "
|
|
305
|
+
"BaseViewSet(model=X) already generates it",
|
|
306
|
+
),
|
|
307
|
+
_class_lines(node),
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
elif (
|
|
311
|
+
isinstance(node, ast.Call)
|
|
312
|
+
and _dotted(node.func) == "admin.site.register"
|
|
313
|
+
and len(node.args) == 1
|
|
314
|
+
):
|
|
315
|
+
found.append(
|
|
316
|
+
(
|
|
317
|
+
_finding(
|
|
318
|
+
"admin-convention",
|
|
319
|
+
node.lineno,
|
|
320
|
+
"admin.site.register() with the default ModelAdmin",
|
|
321
|
+
),
|
|
322
|
+
[node.lineno, node.lineno - 1],
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
for lineno, candidates in access_override_defs(tree):
|
|
327
|
+
found.append(
|
|
328
|
+
(
|
|
329
|
+
_finding(
|
|
330
|
+
"access-override",
|
|
331
|
+
lineno,
|
|
332
|
+
"filter_queryset_for_user overridden — express row rules in `access`",
|
|
333
|
+
),
|
|
334
|
+
candidates,
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
for lineno, candidates in unpaginated_action_calls(tree):
|
|
339
|
+
found.append(
|
|
340
|
+
(
|
|
341
|
+
_finding(
|
|
342
|
+
"unpaginated-action",
|
|
343
|
+
lineno,
|
|
344
|
+
"list-returning action serialises many=True without pagination",
|
|
345
|
+
),
|
|
346
|
+
candidates,
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if not _matches(path, SQL_ALLOWED):
|
|
351
|
+
visitor = SqlVisitor()
|
|
352
|
+
visitor.visit(tree)
|
|
353
|
+
for lineno, what in visitor.hits:
|
|
354
|
+
found.append(
|
|
355
|
+
(
|
|
356
|
+
_finding("raw-sql", lineno, f"raw SQL via {what} outside a sql/ directory"),
|
|
357
|
+
[lineno, lineno - 1],
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if not _matches(path, HTTP_ALLOWED):
|
|
362
|
+
for lineno, name in http_client_imports(tree):
|
|
363
|
+
found.append(
|
|
364
|
+
(
|
|
365
|
+
_finding("http-client", lineno, f"direct HTTP client import: {name}"),
|
|
366
|
+
[lineno, lineno - 1],
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
return found
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def http_client_imports(tree: ast.AST) -> list[tuple[int, str]]:
|
|
373
|
+
"""``(lineno, name)`` for each import of a banned HTTP client (one per statement)."""
|
|
374
|
+
hits: list[tuple[int, str]] = []
|
|
375
|
+
for node in ast.walk(tree):
|
|
376
|
+
if isinstance(node, ast.Import):
|
|
377
|
+
names = [a.name for a in node.names]
|
|
378
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
379
|
+
names = [node.module] + [f"{node.module}.{a.name}" for a in node.names]
|
|
380
|
+
else:
|
|
381
|
+
continue
|
|
382
|
+
for name in names:
|
|
383
|
+
if name in HTTP_BANNED or any(name.startswith(f"{b}.") for b in HTTP_BANNED):
|
|
384
|
+
hits.append((node.lineno, name))
|
|
385
|
+
break
|
|
386
|
+
return sorted(hits)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _python_optouts(lines: list[str], found: list[tuple[Finding, list[int]]]) -> list[Finding]:
|
|
390
|
+
markers: dict[int, tuple[set[str], str]] = {}
|
|
391
|
+
for i, text in enumerate(lines, start=1):
|
|
392
|
+
m = _PY_ALLOW.search(text)
|
|
393
|
+
if m:
|
|
394
|
+
rules = {r.strip() for r in m.group(1).split(",") if r.strip()}
|
|
395
|
+
markers[i] = (rules, (m.group(2) or "").strip())
|
|
396
|
+
result: list[Finding] = []
|
|
397
|
+
for i, (rules, reason) in sorted(markers.items()):
|
|
398
|
+
if not reason:
|
|
399
|
+
result.append(
|
|
400
|
+
_finding(
|
|
401
|
+
"opt-out-reason",
|
|
402
|
+
i,
|
|
403
|
+
f"karni: allow({', '.join(sorted(rules))}) requires a non-empty reason",
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
for finding, candidates in found:
|
|
407
|
+
allowed = any(
|
|
408
|
+
finding.rule_id in markers.get(line, (set(), ""))[0] and markers[line][1]
|
|
409
|
+
for line in candidates
|
|
410
|
+
)
|
|
411
|
+
if not allowed:
|
|
412
|
+
result.append(finding)
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# TypeScript / JavaScript
|
|
417
|
+
|
|
418
|
+
_RAW_ELEMENT = re.compile(r"<(table|form|input|select|textarea)(?=[\s/>])")
|
|
419
|
+
_DEEP_IMPORT = re.compile(
|
|
420
|
+
r"""(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)["']@karni/ui/(?:dist|src)(?:/[^"']*)?["']"""
|
|
421
|
+
)
|
|
422
|
+
_FETCH = re.compile(r"(?<![\w$.])(?:(?:window|globalThis|self)\.)?fetch\s*\(")
|
|
423
|
+
_IMPORT_SOURCE = r"""(?:from\s+|import\s*\(\s*|require\s*\(\s*|import\s+)["']"""
|
|
424
|
+
_AXIOS = re.compile(_IMPORT_SOURCE + r"""axios(?:/[^"']*)?["']""")
|
|
425
|
+
_NEW_TRANSPORT = re.compile(
|
|
426
|
+
r"(?<![\w$.])new\s+(?:(?:window|globalThis|self)\.)?(XMLHttpRequest|WebSocket)\b"
|
|
427
|
+
)
|
|
428
|
+
_REPLACEMENTS = {
|
|
429
|
+
"table": "ResourceTable",
|
|
430
|
+
"form": "ResourceForm",
|
|
431
|
+
"input": "Input (or ResourceForm)",
|
|
432
|
+
"select": "Select / ResourceSelect",
|
|
433
|
+
"textarea": "Textarea (or ResourceForm)",
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _split_comments(text: str) -> tuple[list[str], list[tuple[int, str]]]:
|
|
438
|
+
"""Code lines with comments blanked (line numbers kept) + (line, comment) pairs.
|
|
439
|
+
|
|
440
|
+
String literals are skipped so ``"//"`` inside a string is not a comment;
|
|
441
|
+
template literals are treated like strings (no nested ``${}`` parsing).
|
|
442
|
+
"""
|
|
443
|
+
out: list[str] = []
|
|
444
|
+
comments: list[tuple[int, str]] = []
|
|
445
|
+
i, n, line = 0, len(text), 1
|
|
446
|
+
buf: list[str] = []
|
|
447
|
+
block: list[str] | None = None
|
|
448
|
+
block_line = 0
|
|
449
|
+
quote = ""
|
|
450
|
+
while i < n:
|
|
451
|
+
ch = text[i]
|
|
452
|
+
if block is not None:
|
|
453
|
+
if text.startswith("*/", i):
|
|
454
|
+
block.append("*/")
|
|
455
|
+
comments.append((block_line, "".join(block)))
|
|
456
|
+
block = None
|
|
457
|
+
i += 2
|
|
458
|
+
continue
|
|
459
|
+
if ch == "\n":
|
|
460
|
+
out.append("".join(buf))
|
|
461
|
+
buf = []
|
|
462
|
+
line += 1
|
|
463
|
+
block.append(ch)
|
|
464
|
+
i += 1
|
|
465
|
+
continue
|
|
466
|
+
if quote:
|
|
467
|
+
buf.append(ch)
|
|
468
|
+
if ch == "\\" and i + 1 < n:
|
|
469
|
+
buf.append(text[i + 1])
|
|
470
|
+
i += 2
|
|
471
|
+
continue
|
|
472
|
+
if ch == quote:
|
|
473
|
+
quote = ""
|
|
474
|
+
elif ch == "\n":
|
|
475
|
+
out.append("".join(buf))
|
|
476
|
+
buf = []
|
|
477
|
+
line += 1
|
|
478
|
+
if quote != "`":
|
|
479
|
+
quote = ""
|
|
480
|
+
i += 1
|
|
481
|
+
continue
|
|
482
|
+
if text.startswith("//", i):
|
|
483
|
+
end = text.find("\n", i)
|
|
484
|
+
end = n if end == -1 else end
|
|
485
|
+
comments.append((line, text[i:end]))
|
|
486
|
+
i = end
|
|
487
|
+
continue
|
|
488
|
+
if text.startswith("/*", i):
|
|
489
|
+
block = ["/*"]
|
|
490
|
+
block_line = line
|
|
491
|
+
i += 2
|
|
492
|
+
continue
|
|
493
|
+
if ch in "\"'`":
|
|
494
|
+
quote = ch
|
|
495
|
+
if ch == "\n":
|
|
496
|
+
out.append("".join(buf))
|
|
497
|
+
buf = []
|
|
498
|
+
line += 1
|
|
499
|
+
else:
|
|
500
|
+
buf.append(ch)
|
|
501
|
+
i += 1
|
|
502
|
+
out.append("".join(buf))
|
|
503
|
+
return out, comments
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
_STRING = re.compile(r""""(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`""")
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _blank_strings(lines: list[str]) -> list[str]:
|
|
510
|
+
"""String literal contents replaced by spaces (quotes and line count kept)."""
|
|
511
|
+
|
|
512
|
+
def blank(match: re.Match[str]) -> str:
|
|
513
|
+
body = match.group(0)
|
|
514
|
+
inner = "".join("\n" if ch == "\n" else " " for ch in body[1:-1])
|
|
515
|
+
return body[0] + inner + body[-1]
|
|
516
|
+
|
|
517
|
+
return _STRING.sub(blank, "\n".join(lines)).split("\n")
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _ts(path: str, text: str) -> list[Finding]:
|
|
521
|
+
code_lines, comments = _split_comments(text)
|
|
522
|
+
bare_lines = _blank_strings(code_lines)
|
|
523
|
+
suffix = Path(path).suffix
|
|
524
|
+
findings: list[Finding] = []
|
|
525
|
+
# eslint directives: line -> set of suppressed rule names ("*" = all)
|
|
526
|
+
suppressed: dict[int, set[str]] = {}
|
|
527
|
+
file_wide: set[str] = set()
|
|
528
|
+
for line, comment in comments:
|
|
529
|
+
m = _ESLINT_DIRECTIVE.search(comment)
|
|
530
|
+
if not m:
|
|
531
|
+
continue
|
|
532
|
+
rules = {r.strip() for r in m.group("rules").split(",") if r.strip()} or {"*"}
|
|
533
|
+
if not (m.group("reason") or "").strip():
|
|
534
|
+
findings.append(
|
|
535
|
+
_finding(
|
|
536
|
+
"eslint-comments/require-description",
|
|
537
|
+
line,
|
|
538
|
+
"eslint-disable comment without a `-- <reason>`",
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
continue
|
|
542
|
+
kind = m.group("kind")
|
|
543
|
+
if kind == "-next-line":
|
|
544
|
+
suppressed.setdefault(line + 1, set()).update(rules)
|
|
545
|
+
elif kind == "-line":
|
|
546
|
+
suppressed.setdefault(line, set()).update(rules)
|
|
547
|
+
else:
|
|
548
|
+
file_wide |= rules
|
|
549
|
+
|
|
550
|
+
def report(rule_id: str, line: int, message: str) -> None:
|
|
551
|
+
active = suppressed.get(line, set()) | file_wide
|
|
552
|
+
if rule_id in active or "*" in active:
|
|
553
|
+
return
|
|
554
|
+
findings.append(_finding(rule_id, line, message))
|
|
555
|
+
|
|
556
|
+
posix = Path(path).as_posix()
|
|
557
|
+
parts = posix.split("/")
|
|
558
|
+
if suffix in JSX_SUFFIXES:
|
|
559
|
+
for number, code in enumerate(bare_lines, start=1):
|
|
560
|
+
for m in _RAW_ELEMENT.finditer(code):
|
|
561
|
+
element = m.group(1)
|
|
562
|
+
report(
|
|
563
|
+
"karni/no-raw-elements",
|
|
564
|
+
number,
|
|
565
|
+
f"raw <{element}> — compose {_REPLACEMENTS[element]} from @karni/ui",
|
|
566
|
+
)
|
|
567
|
+
if "pages" in parts[:-1]:
|
|
568
|
+
physical = text.split("\n")
|
|
569
|
+
count = len(physical) - 1 if physical[-1] == "" else len(physical)
|
|
570
|
+
if count > PAGE_MAX_LINES:
|
|
571
|
+
report(
|
|
572
|
+
"karni/max-page-lines",
|
|
573
|
+
1,
|
|
574
|
+
f"page file is {count} physical lines (max {PAGE_MAX_LINES})",
|
|
575
|
+
)
|
|
576
|
+
for number, code in enumerate(code_lines, start=1):
|
|
577
|
+
if _DEEP_IMPORT.search(code):
|
|
578
|
+
report("karni/no-deep-imports", number, "deep import from @karni/ui internals")
|
|
579
|
+
http_exempt = (
|
|
580
|
+
"api" in parts[:-1]
|
|
581
|
+
or "mocks" in parts[:-1]
|
|
582
|
+
or fnmatch.fnmatch(parts[-1], "*.test.*")
|
|
583
|
+
or fnmatch.fnmatch(parts[-1], "vitest.setup.*")
|
|
584
|
+
)
|
|
585
|
+
if not http_exempt:
|
|
586
|
+
for number, (code, bare) in enumerate(zip(code_lines, bare_lines, strict=True), 1):
|
|
587
|
+
if _FETCH.search(bare):
|
|
588
|
+
report("karni/no-direct-http", number, "direct fetch() outside src/api/")
|
|
589
|
+
if _AXIOS.search(code):
|
|
590
|
+
report("karni/no-direct-http", number, "axios import — Karni ships the HTTP layer")
|
|
591
|
+
for m in _NEW_TRANSPORT.finditer(bare):
|
|
592
|
+
report("karni/no-direct-http", number, f"new {m.group(1)}() outside src/api/")
|
|
593
|
+
return findings
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
# entry points
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def supported(path: str | Path) -> bool:
|
|
600
|
+
suffix = Path(path).suffix
|
|
601
|
+
return suffix in PY_SUFFIXES or suffix in TS_SUFFIXES
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class SourceParseError(ValueError):
|
|
605
|
+
"""A file ``karni check`` cannot parse (code ``E703``); a ``ValueError`` for callers."""
|
|
606
|
+
|
|
607
|
+
code = "E703"
|
|
608
|
+
|
|
609
|
+
def __str__(self) -> str:
|
|
610
|
+
return f"{self.code}: {super().__str__()}"
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def check_source(path: str | Path, text: str) -> list[Finding]:
|
|
614
|
+
"""Findings for ``text`` as if it lived at ``path`` (the path drives allowances)."""
|
|
615
|
+
posix = Path(path).as_posix()
|
|
616
|
+
suffix = Path(path).suffix
|
|
617
|
+
if suffix in PY_SUFFIXES:
|
|
618
|
+
try:
|
|
619
|
+
found = _python(posix, text)
|
|
620
|
+
except SyntaxError as exc:
|
|
621
|
+
raise SourceParseError(
|
|
622
|
+
f"{path}:{exc.lineno}: cannot parse: {exc.msg}. karni check reads code "
|
|
623
|
+
"statically; fix the syntax error and run it again."
|
|
624
|
+
) from None
|
|
625
|
+
findings = _python_optouts(text.splitlines(), found)
|
|
626
|
+
elif suffix in TS_SUFFIXES:
|
|
627
|
+
findings = _ts(posix, text)
|
|
628
|
+
else:
|
|
629
|
+
return []
|
|
630
|
+
return sorted(findings, key=lambda f: (f.line, f.code, f.message))
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def scoped_path(path: Path, root: Path | None = None) -> str:
|
|
634
|
+
"""The path the allowances match against: relative to ``root`` (else the cwd) when
|
|
635
|
+
inside it, as ``./<rel>`` — so a ``tests/`` or ``api/`` directory ABOVE the project
|
|
636
|
+
never changes the verdict — otherwise the absolute path."""
|
|
637
|
+
absolute = path.resolve()
|
|
638
|
+
for base in (root, Path.cwd()):
|
|
639
|
+
if base is None:
|
|
640
|
+
continue
|
|
641
|
+
try:
|
|
642
|
+
return "./" + absolute.relative_to(Path(base).resolve()).as_posix()
|
|
643
|
+
except ValueError:
|
|
644
|
+
continue
|
|
645
|
+
return absolute.as_posix()
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def check_file(
|
|
649
|
+
path: str | Path, enabled: Iterable[str] | None = None, root: str | Path | None = None
|
|
650
|
+
) -> list[Finding]:
|
|
651
|
+
"""Read and check one file; ``enabled`` (codes) filters out disabled rules."""
|
|
652
|
+
path = Path(path)
|
|
653
|
+
scope = scoped_path(path, Path(root) if root is not None else None)
|
|
654
|
+
findings = check_source(scope, path.read_text(encoding="utf-8"))
|
|
655
|
+
if enabled is not None:
|
|
656
|
+
allowed = set(enabled)
|
|
657
|
+
findings = [f for f in findings if f.code in allowed]
|
|
658
|
+
return findings
|