raidlock 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.
Files changed (126) hide show
  1. raidlock/__init__.py +11 -0
  2. raidlock/__main__.py +5 -0
  3. raidlock/_python.py +58 -0
  4. raidlock/achievements.py +156 -0
  5. raidlock/approvals.py +237 -0
  6. raidlock/bench.py +286 -0
  7. raidlock/bridge/__init__.py +186 -0
  8. raidlock/budgets.py +178 -0
  9. raidlock/cli.py +2153 -0
  10. raidlock/commands_install.py +114 -0
  11. raidlock/core/__init__.py +26 -0
  12. raidlock/core/advisories.py +16 -0
  13. raidlock/core/billing.py +54 -0
  14. raidlock/core/board.py +2251 -0
  15. raidlock/core/brief.py +111 -0
  16. raidlock/core/clock.py +40 -0
  17. raidlock/core/cost.py +198 -0
  18. raidlock/core/db.py +608 -0
  19. raidlock/core/errors.py +52 -0
  20. raidlock/core/exe.py +6 -0
  21. raidlock/core/hints.py +32 -0
  22. raidlock/core/identity.py +285 -0
  23. raidlock/core/lead.py +758 -0
  24. raidlock/core/lead_env.py +23 -0
  25. raidlock/core/lead_hook.py +42 -0
  26. raidlock/core/leases.py +19 -0
  27. raidlock/core/lock.py +99 -0
  28. raidlock/core/messages.py +98 -0
  29. raidlock/core/migrate.py +579 -0
  30. raidlock/core/model.py +126 -0
  31. raidlock/core/models.py +109 -0
  32. raidlock/core/paths.py +61 -0
  33. raidlock/core/portable.py +53 -0
  34. raidlock/core/receipts.py +73 -0
  35. raidlock/core/registry.py +39 -0
  36. raidlock/core/repos.py +250 -0
  37. raidlock/core/safeio.py +201 -0
  38. raidlock/core/status.py +52 -0
  39. raidlock/core/store.py +107 -0
  40. raidlock/core/tokens.py +77 -0
  41. raidlock/core/transcript.py +208 -0
  42. raidlock/duel.py +371 -0
  43. raidlock/foresight.py +191 -0
  44. raidlock/frames.py +162 -0
  45. raidlock/gitguard.py +753 -0
  46. raidlock/guard.py +239 -0
  47. raidlock/hooks.py +1526 -0
  48. raidlock/infer.py +205 -0
  49. raidlock/limits.py +685 -0
  50. raidlock/lore/__init__.py +22 -0
  51. raidlock/lore/approval.py +145 -0
  52. raidlock/lore/capture.py +146 -0
  53. raidlock/lore/cli.py +109 -0
  54. raidlock/lore/distill.py +17 -0
  55. raidlock/lore/frontmatter.py +56 -0
  56. raidlock/lore/inject.py +127 -0
  57. raidlock/lore/vault.py +264 -0
  58. raidlock/mcp/__init__.py +34 -0
  59. raidlock/mcp/resources.py +72 -0
  60. raidlock/mcp/server.py +184 -0
  61. raidlock/mcp/tools.py +178 -0
  62. raidlock/notify.py +144 -0
  63. raidlock/party.py +239 -0
  64. raidlock/pr.py +575 -0
  65. raidlock/prsync.py +318 -0
  66. raidlock/recipes/README.md +34 -0
  67. raidlock/recipes/__init__.py +105 -0
  68. raidlock/recipes/docs-refresh.md +15 -0
  69. raidlock/recipes/multi-repo-refactor.md +15 -0
  70. raidlock/recipes/security-audit.md +27 -0
  71. raidlock/recipes/upgrade-deps.md +15 -0
  72. raidlock/reputation.py +194 -0
  73. raidlock/resume.py +329 -0
  74. raidlock/resurrect.py +419 -0
  75. raidlock/routing.py +419 -0
  76. raidlock/runners/__init__.py +16 -0
  77. raidlock/runners/base.py +341 -0
  78. raidlock/runners/brief.py +176 -0
  79. raidlock/runners/claude_code.py +121 -0
  80. raidlock/runners/cli.py +110 -0
  81. raidlock/runners/codex.py +114 -0
  82. raidlock/runners/gemini.py +64 -0
  83. raidlock/runners/isolation.py +391 -0
  84. raidlock/runners/policy.py +123 -0
  85. raidlock/runners/runner.py +921 -0
  86. raidlock/runners/shell.py +35 -0
  87. raidlock/runners/verifycache.py +93 -0
  88. raidlock/runners/workspace.py +376 -0
  89. raidlock/scan.py +357 -0
  90. raidlock/snapshot.py +151 -0
  91. raidlock/treasury.py +210 -0
  92. raidlock/tui.py +223 -0
  93. raidlock/web/__init__.py +5 -0
  94. raidlock/web/achievements/view.css +19 -0
  95. raidlock/web/achievements/view.js +58 -0
  96. raidlock/web/bosses/view.css +19 -0
  97. raidlock/web/bosses/view.js +46 -0
  98. raidlock/web/cli.py +90 -0
  99. raidlock/web/data.py +687 -0
  100. raidlock/web/export.py +153 -0
  101. raidlock/web/frames/view.css +25 -0
  102. raidlock/web/frames/view.js +63 -0
  103. raidlock/web/graph/__init__.py +142 -0
  104. raidlock/web/graph/view.css +33 -0
  105. raidlock/web/graph/view.js +136 -0
  106. raidlock/web/raidmap/__init__.py +205 -0
  107. raidlock/web/raidmap/view.css +15 -0
  108. raidlock/web/raidmap/view.js +47 -0
  109. raidlock/web/replay/__init__.py +258 -0
  110. raidlock/web/replay/view.css +13 -0
  111. raidlock/web/replay/view.js +127 -0
  112. raidlock/web/server.py +292 -0
  113. raidlock/web/static/app.css +851 -0
  114. raidlock/web/static/app.js +2102 -0
  115. raidlock/web/static/fonts/OFL.txt +93 -0
  116. raidlock/web/static/fonts/cinzel-600.woff2 +0 -0
  117. raidlock/web/static/fonts/cinzel-700.woff2 +0 -0
  118. raidlock/web/static/icon-32.png +0 -0
  119. raidlock/web/static/icon-raid.svg +1 -0
  120. raidlock/web/static/icon.svg +1 -0
  121. raidlock/web/static/index.html +122 -0
  122. raidlock-0.1.0.dist-info/METADATA +186 -0
  123. raidlock-0.1.0.dist-info/RECORD +126 -0
  124. raidlock-0.1.0.dist-info/WHEEL +4 -0
  125. raidlock-0.1.0.dist-info/entry_points.txt +34 -0
  126. raidlock-0.1.0.dist-info/licenses/LICENSE +21 -0
