shiftory 0.1.0__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.
- shiftory/__init__.py +17 -0
- shiftory/cache/__init__.py +3 -0
- shiftory/cache/store.py +152 -0
- shiftory/classify/__init__.py +3 -0
- shiftory/classify/rules.py +107 -0
- shiftory/cli.py +579 -0
- shiftory/diff/__init__.py +3 -0
- shiftory/diff/identity.py +21 -0
- shiftory/diff/parser.py +704 -0
- shiftory/errors.py +52 -0
- shiftory/evidence/__init__.py +3 -0
- shiftory/evidence/builder.py +706 -0
- shiftory/explain/__init__.py +3 -0
- shiftory/explain/validator.py +922 -0
- shiftory/git/__init__.py +8 -0
- shiftory/git/repository.py +733 -0
- shiftory/git/source.py +344 -0
- shiftory/graph/__init__.py +3 -0
- shiftory/graph/provider.py +1360 -0
- shiftory/graph/worker.py +272 -0
- shiftory/models/__init__.py +25 -0
- shiftory/models/core.py +154 -0
- shiftory/models/json.py +22 -0
- shiftory/py.typed +0 -0
- shiftory/render/__init__.py +9 -0
- shiftory/render/evidence.py +132 -0
- shiftory/render/report.py +116 -0
- shiftory/schemas/__init__.py +16 -0
- shiftory/schemas/evidence-v1.json +288 -0
- shiftory/schemas/explanation-v1.json +162 -0
- shiftory/schemas/report-v1.json +166 -0
- shiftory/skills/__init__.py +0 -0
- shiftory/skills/shiftory/SKILL.md +30 -0
- shiftory/skills/shiftory/__init__.py +0 -0
- shiftory-0.1.0.dist-info/METADATA +572 -0
- shiftory-0.1.0.dist-info/RECORD +39 -0
- shiftory-0.1.0.dist-info/WHEEL +4 -0
- shiftory-0.1.0.dist-info/entry_points.txt +2 -0
- shiftory-0.1.0.dist-info/licenses/LICENSE +201 -0
shiftory/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Shiftory's typed public API."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from shiftory.evidence.builder import analyze
|
|
6
|
+
from shiftory.explain.validator import validate_explanation
|
|
7
|
+
from shiftory.git.repository import resolve_comparison, resolve_repository
|
|
8
|
+
from shiftory.render.report import render_report
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"__version__",
|
|
12
|
+
"analyze",
|
|
13
|
+
"render_report",
|
|
14
|
+
"resolve_comparison",
|
|
15
|
+
"resolve_repository",
|
|
16
|
+
"validate_explanation",
|
|
17
|
+
]
|
shiftory/cache/store.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Repository-scoped, atomic local cache primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from platformdirs import user_cache_path
|
|
16
|
+
|
|
17
|
+
from shiftory.errors import CacheError
|
|
18
|
+
from shiftory.models.json import canonical_json
|
|
19
|
+
|
|
20
|
+
CACHE_SCHEMA = "shiftory.cache/v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_cache_root() -> Path:
|
|
24
|
+
configured = os.environ.get("SHIFTORY_CACHE_DIR")
|
|
25
|
+
return Path(configured).expanduser().resolve() if configured else user_cache_path("shiftory")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def repository_cache_path(repository_id: str, cache_root: Path | None = None) -> Path:
|
|
29
|
+
if len(repository_id) != 64 or any(
|
|
30
|
+
character not in "0123456789abcdef" for character in repository_id
|
|
31
|
+
):
|
|
32
|
+
raise CacheError("Refusing an invalid repository cache identity")
|
|
33
|
+
root = (cache_root or default_cache_root()).expanduser().resolve()
|
|
34
|
+
return root / "repositories" / repository_id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CacheStore:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
repository_id: str,
|
|
41
|
+
*,
|
|
42
|
+
cache_root: Path | None = None,
|
|
43
|
+
enabled: bool = True,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.repository_id = repository_id
|
|
46
|
+
self.cache_root = (cache_root or default_cache_root()).expanduser().resolve()
|
|
47
|
+
self.root = repository_cache_path(repository_id, self.cache_root)
|
|
48
|
+
self.enabled = enabled
|
|
49
|
+
|
|
50
|
+
def ensure(self) -> Path:
|
|
51
|
+
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
52
|
+
with contextlib.suppress(OSError):
|
|
53
|
+
self.root.chmod(0o700)
|
|
54
|
+
return self.root
|
|
55
|
+
|
|
56
|
+
@contextlib.contextmanager
|
|
57
|
+
def lock(self) -> Iterator[None]:
|
|
58
|
+
if not self.enabled:
|
|
59
|
+
yield
|
|
60
|
+
return
|
|
61
|
+
lock_directory = self.cache_root / ".locks"
|
|
62
|
+
lock_directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
63
|
+
lock_path = lock_directory / f"{self.repository_id}.lock"
|
|
64
|
+
with lock_path.open("a+b") as handle:
|
|
65
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
66
|
+
try:
|
|
67
|
+
yield
|
|
68
|
+
finally:
|
|
69
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
70
|
+
|
|
71
|
+
def read_manifest(self, relative: str) -> dict[str, Any] | None:
|
|
72
|
+
if not self.enabled:
|
|
73
|
+
return None
|
|
74
|
+
path = self._entry_path(relative)
|
|
75
|
+
try:
|
|
76
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
return None
|
|
79
|
+
if not isinstance(value, dict) or value.get("schema") != CACHE_SCHEMA:
|
|
80
|
+
return None
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
def atomic_write(self, relative: str, value: dict[str, Any]) -> Path:
|
|
84
|
+
if not self.enabled:
|
|
85
|
+
raise CacheError("Cache is disabled")
|
|
86
|
+
self.ensure()
|
|
87
|
+
path = self._entry_path(relative)
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.partial")
|
|
90
|
+
payload = {"schema": CACHE_SCHEMA, **value}
|
|
91
|
+
try:
|
|
92
|
+
temporary.write_text(canonical_json(payload), encoding="utf-8")
|
|
93
|
+
os.chmod(temporary, 0o600)
|
|
94
|
+
with temporary.open("rb") as handle:
|
|
95
|
+
os.fsync(handle.fileno())
|
|
96
|
+
os.replace(temporary, path)
|
|
97
|
+
self._fsync_directory(path.parent)
|
|
98
|
+
finally:
|
|
99
|
+
temporary.unlink(missing_ok=True)
|
|
100
|
+
return path
|
|
101
|
+
|
|
102
|
+
def status(self) -> dict[str, Any]:
|
|
103
|
+
exists = self.root.is_dir()
|
|
104
|
+
files = (
|
|
105
|
+
sorted(
|
|
106
|
+
str(path.relative_to(self.root))
|
|
107
|
+
for path in self.root.rglob("*")
|
|
108
|
+
if path.is_file() and path.name != ".lock"
|
|
109
|
+
)
|
|
110
|
+
if exists
|
|
111
|
+
else []
|
|
112
|
+
)
|
|
113
|
+
return {"path": str(self.root), "exists": exists, "files": files}
|
|
114
|
+
|
|
115
|
+
def clear(self) -> Path:
|
|
116
|
+
root = self.root.resolve()
|
|
117
|
+
repositories = (self.cache_root / "repositories").resolve()
|
|
118
|
+
if (
|
|
119
|
+
root == self.cache_root
|
|
120
|
+
or root.parent != repositories
|
|
121
|
+
or root.name != self.repository_id
|
|
122
|
+
):
|
|
123
|
+
raise CacheError(f"Refusing to clear unsafe cache path: {root}")
|
|
124
|
+
with self.lock():
|
|
125
|
+
if root.exists():
|
|
126
|
+
shutil.rmtree(root)
|
|
127
|
+
self._fsync_directory(repositories)
|
|
128
|
+
return root
|
|
129
|
+
|
|
130
|
+
def _entry_path(self, relative: str) -> Path:
|
|
131
|
+
candidate = Path(relative)
|
|
132
|
+
if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
|
|
133
|
+
raise CacheError(f"Refusing an unsafe cache entry path: {relative!r}")
|
|
134
|
+
path = (self.root / candidate).resolve()
|
|
135
|
+
try:
|
|
136
|
+
path.relative_to(self.root.resolve())
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise CacheError(f"Refusing an unsafe cache entry path: {relative!r}") from exc
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _fsync_directory(path: Path) -> None:
|
|
143
|
+
try:
|
|
144
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
145
|
+
except OSError:
|
|
146
|
+
return
|
|
147
|
+
try:
|
|
148
|
+
os.fsync(descriptor)
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
finally:
|
|
152
|
+
os.close(descriptor)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Explicit deterministic evidence-organization rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
|
|
7
|
+
from shiftory.models.core import Confidence, FileChange
|
|
8
|
+
|
|
9
|
+
_LOCKFILES = {
|
|
10
|
+
"cargo.lock",
|
|
11
|
+
"package-lock.json",
|
|
12
|
+
"pnpm-lock.yaml",
|
|
13
|
+
"poetry.lock",
|
|
14
|
+
"uv.lock",
|
|
15
|
+
"yarn.lock",
|
|
16
|
+
}
|
|
17
|
+
_DEPENDENCIES = {
|
|
18
|
+
"cargo.toml",
|
|
19
|
+
"package.json",
|
|
20
|
+
"pyproject.toml",
|
|
21
|
+
"requirements.txt",
|
|
22
|
+
"go.mod",
|
|
23
|
+
"go.sum",
|
|
24
|
+
}
|
|
25
|
+
_CONFIG_NAMES = {
|
|
26
|
+
".editorconfig",
|
|
27
|
+
".gitattributes",
|
|
28
|
+
".gitignore",
|
|
29
|
+
"compose.yaml",
|
|
30
|
+
"compose.yml",
|
|
31
|
+
"dockerfile",
|
|
32
|
+
"makefile",
|
|
33
|
+
"tox.ini",
|
|
34
|
+
}
|
|
35
|
+
_SOURCE_SUFFIXES = {
|
|
36
|
+
".c",
|
|
37
|
+
".cc",
|
|
38
|
+
".cpp",
|
|
39
|
+
".go",
|
|
40
|
+
".java",
|
|
41
|
+
".js",
|
|
42
|
+
".jsx",
|
|
43
|
+
".php",
|
|
44
|
+
".py",
|
|
45
|
+
".rb",
|
|
46
|
+
".rs",
|
|
47
|
+
".ts",
|
|
48
|
+
".tsx",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_formatting_only(file: FileChange) -> bool:
|
|
53
|
+
before = [line.content for hunk in file.hunks for line in hunk.lines if line.side == "before"]
|
|
54
|
+
after = [line.content for hunk in file.hunks for line in hunk.lines if line.side == "after"]
|
|
55
|
+
if not before or not after:
|
|
56
|
+
return False
|
|
57
|
+
raw_before = "\n".join(before)
|
|
58
|
+
raw_after = "\n".join(after)
|
|
59
|
+
return raw_before != raw_after and "".join(raw_before.split()) == "".join(raw_after.split())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def classify_file(file: FileChange) -> tuple[str, Confidence]:
|
|
63
|
+
path = PurePosixPath(file.new_path or file.old_path or "")
|
|
64
|
+
name = path.name.lower()
|
|
65
|
+
parts = {part.lower() for part in path.parts}
|
|
66
|
+
suffix = path.suffix.lower()
|
|
67
|
+
kinds = {unit.kind for unit in file.units}
|
|
68
|
+
if "unsupported" in kinds:
|
|
69
|
+
return "unsupported", "extracted"
|
|
70
|
+
if "binary" in kinds:
|
|
71
|
+
return "binary", "extracted"
|
|
72
|
+
if "rename" in kinds:
|
|
73
|
+
return "rename", "extracted"
|
|
74
|
+
if file.status == "added":
|
|
75
|
+
return "added", "extracted"
|
|
76
|
+
if file.status == "deleted":
|
|
77
|
+
return "deleted", "extracted"
|
|
78
|
+
if kinds == {"mode"}:
|
|
79
|
+
return "mode", "extracted"
|
|
80
|
+
if not file.hunks:
|
|
81
|
+
return "structural", "extracted"
|
|
82
|
+
if name in _LOCKFILES or name in _DEPENDENCIES:
|
|
83
|
+
return "dependency", "extracted"
|
|
84
|
+
if "generated" in parts or name.endswith((".min.js", ".min.css")):
|
|
85
|
+
return "generated", "inferred"
|
|
86
|
+
if (
|
|
87
|
+
suffix == ".schema"
|
|
88
|
+
or "schema" in parts
|
|
89
|
+
or ("schema" in name and suffix in {".json", ".yaml", ".yml", ".graphql"})
|
|
90
|
+
):
|
|
91
|
+
return "schema", "inferred"
|
|
92
|
+
if (
|
|
93
|
+
name in _CONFIG_NAMES
|
|
94
|
+
or ".github" in parts
|
|
95
|
+
or suffix in {".ini", ".toml", ".yaml", ".yml"}
|
|
96
|
+
or (name.startswith(".") and name.endswith("rc"))
|
|
97
|
+
):
|
|
98
|
+
return "configuration", "inferred"
|
|
99
|
+
if "test" in parts or "tests" in parts or name.startswith("test_") or ".test." in name:
|
|
100
|
+
return "tests", "inferred"
|
|
101
|
+
if "docs" in parts or suffix in {".md", ".rst", ".adoc"}:
|
|
102
|
+
return "docs", "inferred"
|
|
103
|
+
if _is_formatting_only(file):
|
|
104
|
+
return "formatting", "inferred"
|
|
105
|
+
if suffix in _SOURCE_SUFFIXES:
|
|
106
|
+
return "behavioral", "inferred"
|
|
107
|
+
return "unresolved", "unresolved"
|