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.
Files changed (145) hide show
  1. karni_cli/__init__.py +3 -0
  2. karni_cli/_agent/__init__.py +82 -0
  3. karni_cli/_agent/__main__.py +44 -0
  4. karni_cli/_agent/check.py +658 -0
  5. karni_cli/_agent/history.py +328 -0
  6. karni_cli/_agent/history_chronicle.py +246 -0
  7. karni_cli/_agent/overrides.py +194 -0
  8. karni_cli/_agent/rules.py +673 -0
  9. karni_cli/_changelog/0.2.0.toml +304 -0
  10. karni_cli/_changelog/0.3.0.toml +25 -0
  11. karni_cli/_changelog/__init__.py +248 -0
  12. karni_cli/_errorcodes/__init__.py +1171 -0
  13. karni_cli/_errorcodes/__main__.py +34 -0
  14. karni_cli/_errorcodes/links.py +217 -0
  15. karni_cli/_templates/project/cookiecutter.json +8 -0
  16. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
  17. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
  18. karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
  19. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
  20. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
  21. karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
  22. karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
  23. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
  24. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
  25. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
  26. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
  27. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
  28. karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
  29. karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
  30. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
  31. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
  32. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
  33. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
  34. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
  35. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
  36. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
  37. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
  38. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
  39. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
  40. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
  41. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
  42. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
  43. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
  44. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
  45. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
  46. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
  47. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
  48. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
  49. karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
  50. karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
  51. karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
  52. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
  53. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
  54. karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
  55. karni_cli/adapters.py +373 -0
  56. karni_cli/agent_adapters.toml +160 -0
  57. karni_cli/agent_data.py +260 -0
  58. karni_cli/brand.py +55 -0
  59. karni_cli/clidoc.py +254 -0
  60. karni_cli/commands/__init__.py +0 -0
  61. karni_cli/commands/add.py +144 -0
  62. karni_cli/commands/beat.py +36 -0
  63. karni_cli/commands/check.py +101 -0
  64. karni_cli/commands/config.py +81 -0
  65. karni_cli/commands/context.py +171 -0
  66. karni_cli/commands/createsuperuser.py +28 -0
  67. karni_cli/commands/doctor.py +136 -0
  68. karni_cli/commands/explain.py +60 -0
  69. karni_cli/commands/make.py +55 -0
  70. karni_cli/commands/manage_passthrough.py +28 -0
  71. karni_cli/commands/mcp.py +20 -0
  72. karni_cli/commands/migrate.py +23 -0
  73. karni_cli/commands/new.py +106 -0
  74. karni_cli/commands/routes.py +20 -0
  75. karni_cli/commands/rules.py +139 -0
  76. karni_cli/commands/run_tests.py +38 -0
  77. karni_cli/commands/schedule.py +40 -0
  78. karni_cli/commands/seed.py +34 -0
  79. karni_cli/commands/serve.py +50 -0
  80. karni_cli/commands/shell.py +28 -0
  81. karni_cli/commands/sync_roles.py +32 -0
  82. karni_cli/commands/types.py +72 -0
  83. karni_cli/commands/upgrade.py +231 -0
  84. karni_cli/commands/worker.py +63 -0
  85. karni_cli/config.py +142 -0
  86. karni_cli/environment.py +29 -0
  87. karni_cli/errorcodes.py +377 -0
  88. karni_cli/errordoc.py +227 -0
  89. karni_cli/generators/__init__.py +67 -0
  90. karni_cli/generators/apitest.py +206 -0
  91. karni_cli/generators/app.py +103 -0
  92. karni_cli/generators/command.py +98 -0
  93. karni_cli/generators/component.py +115 -0
  94. karni_cli/generators/core.py +319 -0
  95. karni_cli/generators/event.py +57 -0
  96. karni_cli/generators/factory.py +88 -0
  97. karni_cli/generators/fields.py +343 -0
  98. karni_cli/generators/introspect.py +263 -0
  99. karni_cli/generators/listener.py +168 -0
  100. karni_cli/generators/middleware.py +95 -0
  101. karni_cli/generators/migration.py +82 -0
  102. karni_cli/generators/model.py +235 -0
  103. karni_cli/generators/module.py +255 -0
  104. karni_cli/generators/naming.py +100 -0
  105. karni_cli/generators/page.py +373 -0
  106. karni_cli/generators/pysource.py +304 -0
  107. karni_cli/generators/schedule.py +174 -0
  108. karni_cli/generators/seeder.py +86 -0
  109. karni_cli/generators/serializer.py +165 -0
  110. karni_cli/generators/service.py +68 -0
  111. karni_cli/generators/task.py +84 -0
  112. karni_cli/generators/viewset.py +179 -0
  113. karni_cli/history.py +59 -0
  114. karni_cli/local_state.py +73 -0
  115. karni_cli/main.py +104 -0
  116. karni_cli/mcp_server.py +103 -0
  117. karni_cli/project.py +248 -0
  118. karni_cli/rules_render.py +86 -0
  119. karni_cli/scaffold.py +197 -0
  120. karni_cli/settings_edit.py +128 -0
  121. karni_cli/tips.py +53 -0
  122. karni_cli/typegen.py +101 -0
  123. karni_cli/upgrades/__init__.py +28 -0
  124. karni_cli/upgrades/changelog.py +123 -0
  125. karni_cli/upgrades/codemod.py +125 -0
  126. karni_cli/upgrades/engine.py +224 -0
  127. karni_cli/upgrades/legacy_agent_files.py +138 -0
  128. karni_cli/upgrades/python.py +670 -0
  129. karni_cli/upgrades/transforms/rename-export.cjs +96 -0
  130. karni_cli/upgrades/typescript.py +160 -0
  131. karni_cli/upgrades/v0_2/__init__.py +1 -0
  132. karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
  133. karni_cli/upgrades/v1_0/__init__.py +5 -0
  134. karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
  135. karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
  136. karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
  137. karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
  138. karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
  139. karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
  140. karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
  141. karni_cli-0.3.1.dist-info/METADATA +191 -0
  142. karni_cli-0.3.1.dist-info/RECORD +145 -0
  143. karni_cli-0.3.1.dist-info/WHEEL +4 -0
  144. karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
  145. 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