raidlock/infer.py ADDED
@@ -0,0 +1,205 @@
1
+ """Read-only lock suggestions from tracked paths, recent history and CODEOWNERS."""
2
+
3
+ import fnmatch
4
+ import re
5
+ import subprocess
6
+ from collections import Counter, defaultdict
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from .core import canonical
10
+ from .core.errors import Precondition
11
+ from .core.repos import Resolver
12
+ from .runners.policy import denied
13
+ from .runners.workspace import git_run
14
+
15
+
16
+ def _git(root, *args):
17
+ try:
18
+ result = git_run(root, *args, timeout=30)
19
+ except (OSError, subprocess.TimeoutExpired):
20
+ return ""
21
+ return result.stdout if result.returncode == 0 else ""
22
+
23
+
24
+ def _tokens(text):
25
+ result = set()
26
+ for token in re.findall(r"[\w./\\-]+", text.replace("\\", "/")):
27
+ token = token.rstrip(".")
28
+ while token.startswith("./"):
29
+ token = token[2:]
30
+ if token and ".." not in PurePosixPath(token).parts and not token.startswith("/"):
31
+ result.add(token)
32
+ return result
33
+
34
+
35
+ def _glob(parts, pattern):
36
+ if not pattern:
37
+ return not parts
38
+ if pattern[0] == "**":
39
+ return _glob(parts, pattern[1:]) or bool(parts) and _glob(parts[1:], pattern)
40
+ return bool(parts) and fnmatch.fnmatchcase(parts[0], pattern[0]) and _glob(parts[1:], pattern[1:])
41
+
42
+
43
+ def _matches(pattern, path):
44
+ if "[" in pattern or "]" in pattern:
45
+ return False
46
+ anchored = pattern.startswith("/") or "/" in pattern.rstrip("/")
47
+ directory = pattern.endswith("/")
48
+ pattern = pattern.strip("/")
49
+ if not pattern:
50
+ return False
51
+ parts = path.split("/")
52
+ if anchored:
53
+ ends = range(1, len(parts) + (0 if directory else 1))
54
+ return any(_glob(parts[:end], pattern.split("/")) for end in ends)
55
+ candidates = parts[:-1] if directory else parts
56
+ return any(fnmatch.fnmatchcase(part, pattern) for part in candidates)
57
+
58
+
59
+ def _tracked_file(root, file):
60
+ try:
61
+ canonical(file)
62
+ if denied(file):
63
+ return False
64
+ parts = PurePosixPath(file).parts
65
+ if any(
66
+ part.lower() in (".git", ".board", ".claude", "secret", "secrets")
67
+ or part.lower().startswith(".env")
68
+ or part.lower().endswith(".pem")
69
+ for part in parts
70
+ ) or parts[:2] == (".github", "workflows"):
71
+ return False
72
+ path = Path(root, file)
73
+ root = Path(root).resolve()
74
+ if not path.is_file() or path.is_symlink() or root not in path.resolve().parents:
75
+ return False
76
+ return not any(parent.is_symlink() for parent in path.parents if parent != root and root in parent.parents)
77
+ except (OSError, RuntimeError, Precondition):
78
+ return False
79
+
80
+
81
+ def _owners(root, files):
82
+ for location in (".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"):
83
+ path = Path(root, location)
84
+ if path.is_file() and not path.is_symlink() and Path(root).resolve() in path.resolve().parents:
85
+ try:
86
+ lines = path.read_text(encoding="utf-8").splitlines()
87
+ except (OSError, UnicodeError):
88
+ return {}
89
+ break
90
+ else:
91
+ return {}
92
+ assignments = {}
93
+ for line in lines:
94
+ line = line.strip()
95
+ if not line or line.startswith("#"):
96
+ continue
97
+ if line.startswith("[") or line.startswith("^["):
98
+ continue
99
+ fields = line.split("#", 1)[0].split()
100
+ if not fields or fields[0].startswith("!"):
101
+ continue
102
+ for file in files:
103
+ if _matches(fields[0], file):
104
+ owners = tuple(
105
+ sorted({owner for owner in fields[1:] if re.fullmatch(r"@[A-Za-z0-9_/-]+|[^\s@]+@[^\s@]+", owner)})
106
+ )
107
+ assignments[file] = (owners, ", ".join(owners)) if owners else None
108
+ return assignments
109
+
110
+
111
+ def suggest_locks(board, text, repos=None, limit=5):
112
+ """Return ranked {path, score, reason} dictionaries without changing board state.
113
+
114
+ repos optionally selects repository names from the board's resolver. Scores
115
+ prioritize explicit paths, then co-change frequency, then shared CODEOWNERS owners.
116
+ Bulk commits over 50 paths, protected files and symlinks are excluded. Directory
117
+ collapse never covers additional tracked files; suggestions never mutate the board.
118
+ """
119
+ if not isinstance(text, str):
120
+ raise Precondition("suggestion text must be text")
121
+ if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0:
122
+ raise Precondition("limit must be a non-negative integer")
123
+ if not text.strip() or limit == 0:
124
+ return []
125
+ resolver = Resolver(board.workspace, board.dir)
126
+ names = sorted(resolver.repos) if repos is None else ([repos] if isinstance(repos, str) else list(repos))
127
+ if any(not isinstance(name, str) for name in names):
128
+ raise Precondition("repository names must be text")
129
+ unknown = set(names) - resolver.repos.keys()
130
+ if unknown:
131
+ raise Precondition("unknown repositories: " + ", ".join(sorted(unknown)))
132
+ tokens = _tokens(text)
133
+ suggestions = []
134
+ for name in sorted(set(names)):
135
+ root = resolver.repos[name]
136
+ tracked_files = set(filter(None, _git(root, "ls-files", "-z").split("\0")))
137
+ files = {file for file in tracked_files if _tracked_file(root, file)}
138
+ scores, reasons = {}, {}
139
+ for file in sorted(files):
140
+ path = PurePosixPath(file)
141
+ aliases = {file, name + "/" + file, path.name}
142
+ directories = {str(parent) for parent in path.parents if str(parent) != "."}
143
+ if tokens & aliases:
144
+ scores[file], reasons[file] = 100, "Path mentioned in text"
145
+ elif tokens & (directories | {name + "/" + d for d in directories}):
146
+ scores[file], reasons[file] = 90, "Inside directory mentioned in text"
147
+ seeds = set(scores)
148
+ if not seeds:
149
+ continue
150
+ counts = Counter()
151
+ history = _git(root, "log", "-200", "--format=%x00", "--name-only", "-z", "--no-renames")
152
+ for commit in history.split("\0\0"):
153
+ raw_changed = {item.lstrip("\n") for item in commit.split("\0")} - {""}
154
+ changed = raw_changed & files
155
+ if changed & seeds and len(raw_changed) <= 50:
156
+ counts.update(changed - seeds)
157
+ for file, count in counts.items():
158
+ scores[file] = 40 + min(count, 39)
159
+ reasons[file] = f"Changed with mentioned files in {count} recent commit(s)"
160
+ owners = _owners(root, files)
161
+ groups = {owners[file] for file in seeds if owners.get(file)}
162
+ for file in sorted(files - seeds):
163
+ if owners.get(file) in groups:
164
+ scores[file] = scores.get(file, 0) + 10
165
+ reason = "Same CODEOWNERS ownership group"
166
+ reasons[file] = reasons[file] + "; " + reason if file in reasons else reason
167
+ siblings = defaultdict(list)
168
+ for file in scores:
169
+ siblings[str(PurePosixPath(file).parent)].append(file)
170
+ for directory, children in sorted(siblings.items()):
171
+ named = directory in tokens or name + "/" + directory in tokens
172
+ if len(children) < 3 or directory == "." or ("/" not in directory and not named):
173
+ continue
174
+ if any(file.startswith(directory + "/") and file not in children for file in tracked_files):
175
+ continue
176
+ scores[directory] = max(scores[child] for child in children)
177
+ reasons[directory] = (
178
+ f"{len(children)} sibling files; " + reasons[max(children, key=lambda child: (scores[child], child))]
179
+ )
180
+ for child in children:
181
+ del scores[child]
182
+ for file, score in scores.items():
183
+ try:
184
+ path = canonical(name + "/" + file)
185
+ except Precondition:
186
+ continue
187
+ suggestions.append({"path": path, "score": score, "reason": reasons[file]})
188
+ return sorted(suggestions, key=lambda item: (-item["score"], item["path"]))[:limit]
189
+
190
+
191
+ def register_cli(add_command):
192
+ """Register with the raidlock.commands extension interface."""
193
+
194
+ def setup(parser):
195
+ parser.add_argument("text")
196
+ parser.add_argument("--repos", nargs="+")
197
+ parser.add_argument("--limit", type=int, default=5)
198
+
199
+ def handler(ctx, args):
200
+ suggestions = suggest_locks(ctx.board, args.text, args.repos, args.limit)
201
+ return {"suggestions": suggestions}, "\n".join(
202
+ item["path"] + ": " + item["reason"] for item in suggestions
203
+ ) or "No lock suggestions."
204
+
205
+ add_command("suggest-locks", "suggest narrow lock paths from card text", setup, handler)