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,673 @@
1
+ """Karni's agent guidance as structured data — the single source of truth.
2
+
3
+ Every statement the scaffold used to hand-write into ``AGENTS.md`` /
4
+ ``CLAUDE.md`` / ``.cursorrules`` / slash-command files lives here as a
5
+ :class:`Rule` (conventions) or a :class:`Procedure` (step lists). The
6
+ ``karni`` CLI renders them (``karni rules``), checks files against them
7
+ (``karni rules --check``), serves them over MCP (``karni mcp``) and generates
8
+ the thin per-tool adapter files from them (``karni rules sync``).
9
+
10
+ Import-light by contract: stdlib only, no Django, no settings — the CLI reads
11
+ this module without a project (guarded by ``tests/agent``).
12
+
13
+ Code scheme (stable; a code is never reused for a different rule):
14
+
15
+ * ``K0xx`` workflow — how an agent works in a Karni project
16
+ * ``K1xx`` backend — Python/Django conventions
17
+ * ``K2xx`` frontend — React/TypeScript conventions
18
+ * ``K3xx`` architecture — what to build at all, and where
19
+ * project codes (``P001``…) come from the project's ``karni.rules.py``
20
+
21
+ Severity: ``error`` = enforced, fails the build (``karni check`` or the
22
+ ``@karni/ui`` ESLint preset; ``enforced_by`` names the underlying rule id);
23
+ ``must`` = mandatory convention, not machine-checked; ``should`` = strong
24
+ default, deviate only with a stated reason.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import asdict, dataclass, field
30
+ from typing import Any, Literal
31
+
32
+ Severity = Literal["error", "must", "should"]
33
+
34
+ #: Render order of categories in ``karni rules``.
35
+ CATEGORIES: tuple[str, ...] = ("workflow", "architecture", "backend", "frontend")
36
+
37
+ SEVERITIES: tuple[str, ...] = ("error", "must", "should")
38
+
39
+ #: Values ``Rule.when`` may take. ``history``: a history provider detects a
40
+ #: prompt-history store for the project (:mod:`karni.agent.history`).
41
+ CONDITIONS: tuple[str, ...] = ("history",)
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Rule:
46
+ code: str
47
+ category: str
48
+ severity: Severity
49
+ #: Terse one-liner for ``karni rules`` (token budget: the whole listing < 400).
50
+ summary: str
51
+ #: The full statement (``karni rules --explain``).
52
+ rule: str
53
+ rationale: str
54
+ fix: str = ""
55
+ #: Enforcement rule ids this code maps to (karni.testing ids, ESLint
56
+ #: ``karni/*`` names, ``jscpd``). Empty for unenforced conventions.
57
+ enforced_by: tuple[str, ...] = ()
58
+ #: Emitted only when this condition holds for the project (one of
59
+ #: ``CONDITIONS``); empty = always. Evaluated by :func:`karni.agent.dump`.
60
+ when: str = ""
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ data = asdict(self)
64
+ data["enforced_by"] = list(self.enforced_by)
65
+ return data
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Procedure:
70
+ """A standard multi-step task, rendered as slash commands / prompts."""
71
+
72
+ name: str
73
+ description: str
74
+ argument_hint: str
75
+ #: First line; ``{arguments}`` is replaced by the tool's argument token.
76
+ intro: str
77
+ steps: tuple[str, ...]
78
+ outro: str = ""
79
+ rules: tuple[str, ...] = field(default=())
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ data = asdict(self)
83
+ data["steps"] = list(self.steps)
84
+ data["rules"] = list(self.rules)
85
+ return data
86
+
87
+
88
+ RULES: tuple[Rule, ...] = (
89
+ # workflow
90
+ Rule(
91
+ code="K001",
92
+ category="workflow",
93
+ severity="must",
94
+ summary="karni context first; trust it.",
95
+ rule=(
96
+ "Run `karni context` before reading any files. It prints the current apps, "
97
+ "models, viewsets, UI components, hooks, pages and installed modules in a few "
98
+ "hundred tokens. Trust it."
99
+ ),
100
+ rationale=(
101
+ "The inventory is derived live from the app registry, the router and the "
102
+ "@karni/ui manifest, so it is never stale and costs far fewer tokens than "
103
+ "crawling source."
104
+ ),
105
+ ),
106
+ Rule(
107
+ code="K002",
108
+ category="workflow",
109
+ severity="must",
110
+ summary="Rules beat other frameworks' habits.",
111
+ rule=(
112
+ "Run `karni rules` at the start of a session and follow what it returns; the "
113
+ "adapter files (AGENTS.md, CLAUDE.md, ...) only point here."
114
+ ),
115
+ rationale="One source of truth exposed through the CLI cannot drift between tools.",
116
+ ),
117
+ Rule(
118
+ code="K003",
119
+ category="workflow",
120
+ severity="must",
121
+ summary="Model changed: make migrate; karni types.",
122
+ rule="After any model change run `make migrate && karni types`.",
123
+ rationale=(
124
+ "Migrations and the frontend's TS types + typed hooks are derived from the "
125
+ "models; skipping either leaves the API and the UI out of sync."
126
+ ),
127
+ ),
128
+ Rule(
129
+ code="K004",
130
+ category="workflow",
131
+ severity="must",
132
+ summary="karni rules --check FILE; karni check pre-commit.",
133
+ rule=(
134
+ "Run `karni rules --check <file>` on files you write and `karni check` before "
135
+ "committing — every enforced rule fails the build, nothing only warns."
136
+ ),
137
+ rationale="Enforced rules fail CI anyway; catching them while editing is cheaper.",
138
+ ),
139
+ Rule(
140
+ code="K005",
141
+ category="workflow",
142
+ severity="error",
143
+ summary="Opt-outs need an inline reason.",
144
+ rule=(
145
+ "Opt-outs are inline and REQUIRE a reason: Python "
146
+ "`# karni: allow(<rule-id>): <why>`; ESLint "
147
+ "`// eslint-disable-next-line karni/<rule> -- <why>`; jscpd "
148
+ "`// jscpd:ignore-start -- <why>`."
149
+ ),
150
+ rationale=(
151
+ "Rules without escape hatches cause forks, and a fork is worse than a "
152
+ "duplicate — but every exception must say why so it can be reviewed."
153
+ ),
154
+ fix="Write the reason after the marker.",
155
+ enforced_by=("opt-out-reason", "eslint-comments/require-description"),
156
+ ),
157
+ Rule(
158
+ code="K006",
159
+ category="workflow",
160
+ severity="should",
161
+ summary="Procedures: karni rules --procedure NAME.",
162
+ rule=(
163
+ "Prefer the standard procedures for routine work: new-model, new-page, "
164
+ "add-module, new-module — `karni rules --procedure <name>`, or the matching "
165
+ "slash command where your tool has one."
166
+ ),
167
+ rationale="The procedures encode the shortest correct path for routine work.",
168
+ ),
169
+ Rule(
170
+ code="K007",
171
+ category="workflow",
172
+ severity="should",
173
+ summary="Commands: karni --help.",
174
+ rule=(
175
+ "Use the project commands (`karni --help` lists them all): `make up` starts the "
176
+ "stack; `make migrate` runs makemigrations + migrate; `karni types` regenerates "
177
+ "TS types + typed hooks from the API schema; `karni check` runs architecture and "
178
+ "system checks; `karni context` prints the inventory; `karni make <type>` "
179
+ "scaffolds; runners like `karni test`, `karni shell`, `karni routes`, "
180
+ "`karni worker`, `karni schedule list` wrap manage.py/celery/pytest."
181
+ ),
182
+ rationale="They run inside the right interpreter (project .venv or compose service).",
183
+ ),
184
+ Rule(
185
+ code="K008",
186
+ category="workflow",
187
+ severity="must",
188
+ summary="Before editing a file: karni context --with-history FILE.",
189
+ rule=(
190
+ "This project keeps a prompt-history store. Before editing a file that already "
191
+ "has history, run `karni context --with-history <file>` and read its HISTORY "
192
+ "section, so you do not redo a decision that was already made and reverted."
193
+ ),
194
+ rationale=(
195
+ "The inventory shows what the code contains now; the history provider shows "
196
+ "what was already asked, decided and abandoned for that file."
197
+ ),
198
+ when="history",
199
+ ),
200
+ Rule(
201
+ code="K009",
202
+ category="workflow",
203
+ severity="must",
204
+ summary="Scaffold with karni make.",
205
+ rule=(
206
+ "Create apps, models, viewsets, serializers, migrations, tasks, schedules, events, "
207
+ "listeners, management commands, services, middleware, pages, components and "
208
+ "modules with `karni make <type>` (`karni make --list`; `--dry-run` previews, "
209
+ "`--help` per type). Never hand-create a file, class or route a generator makes "
210
+ "— run it, then edit what it wrote."
211
+ ),
212
+ rationale=(
213
+ "Generators emit the conventions in one step (BaseModel, a mandatory access "
214
+ "declaration, settings and route markers, ruff-clean imports) and are tested to "
215
+ "pass every rule; hand-written scaffolding drifts."
216
+ ),
217
+ fix="Delete the hand-written scaffold and run the matching `karni make` command.",
218
+ ),
219
+ # architecture
220
+ Rule(
221
+ code="K301",
222
+ category="architecture",
223
+ severity="must",
224
+ summary="Domain code only; extend Karni.",
225
+ rule=(
226
+ "Karni already implements everything generic; you write only domain code. "
227
+ "Extending Karni is always cheaper than reinventing it."
228
+ ),
229
+ rationale="Generic code written per project is the boilerplate Karni exists to remove.",
230
+ ),
231
+ Rule(
232
+ code="K302",
233
+ category="architecture",
234
+ severity="must",
235
+ summary=(
236
+ "Never write auth/users, admin, serializers, routers, settings, infra, tables, forms."
237
+ ),
238
+ rule=(
239
+ "Never write auth flows, user models, admin registration, serializers, routers, "
240
+ "settings, Docker/Compose config, Celery config, HTML tables, forms, or detail "
241
+ "views. Karni provides all of these. If you think you need one, you need an "
242
+ "override on an existing Karni base instead."
243
+ ),
244
+ rationale="Each of these ships in Karni with tested defaults and override points.",
245
+ fix="Find the Karni base that provides it (`karni context` BASES) and override it.",
246
+ ),
247
+ Rule(
248
+ code="K303",
249
+ category="architecture",
250
+ severity="must",
251
+ summary="Extend via props/methods; never fork.",
252
+ rule=(
253
+ "If an existing component or base class almost fits, extend it through its "
254
+ "override props / hooks (`columns`, `fields`, `slots`, `AdminMeta`, `APIMeta`, "
255
+ "overridable methods). Do not create a parallel variant."
256
+ ),
257
+ rationale="A forked variant stops receiving fixes and doubles the surface to maintain.",
258
+ ),
259
+ Rule(
260
+ code="K304",
261
+ category="architecture",
262
+ severity="must",
263
+ summary="No extra state/form/table/HTTP/CSS library.",
264
+ rule=(
265
+ "The stack is fixed. Do not introduce a state library, form library, table "
266
+ "library, HTTP client or CSS framework beyond what Karni ships (TanStack "
267
+ "Query/Table, react-hook-form, Zod, Tailwind, Radix via @karni/ui). Backend "
268
+ "HTTP clients are allowed only in services modules (K105)."
269
+ ),
270
+ rationale="Every extra library is a second way to do something Karni already does.",
271
+ ),
272
+ Rule(
273
+ code="K305",
274
+ category="architecture",
275
+ severity="must",
276
+ summary="Per entity: models.py and viewsets.py.",
277
+ rule=(
278
+ "Backend code per entity goes in `<app>/models.py` and `<app>/viewsets.py` — "
279
+ "nothing else. No admin.py, serializers.py, forms.py; urls.py only for non-API "
280
+ "endpoints such as webhooks (viewsets ride the auto-router)."
281
+ ),
282
+ rationale="Admin, serializers, filters, routes and schema are derived from those two.",
283
+ ),
284
+ Rule(
285
+ code="K306",
286
+ category="architecture",
287
+ severity="must",
288
+ summary="Pages: frontend/src/pages/.",
289
+ rule=(
290
+ "Frontend pages live in `frontend/src/pages/`, one file per page, with the route "
291
+ "registered alongside the existing pages."
292
+ ),
293
+ rationale="The page cap (K202) and the inventory (`karni context` PAGES) rely on it.",
294
+ ),
295
+ Rule(
296
+ code="K307",
297
+ category="architecture",
298
+ severity="should",
299
+ summary="Cross-project code: a Karni module.",
300
+ rule=(
301
+ "Anything reusable across projects belongs in a Karni module (a `karni_<name>` "
302
+ "package), not in this project — say so instead of building it."
303
+ ),
304
+ rationale="Code that would be written more than once belongs in Karni, never per project.",
305
+ ),
306
+ Rule(
307
+ code="K308",
308
+ category="architecture",
309
+ severity="must",
310
+ summary="Modules: configure via settings.",
311
+ rule=(
312
+ "Configure an installed module only through the settings it documents; never "
313
+ "patch its internals."
314
+ ),
315
+ rationale="Patched internals break on the module's next release.",
316
+ ),
317
+ # backend
318
+ Rule(
319
+ code="K101",
320
+ category="backend",
321
+ severity="error",
322
+ summary="Models inherit BaseModel or compose its mixins.",
323
+ rule=(
324
+ "Models inherit `karni.models.BaseModel` (= `UUIDModel` + `TimestampedModel` + "
325
+ "`SoftDeleteModel` + `AuditedModel`), or compose those mixins directly when a "
326
+ "model should not have all four (e.g. an append-only log: "
327
+ "`UUIDModel, TimestampedModel`). Declare `APIMeta` / `AdminMeta` only for "
328
+ "fields that must be hidden, read-only or write-only."
329
+ ),
330
+ rationale=(
331
+ "The mixins supply uuid ids, timestamps, soft delete, audit stamping and the "
332
+ "managers the derived API/admin rely on."
333
+ ),
334
+ fix="Inherit karni.models.BaseModel (or compose its mixins).",
335
+ enforced_by=("model-base",),
336
+ ),
337
+ Rule(
338
+ code="K108",
339
+ category="backend",
340
+ severity="must",
341
+ summary="Off-request writes: current_user(u).",
342
+ rule=(
343
+ "`AuditedModel` stamps `created_by`/`updated_by` from the current request. "
344
+ "Outside a request (Celery tasks, management commands) nothing is stamped "
345
+ "unless you wrap the writes in `with current_user(user):` from "
346
+ "`karni.middleware`. Never assign `created_by`/`updated_by` by hand."
347
+ ),
348
+ rationale=(
349
+ "One stamping path keeps the audit trail trustworthy under WSGI, ASGI and workers."
350
+ ),
351
+ fix="from karni.middleware import current_user; with current_user(user): ...",
352
+ ),
353
+ Rule(
354
+ code="K102",
355
+ category="backend",
356
+ severity="error",
357
+ summary="Viewsets inherit BaseViewSet.",
358
+ rule=(
359
+ "Viewsets inherit `karni.api.BaseViewSet` — usually just `model = X` plus its "
360
+ "`access` declaration (K109) (+ `search_fields`); the auto-router registers it."
361
+ ),
362
+ rationale="BaseViewSet derives serializer, filters, pagination, permissions and schema.",
363
+ fix="Inherit karni.api.BaseViewSet and set `model = X`.",
364
+ enforced_by=("viewset-base",),
365
+ ),
366
+ Rule(
367
+ code="K109",
368
+ category="backend",
369
+ severity="error",
370
+ summary="Viewsets declare access.",
371
+ rule=(
372
+ "Every `BaseViewSet` declares `access`: `Granted()` (the roles' grants for the "
373
+ "model, row-scoped; the default) or `Access(read=..., write=...)` built from "
374
+ '`Owned("<fk to user>")`, `HasRole(...)`, `Authenticated()`, `Public()` and '
375
+ "`Where(lambda request: Q(...))` with `|` / `&`. Reads are public only when you "
376
+ "write `Public()`. No `access` denies every request; `Unscoped(reason=...)` is "
377
+ "the explicit opt-out."
378
+ ),
379
+ rationale=(
380
+ "One declaration compiles into both the request gate and the row filter, so "
381
+ "neither can be forgotten; a forgotten permission class fails open silently."
382
+ ),
383
+ fix="Add `access = Granted()` (or `Access(read=..., write=...)` for a custom rule).",
384
+ enforced_by=("viewset-access",),
385
+ ),
386
+ Rule(
387
+ code="K110",
388
+ category="backend",
389
+ severity="error",
390
+ summary="Row rules: in access.",
391
+ rule=(
392
+ "Express who sees which rows in `access` (`Where(...)` for custom logic, "
393
+ "`@action(..., access=...)` for one action). An overridden `get_queryset` is "
394
+ "still read-scoped — never use it to widen. Overriding `filter_queryset_for_user` "
395
+ "needs `# karni: allow(access-override): <why>`."
396
+ ),
397
+ rationale=(
398
+ "The queryset is the authoritative layer: lists, details, actions, relation "
399
+ "PKs and ?expand= all go through the read scope, so a bypass leaks everywhere."
400
+ ),
401
+ fix="Move the condition into access (Where(lambda request: Q(...))), or add the marker.",
402
+ enforced_by=("access-override",),
403
+ ),
404
+ Rule(
405
+ code="K111",
406
+ category="backend",
407
+ severity="error",
408
+ summary="Paginate lists; no N+1.",
409
+ rule=(
410
+ "Lists stay bounded and flat. A list-returning `@action` returns "
411
+ "`self.paginated_response(queryset)`, never `Serializer(qs, many=True).data`; "
412
+ "`pagination_class = None` needs `pagination = Unpaginated(max_rows=N, "
413
+ "reason=...)`. Query counts must not grow with rows: BaseViewSet derives "
414
+ "select_related/prefetch_related from the serializer, so hint only what it cannot "
415
+ "see (SerializerMethodField, properties) with `select_related = [...]` / "
416
+ "`prefetch_related = [...]` on the viewset. Raise a budget only with "
417
+ "`query_budget = Budget(list=N, reason=...)`."
418
+ ),
419
+ rationale=(
420
+ "An unpaginated list or an N+1 passes with test data and falls over with real "
421
+ "data; the scaling check (2 vs 12 rows) and per-action query budgets catch both "
422
+ "in CI."
423
+ ),
424
+ fix="Return self.paginated_response(qs); hint opaque fields with prefetch_related.",
425
+ enforced_by=("unpaginated-action", "query-scaling"),
426
+ ),
427
+ Rule(
428
+ code="K113",
429
+ category="backend",
430
+ severity="error",
431
+ summary="Audited models: no update()/bulk_update().",
432
+ rule=(
433
+ "A model with `class Karni: audit = True` keeps field-level change history "
434
+ "through save() and delete(); `QuerySet.update()` and `bulk_update()` bypass it "
435
+ "and leave no entry. Save the instances instead, or opt out with "
436
+ "`# karni: allow(audited-bulk-write): <reason>`."
437
+ ),
438
+ rationale=(
439
+ "A silent gap in the history is exactly the bug someone discovers during an "
440
+ "audit; the rule names every bulk write on an audited model at build time."
441
+ ),
442
+ fix="Loop and save(), or add the allow marker with the reason.",
443
+ enforced_by=("audited-bulk-write",),
444
+ ),
445
+ Rule(
446
+ code="K103",
447
+ category="backend",
448
+ severity="error",
449
+ summary="No ModelAdmin; use AdminMeta.",
450
+ rule=(
451
+ "Do not register a plain `ModelAdmin`. Declare `AdminMeta` on the model (the "
452
+ "admin is derived), or subclass `karni.admin.KarniModelAdmin`."
453
+ ),
454
+ rationale="The derived admin stays consistent with the model and the API.",
455
+ fix="Delete the admin class and use AdminMeta, or subclass KarniModelAdmin.",
456
+ enforced_by=("admin-convention",),
457
+ ),
458
+ Rule(
459
+ code="K104",
460
+ category="backend",
461
+ severity="error",
462
+ summary="Raw SQL only under */sql/*.",
463
+ rule="No raw SQL (`.raw()`, `cursor.execute()`, `RawSQL`) outside `*/sql/*` or migrations.",
464
+ rationale="Raw SQL bypasses managers (soft delete, owner scoping) and the ORM's safety.",
465
+ fix="Use the ORM, or move the query into a `sql/` module.",
466
+ enforced_by=("raw-sql",),
467
+ ),
468
+ Rule(
469
+ code="K105",
470
+ category="backend",
471
+ severity="error",
472
+ summary="HTTP clients only in */services/*.",
473
+ rule=(
474
+ "Import `requests` / `httpx` / `urllib.request` only in `*/services/*` or "
475
+ "`*/providers/*` modules."
476
+ ),
477
+ rationale="External calls in one layer are mockable, retryable and auditable.",
478
+ fix="Move the call into a services module and call that.",
479
+ enforced_by=("http-client",),
480
+ ),
481
+ Rule(
482
+ code="K106",
483
+ category="backend",
484
+ severity="error",
485
+ summary="No Meta-only ModelSerializer.",
486
+ rule="Never write a Meta-only `ModelSerializer`; `BaseViewSet(model=X)` generates it.",
487
+ rationale="A redundant serializer silently diverges from the generated one.",
488
+ fix="Delete it; customise via APIMeta or override get_serializer_class().",
489
+ enforced_by=("redundant-serializer",),
490
+ ),
491
+ Rule(
492
+ code="K107",
493
+ category="backend",
494
+ severity="error",
495
+ summary="No duplicated code.",
496
+ rule=(
497
+ "No structurally duplicated code (8+ identical statements in two places); reuse "
498
+ "the named Karni API or extract one implementation."
499
+ ),
500
+ rationale="Duplicates are where boilerplate creeps back in.",
501
+ fix="Call the existing implementation instead of copying it.",
502
+ enforced_by=("duplicate",),
503
+ ),
504
+ # frontend
505
+ Rule(
506
+ code="K201",
507
+ category="frontend",
508
+ severity="error",
509
+ summary="Compose @karni/ui; no raw table/form/input.",
510
+ rule=(
511
+ "Screens compose `@karni/ui` components (`Page`, `ResourceTable`, "
512
+ "`ResourceForm`, `ResourceDetail`, ...). Never hand-roll a table or form: no raw "
513
+ "`<table>/<form>/<input>/<select>/<textarea>`."
514
+ ),
515
+ rationale="The components already handle data, validation, a11y and theming.",
516
+ fix="Compose ResourceTable / ResourceForm / Input / Select from @karni/ui.",
517
+ enforced_by=("karni/no-raw-elements",),
518
+ ),
519
+ Rule(
520
+ code="K202",
521
+ category="frontend",
522
+ severity="error",
523
+ summary="Pages: max 50 lines.",
524
+ rule=(
525
+ "Page files stay at 50 lines or fewer. Past that you are rebuilding something "
526
+ "Karni ships — use the component's props."
527
+ ),
528
+ rationale="A long page is reimplementing a component.",
529
+ fix="Use the components' props; move logic into hooks.",
530
+ enforced_by=("karni/max-page-lines",),
531
+ ),
532
+ Rule(
533
+ code="K203",
534
+ category="frontend",
535
+ severity="error",
536
+ summary="Import only the @karni/ui barrel.",
537
+ rule="Import only the public `@karni/ui` barrel, never `@karni/ui/dist/*` or `/src/*`.",
538
+ rationale="Internals change without notice; the barrel is the versioned API.",
539
+ fix='Import from "@karni/ui".',
540
+ enforced_by=("karni/no-deep-imports",),
541
+ ),
542
+ Rule(
543
+ code="K204",
544
+ category="frontend",
545
+ severity="error",
546
+ summary="Data only via Karni hooks; no fetch/axios.",
547
+ rule=(
548
+ "Data access only via @karni/ui hooks (`useResourceList`, `useResourceItem`, "
549
+ "`useResourceMutations`, the KarniClient) and the typed hooks generated by "
550
+ "`karni types`. No fetch/axios/XMLHttpRequest/WebSocket outside `src/api/`."
551
+ ),
552
+ rationale="The hooks share caching, auth, errors and query-key invalidation.",
553
+ fix="Use the generated typed hook or useKarniClient().",
554
+ enforced_by=("karni/no-direct-http",),
555
+ ),
556
+ Rule(
557
+ code="K205",
558
+ category="frontend",
559
+ severity="error",
560
+ summary="No duplicated frontend code.",
561
+ rule="No duplicated frontend code above the `frontend/.jscpd.json` threshold.",
562
+ rationale="Duplicates are where boilerplate creeps back in.",
563
+ fix="Extract one component or hook, or compose the existing @karni/ui one.",
564
+ enforced_by=("jscpd",),
565
+ ),
566
+ )
567
+
568
+
569
+ PROCEDURES: tuple[Procedure, ...] = (
570
+ Procedure(
571
+ name="new-model",
572
+ description="Add a domain model the Karni way (model + viewset, nothing else)",
573
+ argument_hint="<ModelName> [in <app>] [fields...]",
574
+ intro="Add a new domain model: {arguments}",
575
+ steps=(
576
+ "Run `karni context` to see existing apps, models and viewsets.",
577
+ "`karni make model <Name> <field:type ...> --with viewset --access <who> "
578
+ "[--search f1,f2] [--app <app>]` writes the model on `karni.models.BaseModel` and "
579
+ "its viewset (`-a` also adds list/detail/form pages). Field types: "
580
+ "`karni make model --help`. `--access`: authenticated (default), public-read, "
581
+ "owned:<fk to user>, staff, role:<name>; edit the generated `access` for anything "
582
+ "richer (`Owned(...) | HasRole(...)`, `Where(...)`). Do NOT write an admin, "
583
+ "serializer or router: the auto-router registers it; admin, filters, schema are "
584
+ "derived.",
585
+ "Add `class APIMeta:` / `class AdminMeta:` only for fields that must be hidden, "
586
+ "read-only or write-only.",
587
+ "`karni make migration && karni migrate && karni types`",
588
+ "Verify: `karni context` lists the model and viewset; GET /api/<plural>/ works "
589
+ "for a caller `access` admits and is refused (401/403) or empty for one it does not.",
590
+ ),
591
+ outro=(
592
+ "Total new code should be well under 20 lines. If it isn't, stop — you are "
593
+ "reimplementing something Karni provides."
594
+ ),
595
+ rules=("K009", "K101", "K102", "K109", "K108", "K302", "K305", "K003"),
596
+ ),
597
+ Procedure(
598
+ name="new-page",
599
+ description="Add a frontend page composing @karni/ui (50 lines or fewer)",
600
+ argument_hint="<page purpose> [resource]",
601
+ intro="Add a page: {arguments}",
602
+ steps=(
603
+ "Run `karni context` for available components, hooks, resources and pages.",
604
+ "`karni make page <name> --resource <resource> --kind list|detail|form` writes "
605
+ "`frontend/src/pages/<name>.tsx` composing @karni/ui and does the rest a person "
606
+ "would do by hand: register the route alongside the existing pages "
607
+ "(frontend/src/routes.tsx). Without `--resource` it is a bare `<Page>`. Never "
608
+ "hand-roll tables, forms or fetching.",
609
+ "Custom behaviour goes through props (`columns`, `fields`, `rowActions`, slots, "
610
+ "custom cell/field renderers) — extend, never fork a variant.",
611
+ "Keep the file at 50 lines or fewer. Data access only via @karni/ui hooks "
612
+ "(`useResourceList`, `useResourceItem`, `useResourceMutations`) and the typed "
613
+ "hooks generated by `karni types`.",
614
+ "`karni rules --check <the page file>` must report no violations.",
615
+ ),
616
+ rules=("K009", "K201", "K202", "K204", "K303", "K306"),
617
+ ),
618
+ Procedure(
619
+ name="add-module",
620
+ description="Install an optional Karni module and wire it into settings",
621
+ argument_hint="<module> (e.g. payments)",
622
+ intro="Install Karni module: {arguments}",
623
+ steps=(
624
+ "Run `karni add <module>` — it adds the `karni-<module>` dependency to "
625
+ "pyproject.toml, wires `karni_<module>` into the INSTALLED_APPS block marked "
626
+ "`# karni:apps` in config/settings.py, and (once installed) records the "
627
+ "module's agent guidance.",
628
+ "Rebuild/install: `docker compose build` (or `uv pip install -r pyproject.toml` "
629
+ "in a host venv), then `make migrate`; run `karni add <module>` again if it "
630
+ "asked you to.",
631
+ "Run `karni context` — the module must appear under MODULES; `karni rules` now "
632
+ "includes its guidance.",
633
+ "Configure only settings the module documents; do not patch its internals.",
634
+ "To create a new reusable module instead: `karni make module <name>` "
635
+ "(modules/karni-<name>/, docs/modules.md).",
636
+ ),
637
+ rules=("K009", "K308"),
638
+ ),
639
+ Procedure(
640
+ name="new-module",
641
+ description="Create a new local Django app following Karni conventions",
642
+ argument_hint="<app_name>",
643
+ intro="Create a local app: {arguments}",
644
+ steps=(
645
+ "Run `karni context` first — confirm the domain isn't already covered.",
646
+ "`karni make app <app_name>` writes `__init__.py`, `apps.py`, `models.py`, "
647
+ "`viewsets.py`, `migrations/`, `tests/` and adds the app to the `# karni:apps` "
648
+ "block in config/settings.py. No admin.py, serializers.py, urls.py, forms.py — "
649
+ "Karni derives all of that.",
650
+ "Add models with `karni make model <Name> <field:type ...> --app <app_name> "
651
+ "--with viewset --access <who>` (procedure new-model).",
652
+ "`karni make migration && karni migrate && karni types`, then `karni context` "
653
+ "to verify.",
654
+ "Anything reusable across projects belongs in a Karni module: "
655
+ "`karni make module <name>`, not in this project — say so instead of building it.",
656
+ ),
657
+ rules=("K009", "K305", "K307"),
658
+ ),
659
+ )
660
+
661
+
662
+ def rules_by_code() -> dict[str, Rule]:
663
+ return {rule.code: rule for rule in RULES}
664
+
665
+
666
+ def procedures_by_name() -> dict[str, Procedure]:
667
+ return {procedure.name: procedure for procedure in PROCEDURES}
668
+
669
+
670
+ #: Enforcement rule id -> K code, for tools that report the underlying id.
671
+ ENFORCEMENT_CODES: dict[str, str] = {
672
+ rule_id: rule.code for rule in RULES for rule_id in rule.enforced_by
673
+ }