karni-cli 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karni_cli/__init__.py +3 -0
- karni_cli/_agent/__init__.py +82 -0
- karni_cli/_agent/__main__.py +44 -0
- karni_cli/_agent/check.py +658 -0
- karni_cli/_agent/history.py +328 -0
- karni_cli/_agent/history_chronicle.py +246 -0
- karni_cli/_agent/overrides.py +194 -0
- karni_cli/_agent/rules.py +673 -0
- karni_cli/_changelog/0.2.0.toml +304 -0
- karni_cli/_changelog/0.3.0.toml +25 -0
- karni_cli/_changelog/__init__.py +248 -0
- karni_cli/_errorcodes/__init__.py +1171 -0
- karni_cli/_errorcodes/__main__.py +34 -0
- karni_cli/_errorcodes/links.py +217 -0
- karni_cli/_templates/project/cookiecutter.json +8 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.dockerignore +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.env.example +85 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/.gitignore +10 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Caddyfile +22 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/Makefile +52 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/README.md +169 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/__init__.py +16 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/asgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/celery.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/settings.py +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/urls.py +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/config/wsgi.py +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/docker-compose.yml +126 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.dockerignore +2 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.jscpd.json +9 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/.npmrc +7 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/Dockerfile.dev +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/eslint.config.js +32 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/index.html +14 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/package.json +43 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/postcss.config.js +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/public/favicon.svg +6 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/App.tsx +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/api/health.ts +21 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/index.css +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/layout.tsx +51 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/main.tsx +11 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/pages/home.tsx +28 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/routes.tsx +24 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/src/vite-env.d.ts +1 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tailwind.config.ts +13 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/tsconfig.json +19 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/frontend/vite.config.ts +18 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/manage.py +23 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/pyproject.toml +31 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/__init__.py +0 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_api_schema.py +17 -0
- karni_cli/_templates/project/{{cookiecutter.project_slug}}/tests/test_smoke.py +11 -0
- karni_cli/adapters.py +373 -0
- karni_cli/agent_adapters.toml +160 -0
- karni_cli/agent_data.py +260 -0
- karni_cli/brand.py +55 -0
- karni_cli/clidoc.py +254 -0
- karni_cli/commands/__init__.py +0 -0
- karni_cli/commands/add.py +144 -0
- karni_cli/commands/beat.py +36 -0
- karni_cli/commands/check.py +101 -0
- karni_cli/commands/config.py +81 -0
- karni_cli/commands/context.py +171 -0
- karni_cli/commands/createsuperuser.py +28 -0
- karni_cli/commands/doctor.py +136 -0
- karni_cli/commands/explain.py +60 -0
- karni_cli/commands/make.py +55 -0
- karni_cli/commands/manage_passthrough.py +28 -0
- karni_cli/commands/mcp.py +20 -0
- karni_cli/commands/migrate.py +23 -0
- karni_cli/commands/new.py +106 -0
- karni_cli/commands/routes.py +20 -0
- karni_cli/commands/rules.py +139 -0
- karni_cli/commands/run_tests.py +38 -0
- karni_cli/commands/schedule.py +40 -0
- karni_cli/commands/seed.py +34 -0
- karni_cli/commands/serve.py +50 -0
- karni_cli/commands/shell.py +28 -0
- karni_cli/commands/sync_roles.py +32 -0
- karni_cli/commands/types.py +72 -0
- karni_cli/commands/upgrade.py +231 -0
- karni_cli/commands/worker.py +63 -0
- karni_cli/config.py +142 -0
- karni_cli/environment.py +29 -0
- karni_cli/errorcodes.py +377 -0
- karni_cli/errordoc.py +227 -0
- karni_cli/generators/__init__.py +67 -0
- karni_cli/generators/apitest.py +206 -0
- karni_cli/generators/app.py +103 -0
- karni_cli/generators/command.py +98 -0
- karni_cli/generators/component.py +115 -0
- karni_cli/generators/core.py +319 -0
- karni_cli/generators/event.py +57 -0
- karni_cli/generators/factory.py +88 -0
- karni_cli/generators/fields.py +343 -0
- karni_cli/generators/introspect.py +263 -0
- karni_cli/generators/listener.py +168 -0
- karni_cli/generators/middleware.py +95 -0
- karni_cli/generators/migration.py +82 -0
- karni_cli/generators/model.py +235 -0
- karni_cli/generators/module.py +255 -0
- karni_cli/generators/naming.py +100 -0
- karni_cli/generators/page.py +373 -0
- karni_cli/generators/pysource.py +304 -0
- karni_cli/generators/schedule.py +174 -0
- karni_cli/generators/seeder.py +86 -0
- karni_cli/generators/serializer.py +165 -0
- karni_cli/generators/service.py +68 -0
- karni_cli/generators/task.py +84 -0
- karni_cli/generators/viewset.py +179 -0
- karni_cli/history.py +59 -0
- karni_cli/local_state.py +73 -0
- karni_cli/main.py +104 -0
- karni_cli/mcp_server.py +103 -0
- karni_cli/project.py +248 -0
- karni_cli/rules_render.py +86 -0
- karni_cli/scaffold.py +197 -0
- karni_cli/settings_edit.py +128 -0
- karni_cli/tips.py +53 -0
- karni_cli/typegen.py +101 -0
- karni_cli/upgrades/__init__.py +28 -0
- karni_cli/upgrades/changelog.py +123 -0
- karni_cli/upgrades/codemod.py +125 -0
- karni_cli/upgrades/engine.py +224 -0
- karni_cli/upgrades/legacy_agent_files.py +138 -0
- karni_cli/upgrades/python.py +670 -0
- karni_cli/upgrades/transforms/rename-export.cjs +96 -0
- karni_cli/upgrades/typescript.py +160 -0
- karni_cli/upgrades/v0_2/__init__.py +1 -0
- karni_cli/upgrades/v0_2/agent_guidance.py +269 -0
- karni_cli/upgrades/v1_0/__init__.py +5 -0
- karni_cli/upgrades/v1_0/all_objects_to_without.py +18 -0
- karni_cli/upgrades/v1_0/all_with_deleted_to_without.py +13 -0
- karni_cli/upgrades/v1_0/deleted_only_to_without_dead.py +13 -0
- karni_cli/upgrades/v1_0/object_permission_classes_to_access.py +16 -0
- karni_cli/upgrades/v1_0/related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/scoped_related_lookups_to_optimize.py +15 -0
- karni_cli/upgrades/v1_0/soft_delete_manager_mixin_to_scoped.py +14 -0
- karni_cli-0.3.1.dist-info/METADATA +191 -0
- karni_cli-0.3.1.dist-info/RECORD +145 -0
- karni_cli-0.3.1.dist-info/WHEEL +4 -0
- karni_cli-0.3.1.dist-info/entry_points.txt +2 -0
- karni_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,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,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
|
+
)
|