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,96 @@
1
+ /**
2
+ * jscodeshift transform: rename one named export of a module at every use site.
3
+ *
4
+ * Driven by data (karni_cli.upgrades.typescript.RenameExport), never edited
5
+ * per change. Options:
6
+ * --karni-module=@karni/ui --karni-from=OldName --karni-to=NewName
7
+ *
8
+ * import { Old } from "mod" -> import { New } from "mod" (+ every reference)
9
+ * import { Old as X } from "mod" -> import { New as X } from "mod"
10
+ * export { Old } from "mod" -> export { New as Old } from "mod" (your export keeps its name)
11
+ *
12
+ * References are renamed only where `Old` resolves to the module-level
13
+ * import, so a local variable or parameter that shadows it is left alone,
14
+ * as are object keys and member names. Reports `karni-count <n>` per file.
15
+ */
16
+ module.exports = function renameExport(file, api, options) {
17
+ const j = api.jscodeshift;
18
+ const mod = options["karni-module"];
19
+ const from = options["karni-from"];
20
+ const to = options["karni-to"];
21
+ if (!mod || !from || !to) {
22
+ throw new Error("rename-export needs --karni-module, --karni-from and --karni-to");
23
+ }
24
+ const root = j(file.source);
25
+ const program = root.find(j.Program).paths()[0];
26
+ const bindsToImport = (path) => path.scope != null && path.scope.lookup(from) === program.scope;
27
+
28
+ const specifiers = [];
29
+ let unaliased = false;
30
+ root.find(j.ImportDeclaration, { source: { value: mod } }).forEach((path) => {
31
+ for (const spec of path.node.specifiers || []) {
32
+ if (spec.type !== "ImportSpecifier" || spec.imported.name !== from) continue;
33
+ specifiers.push(spec);
34
+ if (!spec.local || spec.local.name === from) unaliased = true;
35
+ }
36
+ });
37
+
38
+ // Collect references before renaming anything: scope lookups use the old name.
39
+ // A Set: the AST walk can reach one JSX tag name through two paths.
40
+ const references = new Set();
41
+ if (unaliased) {
42
+ root
43
+ .find(j.Identifier, { name: from })
44
+ .filter((path) => {
45
+ const parent = path.parent.node;
46
+ if (parent.type === "ImportSpecifier" || parent.type === "ExportSpecifier") return false;
47
+ const member =
48
+ parent.type === "MemberExpression" || parent.type === "OptionalMemberExpression";
49
+ if (member && parent.property === path.node && !parent.computed) return false;
50
+ const keyed =
51
+ parent.type === "Property" ||
52
+ parent.type === "ObjectProperty" ||
53
+ parent.type === "ClassProperty" ||
54
+ parent.type === "TSPropertySignature";
55
+ if (keyed && parent.key === path.node && !parent.computed) return false;
56
+ if (parent.type === "TSQualifiedName" && parent.right === path.node) return false;
57
+ return bindsToImport(path);
58
+ })
59
+ .forEach((path) => references.add(path.node));
60
+ root
61
+ .find(j.JSXIdentifier, { name: from })
62
+ .filter((path) => {
63
+ const parent = path.parent.node;
64
+ const tag =
65
+ parent.type === "JSXOpeningElement" ||
66
+ parent.type === "JSXClosingElement" ||
67
+ (parent.type === "JSXMemberExpression" && parent.object === path.node);
68
+ return tag && bindsToImport(path);
69
+ })
70
+ .forEach((path) => references.add(path.node));
71
+ }
72
+
73
+ let count = 0;
74
+ for (const spec of specifiers) {
75
+ const aliased = spec.local && spec.local.name !== from;
76
+ spec.imported = j.identifier(to);
77
+ if (!aliased) spec.local = j.identifier(to);
78
+ count += 1;
79
+ }
80
+ for (const node of references) {
81
+ node.name = to;
82
+ count += 1;
83
+ }
84
+ root.find(j.ExportNamedDeclaration, { source: { value: mod } }).forEach((path) => {
85
+ path.node.specifiers = (path.node.specifiers || []).map((spec) => {
86
+ if (spec.type !== "ExportSpecifier" || spec.local.name !== from) return spec;
87
+ count += 1;
88
+ const exported = spec.exported ? spec.exported.name : from;
89
+ return j.exportSpecifier.from({ local: j.identifier(to), exported: j.identifier(exported) });
90
+ });
91
+ });
92
+
93
+ if (count === 0) return null;
94
+ api.report(`karni-count ${count}`);
95
+ return root.toSource();
96
+ };
@@ -0,0 +1,160 @@
1
+ """TypeScript codemods: jscodeshift, run through the machine's own Node.js.
2
+
3
+ The CLI ships the transforms (``karni_cli/upgrades/transforms/``) and runs
4
+ them with a pinned jscodeshift through ``npx``, so a project needs nothing
5
+ installed beyond Node.js itself. Without Node.js, a TypeScript codemod
6
+ reports itself skipped (naming how many files need it) instead of failing
7
+ the upgrade. Files are pre-filtered in Python, so a project with nothing to
8
+ change never needs Node.js at all.
9
+
10
+ A breaking ``@karni/ui`` change is one line of data in its version module::
11
+
12
+ CODEMOD = RenameExport(module="@karni/ui", old="DataTable", new="ResourceTable")
13
+
14
+ Override / escape hatch: ``KARNI_JSCODESHIFT`` replaces the whole runner
15
+ command (e.g. ``"node /path/to/jscodeshift/bin/jscodeshift.js"`` for an
16
+ offline machine); subclass :class:`JscodeshiftCodemod` with a new transform
17
+ for a change that is not a rename.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import re
24
+ import shlex
25
+ import shutil
26
+ import subprocess
27
+ from abc import abstractmethod
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import ClassVar, Final
31
+
32
+ from karni_cli.upgrades.codemod import Codemod, Outcome, source_files
33
+
34
+ __all__ = [
35
+ "JSCODESHIFT_VERSION",
36
+ "JscodeshiftCodemod",
37
+ "RenameExport",
38
+ "jscodeshift_command",
39
+ ]
40
+
41
+ #: Pinned so a codemod behaves the same on every machine, every run.
42
+ JSCODESHIFT_VERSION: Final = "17.4.0"
43
+ TRANSFORMS: Final = Path(__file__).resolve().parent / "transforms"
44
+ #: Seconds one jscodeshift run may take (the first npx run also downloads it).
45
+ TIMEOUT: Final = 600
46
+ SUFFIXES: Final = (".ts", ".tsx", ".mts", ".cts")
47
+ _REPORT = re.compile(r"^\s*REP (?P<path>.+) karni-count (?P<count>\d+)\s*$", re.MULTILINE)
48
+
49
+
50
+ def jscodeshift_command() -> list[str] | None:
51
+ """``KARNI_JSCODESHIFT``, else ``npx --yes jscodeshift@<pin>``; None without Node.js."""
52
+ override = os.environ.get("KARNI_JSCODESHIFT", "").strip()
53
+ if override:
54
+ return shlex.split(override)
55
+ npx = shutil.which("npx")
56
+ return [npx, "--yes", f"jscodeshift@{JSCODESHIFT_VERSION}"] if npx else None
57
+
58
+
59
+ class JscodeshiftCodemod(Codemod):
60
+ """A codemod that runs one transform from ``transforms/`` over the TypeScript files."""
61
+
62
+ #: File name of the transform under ``karni_cli/upgrades/transforms/``.
63
+ transform: ClassVar[str]
64
+
65
+ @abstractmethod
66
+ def needles(self) -> tuple[str, ...]:
67
+ """A file is handed to jscodeshift only when it contains every one of these."""
68
+
69
+ @abstractmethod
70
+ def options(self) -> list[str]:
71
+ """Options passed through to the transform (``--name=value``)."""
72
+
73
+ def apply(self, root: Path) -> Outcome:
74
+ needles = [needle.encode() for needle in self.needles()]
75
+ candidates = [
76
+ rel
77
+ for rel in source_files(root, SUFFIXES)
78
+ if not rel.endswith(".d.ts") and all(n in (root / rel).read_bytes() for n in needles)
79
+ ]
80
+ if not candidates:
81
+ return Outcome()
82
+ command = jscodeshift_command()
83
+ if command is None:
84
+ return Outcome(
85
+ skipped=f"Node.js (npx) not found, and {len(candidates)} TypeScript file(s) "
86
+ "need this change: install Node.js 18+ and run karni upgrade again, "
87
+ "or make it by hand"
88
+ )
89
+ before = {rel: (root / rel).read_bytes() for rel in candidates}
90
+ counts: dict[str, int] = {}
91
+ groups = (
92
+ ("tsx", [rel for rel in candidates if rel.endswith(".tsx")]),
93
+ ("ts", [rel for rel in candidates if not rel.endswith(".tsx")]),
94
+ )
95
+ for parser, files in groups:
96
+ if not files:
97
+ continue
98
+ argv = [
99
+ *command,
100
+ f"--transform={TRANSFORMS / self.transform}",
101
+ f"--parser={parser}",
102
+ "--extensions=ts,tsx,mts,cts",
103
+ "--no-babel",
104
+ "--run-in-band",
105
+ "--fail-on-error",
106
+ *self.options(),
107
+ *files,
108
+ ]
109
+ try:
110
+ proc = subprocess.run(
111
+ argv,
112
+ cwd=root,
113
+ text=True,
114
+ capture_output=True,
115
+ stdin=subprocess.DEVNULL, # never wait on a prompt
116
+ timeout=TIMEOUT,
117
+ check=False,
118
+ )
119
+ except subprocess.TimeoutExpired:
120
+ proc = subprocess.CompletedProcess(argv, 124, "", f"timed out after {TIMEOUT}s")
121
+ except OSError as exc:
122
+ proc = subprocess.CompletedProcess(argv, 127, "", str(exc))
123
+ if proc.returncode != 0:
124
+ for rel, content in before.items(): # never leave a half-run behind
125
+ (root / rel).write_bytes(content)
126
+ detail = (proc.stderr or proc.stdout).strip().splitlines()
127
+ return Outcome(
128
+ skipped=f"jscodeshift failed (exit {proc.returncode}): "
129
+ f"{detail[-1] if detail else 'no output'}; make this change by hand"
130
+ )
131
+ for match in _REPORT.finditer(proc.stdout):
132
+ path = match.group("path").strip()
133
+ counts[path] = counts.get(path, 0) + int(match.group("count"))
134
+ files_changed = {
135
+ rel: counts.get(rel, 1)
136
+ for rel in candidates
137
+ if (root / rel).read_bytes() != before[rel]
138
+ }
139
+ return Outcome(files=files_changed)
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class RenameExport(JscodeshiftCodemod):
144
+ """``import { old } from "module"`` -> ``import { new } from "module"``, plus every use."""
145
+
146
+ transform: ClassVar[str] = "rename-export.cjs"
147
+
148
+ module: str
149
+ old: str
150
+ new: str
151
+
152
+ def needles(self) -> tuple[str, ...]:
153
+ return (self.module, self.old)
154
+
155
+ def options(self) -> list[str]:
156
+ return [
157
+ f"--karni-module={self.module}",
158
+ f"--karni-from={self.old}",
159
+ f"--karni-to={self.new}",
160
+ ]
@@ -0,0 +1 @@
1
+ """Codemods for changes that break in Karni 0.2 (changelog: karni/changelog/0.2.0.toml)."""
@@ -0,0 +1,269 @@
1
+ """Codemod: Karni's hand-written agent files -> karni.rules.py + generated adapters.
2
+
3
+ Projects scaffolded before the retrofit carry AGENTS.md (canonical guidance),
4
+ CLAUDE.md (``@AGENTS.md``; older projects: the full guidance), .cursorrules
5
+ and .claude/commands/*.md. For each one that is recognisably Karni's:
6
+
7
+ 1. module blocks (``<!-- karni:module:<x> -->``) move to ``.karni/modules/<x>.md``;
8
+ 2. every line the project ADDED to Karni's text becomes an ``ADD`` entry in
9
+ ``karni.rules.py`` (verbatim; grouped per bullet/paragraph; P### codes);
10
+ 3. every line the project REMOVED from Karni's text is listed as a
11
+ ``# REVIEW:`` comment in karni.rules.py and printed — the codemod cannot
12
+ know whether that meant "disable this rule", so it asks a human;
13
+ 4. the original is backed up under ``.karni/backup/`` and replaced by the
14
+ generated adapter (``karni rules sync``).
15
+
16
+ Files that do not look like Karni's are left untouched and reported. Nothing
17
+ a user wrote is discarded silently.
18
+
19
+ Changelog entry ``agent-guidance-files`` (0.2.0); ``CODEMOD`` is what
20
+ ``karni upgrade`` runs. :func:`migrate_agent_guidance` is the same migration
21
+ for direct callers, with its own dry run and printed notes.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import difflib
27
+ import json
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+
32
+ import typer
33
+
34
+ from karni_cli import agent_data
35
+ from karni_cli.adapters import MODULES_DIR, backup, is_generated, sync
36
+ from karni_cli.upgrades.codemod import Codemod, Outcome, Site
37
+ from karni_cli.upgrades.legacy_agent_files import LEGACY_FILES, PROJECT_NAME
38
+
39
+ RULES_FILE = "karni.rules.py"
40
+ #: Line-similarity to a frozen baseline above which a file counts as Karni's.
41
+ SIMILARITY = 0.5
42
+ _MODULE_BLOCK = re.compile(
43
+ r"<!-- karni:module:(?P<name>[\w-]+) -->\n(?P<body>.*?)\n?<!-- /karni:module:(?P=name) -->\n?",
44
+ re.DOTALL,
45
+ )
46
+ _H1 = re.compile(r"^# (?P<name>.+?) — agent guidance\s*$")
47
+ _ITEM = re.compile(r"^(?:[-*+]|\d+[.)])\s+")
48
+ _CODE = re.compile(r"""["']code["']\s*:\s*["']P(\d{3})["']""")
49
+ _SIGNATURES = ("This project is built on Karni.", "karni context")
50
+
51
+
52
+ @dataclass
53
+ class Migration:
54
+ entries: list[dict[str, str]] = field(default_factory=list)
55
+ removed: list[tuple[str, str]] = field(default_factory=list)
56
+ modules: dict[str, str] = field(default_factory=dict)
57
+ migrated: list[str] = field(default_factory=list)
58
+ foreign: list[str] = field(default_factory=list)
59
+
60
+
61
+ def _baselines(rel: str, text: str) -> list[str]:
62
+ """Candidate Karni originals for ``rel`` with the project name filled in."""
63
+ match = _H1.match(text.splitlines()[0]) if text.strip() else None
64
+ name = match.group("name") if match else PROJECT_NAME
65
+ candidates = [LEGACY_FILES[rel]]
66
+ if rel == "CLAUDE.md": # pre-AGENTS.md projects kept the full guidance in CLAUDE.md
67
+ candidates.append(LEGACY_FILES["AGENTS.md"])
68
+ return [c.replace(PROJECT_NAME, name) for c in candidates]
69
+
70
+
71
+ def _ratio(a: list[str], b: list[str]) -> float:
72
+ return difflib.SequenceMatcher(a=a, b=b, autojunk=False).ratio()
73
+
74
+
75
+ def _is_karnis(rel: str, text: str, baseline: str) -> bool:
76
+ if text == baseline:
77
+ return True
78
+ lines, base = text.splitlines(), baseline.splitlines()
79
+ if _ratio(lines, base) >= SIMILARITY:
80
+ return True
81
+ return rel.endswith(".md") and all(sig in text for sig in _SIGNATURES)
82
+
83
+
84
+ def _category(heading: str | None) -> str:
85
+ if not heading:
86
+ return "project"
87
+ slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-")
88
+ return f"project-{slug}" if slug else "project"
89
+
90
+
91
+ def _group(added: list[tuple[str, str | None]]) -> list[tuple[str, str | None]]:
92
+ """Fold added lines into entries: one per bullet / numbered item / paragraph.
93
+
94
+ A heading the project added becomes the category of what follows it; a
95
+ heading added with nothing under it is kept as an entry of its own.
96
+ """
97
+ entries: list[tuple[str, str | None]] = []
98
+ current: list[str] = []
99
+ section: str | None = None
100
+ headings: list[str] = []
101
+
102
+ def flush() -> None:
103
+ if current:
104
+ entries.append((" ".join(current), section))
105
+ current.clear()
106
+
107
+ for line, line_section in added:
108
+ stripped = line.strip()
109
+ if not stripped or stripped.startswith("#"):
110
+ flush()
111
+ if stripped.startswith("#"):
112
+ headings.append(stripped.lstrip("#").strip())
113
+ continue
114
+ if line_section != section:
115
+ flush()
116
+ section = line_section
117
+ if _ITEM.match(stripped):
118
+ flush()
119
+ stripped = _ITEM.sub("", stripped, count=1)
120
+ current.append(stripped)
121
+ flush()
122
+ used = {entry_section for _, entry_section in entries}
123
+ entries += [(f"## {h}", h) for h in headings if h not in used]
124
+ return entries
125
+
126
+
127
+ def _diff(text: str, baseline: str) -> tuple[list[tuple[str, str | None]], list[str]]:
128
+ """(added lines with their section heading, removed non-blank lines)."""
129
+ ours, base = text.splitlines(), baseline.splitlines()
130
+ added: list[tuple[str, str | None]] = []
131
+ removed: list[str] = []
132
+ headings: list[str | None] = []
133
+ heading: str | None = None
134
+ for line in ours:
135
+ if line.startswith("## "):
136
+ heading = line[3:].strip()
137
+ headings.append(heading)
138
+ matcher = difflib.SequenceMatcher(a=base, b=ours, autojunk=False)
139
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
140
+ if tag in ("replace", "delete"):
141
+ removed += [line for line in base[i1:i2] if line.strip()]
142
+ if tag in ("replace", "insert"):
143
+ added += [(ours[j], headings[j]) for j in range(j1, j2)]
144
+ return added, removed
145
+
146
+
147
+ def plan(project: Path) -> Migration:
148
+ migration = Migration()
149
+ for rel in LEGACY_FILES:
150
+ path = project / rel
151
+ if not path.is_file() or is_generated(path):
152
+ continue
153
+ text = path.read_text(encoding="utf-8")
154
+ for block in _MODULE_BLOCK.finditer(text):
155
+ migration.modules[block.group("name")] = block.group("body").strip() + "\n"
156
+ text = _MODULE_BLOCK.sub("", text)
157
+ baselines = _baselines(rel, text)
158
+ baseline = max(baselines, key=lambda b: _ratio(text.splitlines(), b.splitlines()))
159
+ if not _is_karnis(rel, text, baseline):
160
+ migration.foreign.append(rel)
161
+ continue
162
+ migration.migrated.append(rel)
163
+ added, removed = _diff(text.rstrip("\n") + "\n", baseline)
164
+ prefix = "" if rel in ("AGENTS.md", "CLAUDE.md", ".cursorrules") else f"({rel}) "
165
+ for statement, heading in _group(added):
166
+ migration.entries.append(
167
+ {"rule": prefix + statement, "category": _category(heading), "source": rel}
168
+ )
169
+ migration.removed += [(rel, line) for line in removed]
170
+ return migration
171
+
172
+
173
+ def _next_code(existing: str) -> int:
174
+ used = [int(m) for m in _CODE.findall(existing)]
175
+ return max(used, default=0) + 1
176
+
177
+
178
+ def render_rules_block(migration: Migration, existing: str) -> str:
179
+ start = _next_code(existing)
180
+ lines = []
181
+ if not existing:
182
+ lines += [
183
+ '"""Project agent rules for `karni rules` (read statically; literals only).',
184
+ "",
185
+ "Schema: ADD / OVERRIDE / DISABLE / ADAPTERS — see docs/agents.md.",
186
+ '"""',
187
+ "",
188
+ ]
189
+ lines.append("# Migrated by `karni upgrade` from Karni's old agent files (originals in")
190
+ lines.append("# .karni/backup/). Each entry is text your project added, verbatim.")
191
+ for rel, line in migration.removed:
192
+ lines.append(f"# REVIEW: removed from Karni's guidance in {rel}: {line.strip()}")
193
+ lines.append("ADD += [" if existing else "ADD = [")
194
+ for offset, entry in enumerate(migration.entries):
195
+ lines.append(" {")
196
+ lines.append(f' "code": "P{start + offset:03d}",')
197
+ lines.append(f' "category": {json.dumps(entry["category"], ensure_ascii=False)},')
198
+ lines.append(f' "rule": {json.dumps(entry["rule"], ensure_ascii=False)},')
199
+ lines.append(" },")
200
+ lines.append("]")
201
+ return "\n".join(lines) + "\n"
202
+
203
+
204
+ def _would_change(migration: Migration) -> list[str]:
205
+ changed = list(migration.migrated)
206
+ if migration.entries or migration.removed:
207
+ changed.append(RULES_FILE)
208
+ changed += [(MODULES_DIR / f"{name}.md").as_posix() for name in migration.modules]
209
+ return sorted(set(changed))
210
+
211
+
212
+ def _apply(project: Path, migration: Migration) -> list[str]:
213
+ """Write the migration; returns every file written, backed up or removed."""
214
+ changed = _would_change(migration)
215
+ rules_path = project / RULES_FILE
216
+ existing = rules_path.read_text(encoding="utf-8") if rules_path.is_file() else ""
217
+ if migration.entries or migration.removed:
218
+ block = render_rules_block(migration, existing)
219
+ rules_path.write_text(existing + ("\n" if existing else "") + block, encoding="utf-8")
220
+ for name, body in migration.modules.items():
221
+ target = project / MODULES_DIR / f"{name}.md"
222
+ if not target.is_file():
223
+ target.parent.mkdir(parents=True, exist_ok=True)
224
+ target.write_text(body, encoding="utf-8")
225
+ for rel in migration.migrated:
226
+ changed.append(backup(project, rel))
227
+ (project / rel).unlink()
228
+ result = sync(project, agent_data.load(project))
229
+ changed += result.written + result.removed
230
+ return sorted(set(changed))
231
+
232
+
233
+ def _notes(migration: Migration) -> list[tuple[str, str]]:
234
+ """``(file, note)`` for everything a person has to look at."""
235
+ notes = [
236
+ (rel, "does not look like Karni's file — left untouched, review it")
237
+ for rel in migration.foreign
238
+ ]
239
+ notes += [
240
+ (rel, f"REVIEW: you removed {line.strip()!r} (noted in {RULES_FILE})")
241
+ for rel, line in migration.removed
242
+ ]
243
+ return notes
244
+
245
+
246
+ def migrate_agent_guidance(project: Path, dry_run: bool) -> list[str]:
247
+ """Migrate (or, with ``dry_run``, list) the agent files; prints what needs review."""
248
+ migration = plan(project)
249
+ for rel, note in _notes(migration):
250
+ typer.echo(f" {rel}: {note}")
251
+ if not migration.migrated:
252
+ return []
253
+ if dry_run:
254
+ return _would_change(migration)
255
+ return _apply(project, migration)
256
+
257
+
258
+ class AgentGuidance(Codemod):
259
+ """The ``karni upgrade`` codemod: same migration, notes reported as sites."""
260
+
261
+ def apply(self, root: Path) -> Outcome:
262
+ migration = plan(root)
263
+ sites = [Site(rel, 0, note) for rel, note in _notes(migration)]
264
+ if not migration.migrated:
265
+ return Outcome(sites=sites)
266
+ return Outcome(files=dict.fromkeys(_apply(root, migration), 1), sites=sites)
267
+
268
+
269
+ CODEMOD = AgentGuidance()
@@ -0,0 +1,5 @@
1
+ """Codemods for changes that break in Karni 1.0: the removals of what 0.2 deprecated.
2
+
3
+ Every replacement already exists in 0.2, so ``karni upgrade`` runs these
4
+ from 0.2 on, a release before the old names stop working.
5
+ """
@@ -0,0 +1,18 @@
1
+ """``Model.all_objects`` -> ``Model.objects.without("soft_delete", reason=...)``.
2
+
3
+ Changelog entry ``all-objects``; migration note docs/migrations/0.2.md#all_objects.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from karni_cli.upgrades.python import ReplaceAttribute
9
+
10
+ CODEMOD = ReplaceAttribute(
11
+ attr="all_objects",
12
+ replacement='objects.without("soft_delete", reason="<auto: migrated from all_objects>")',
13
+ # all_objects is a manager; the replacement is a queryset. These need the
14
+ # manager (or would chain a second, conflicting scope lift).
15
+ manager_only=frozenset(
16
+ {"get_queryset", "db_manager", "without", "all_with_deleted", "deleted_only"}
17
+ ),
18
+ )
@@ -0,0 +1,13 @@
1
+ """``objects.all_with_deleted()`` -> ``objects.without("soft_delete", reason=...)``.
2
+
3
+ Changelog entry ``all-with-deleted``; migration note docs/migrations/0.2.md#all_with_deleted.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from karni_cli.upgrades.python import ReplaceMethodCall
9
+
10
+ CODEMOD = ReplaceMethodCall(
11
+ method="all_with_deleted",
12
+ replacement='without("soft_delete", reason="<auto: migrated from all_with_deleted()>")',
13
+ )
@@ -0,0 +1,13 @@
1
+ """``objects.deleted_only()`` -> ``objects.without("soft_delete", reason=...).dead()``.
2
+
3
+ Changelog entry ``deleted-only``; migration note docs/migrations/0.2.md#deleted_only.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from karni_cli.upgrades.python import ReplaceMethodCall
9
+
10
+ CODEMOD = ReplaceMethodCall(
11
+ method="deleted_only",
12
+ replacement='without("soft_delete", reason="<auto: migrated from deleted_only()>").dead()',
13
+ )
@@ -0,0 +1,16 @@
1
+ """Detector: ``BaseViewSet.object_permission_classes`` -> the ``access`` declaration.
2
+
3
+ Not mechanical: an arbitrary permission class has no faithful translation
4
+ into access predicates, so this reports every viewset that sets it and edits
5
+ nothing. Changelog entry ``object-permission-classes``; migration note
6
+ docs/migrations/0.2.md#object_permission_classes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from karni_cli.upgrades.python import FindClassAttribute
12
+
13
+ CODEMOD = FindClassAttribute(
14
+ name="object_permission_classes",
15
+ note="express this rule in the access declaration (Owned, HasRole, Where)",
16
+ )
@@ -0,0 +1,15 @@
1
+ """Detector: ``karni.api.expansion.related_lookups()`` -> ``karni.api.optimizer.optimize()``.
2
+
3
+ Not mechanical: the replacement takes a queryset and a serializer instead
4
+ of expand paths. Changelog entry ``related-lookups``; migration note
5
+ docs/migrations/0.2.md#related_lookups-and-scoped_related_lookups.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from karni_cli.upgrades.python import FindReferences
11
+
12
+ CODEMOD = FindReferences(
13
+ qualified_names=("karni.api.expansion.related_lookups",),
14
+ note="replace with karni.api.optimizer.optimize(queryset, serializer)",
15
+ )
@@ -0,0 +1,15 @@
1
+ """Detector: ``karni.api.expansion.scoped_related_lookups()`` -> ``optimize(..., scope=...)``.
2
+
3
+ Not mechanical: the replacement takes a queryset and a serializer instead
4
+ of expand paths. Changelog entry ``scoped-related-lookups``; migration note
5
+ docs/migrations/0.2.md#related_lookups-and-scoped_related_lookups.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from karni_cli.upgrades.python import FindReferences
11
+
12
+ CODEMOD = FindReferences(
13
+ qualified_names=("karni.api.expansion.scoped_related_lookups",),
14
+ note="replace with karni.api.optimizer.optimize(queryset, serializer, scope=...)",
15
+ )
@@ -0,0 +1,14 @@
1
+ """``karni.managers.SoftDeleteManagerMixin`` -> ``karni.managers.ScopedManagerMixin``.
2
+
3
+ Changelog entry ``soft-delete-manager-mixin``; migration note
4
+ docs/migrations/0.2.md#softdeletemanagermixin.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from karni_cli.upgrades.python import RenameSymbol
10
+
11
+ CODEMOD = RenameSymbol(
12
+ old="karni.managers.SoftDeleteManagerMixin",
13
+ new="karni.managers.ScopedManagerMixin",
14
+ )