aletheore 0.2.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.
aletheore/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,13 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class AgentAdapter(ABC):
5
+ name: str = "unnamed"
6
+
7
+ @abstractmethod
8
+ def is_available(self) -> bool:
9
+ raise NotImplementedError
10
+
11
+ @abstractmethod
12
+ def invoke(self, instruction: str, cwd: str) -> str:
13
+ raise NotImplementedError
@@ -0,0 +1,38 @@
1
+ import shutil
2
+ import subprocess
3
+
4
+ from aletheore.adapters.base import AgentAdapter
5
+
6
+ INVOCATION_TIMEOUT_SECONDS = 600
7
+
8
+
9
+ class AdapterInvocationError(Exception):
10
+ pass
11
+
12
+
13
+ class ClaudeCodeAdapter(AgentAdapter):
14
+ name = "claude"
15
+
16
+ def is_available(self) -> bool:
17
+ return shutil.which("claude") is not None
18
+
19
+ def invoke(self, instruction: str, cwd: str) -> str:
20
+ try:
21
+ result = subprocess.run(
22
+ ["claude", "-p", instruction],
23
+ cwd=cwd,
24
+ capture_output=True,
25
+ text=True,
26
+ timeout=INVOCATION_TIMEOUT_SECONDS,
27
+ )
28
+ except subprocess.TimeoutExpired as exc:
29
+ raise AdapterInvocationError(
30
+ f"claude invocation timed out after {INVOCATION_TIMEOUT_SECONDS}s"
31
+ ) from exc
32
+
33
+ if result.returncode != 0:
34
+ raise AdapterInvocationError(
35
+ f"claude invocation failed (exit {result.returncode}): {result.stderr}"
36
+ )
37
+
38
+ return result.stdout
@@ -0,0 +1,137 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import networkx as nx
5
+ from networkx.algorithms.community import greedy_modularity_communities
6
+
7
+
8
+ def load_architecture_config(repo_path: Path) -> dict | None:
9
+ config_file = repo_path / ".aletheore.json"
10
+ if not config_file.exists():
11
+ return None
12
+ try:
13
+ data = json.loads(config_file.read_text(encoding="utf-8", errors="ignore"))
14
+ except json.JSONDecodeError:
15
+ return None
16
+ if not isinstance(data, dict):
17
+ return None
18
+
19
+ layer_markers = data.get("layer_markers", {})
20
+ if not isinstance(layer_markers, dict):
21
+ layer_markers = {}
22
+
23
+ cluster_resolution = data.get("cluster_resolution", 1.0)
24
+ if not isinstance(cluster_resolution, (int, float)) or isinstance(cluster_resolution, bool):
25
+ cluster_resolution = 1.0
26
+
27
+ return {"layer_markers": layer_markers, "cluster_resolution": float(cluster_resolution)}
28
+
29
+
30
+ LAYER_FOLDER_MARKERS = {
31
+ "domain": 0,
32
+ "core": 0,
33
+ "entities": 0,
34
+ "application": 1,
35
+ "services": 1,
36
+ "use_cases": 1,
37
+ "infrastructure": 2,
38
+ "infra": 2,
39
+ "adapters": 2,
40
+ "api": 2,
41
+ "routers": 2,
42
+ "web": 2,
43
+ "controllers": 2,
44
+ }
45
+
46
+
47
+ def build_clusters(dependency_graph: dict, resolution: float = 1.0) -> tuple[list[dict], list[dict]]:
48
+ graph = nx.Graph()
49
+ graph.add_nodes_from(dependency_graph["nodes"])
50
+ graph.add_edges_from(dependency_graph["edges"])
51
+
52
+ communities = list(greedy_modularity_communities(graph, resolution=resolution))
53
+
54
+ cluster_of: dict[str, int] = {}
55
+ clusters = []
56
+ for cluster_id, community in enumerate(communities):
57
+ modules = sorted(community)
58
+ for module in modules:
59
+ cluster_of[module] = cluster_id
60
+ clusters.append({"id": cluster_id, "modules": modules, "internal_edges": 0})
61
+
62
+ for a, b in dependency_graph["edges"]:
63
+ if cluster_of.get(a) is not None and cluster_of.get(a) == cluster_of.get(b):
64
+ clusters[cluster_of[a]]["internal_edges"] += 1
65
+
66
+ cross_pairs: dict[tuple[int, int], list[list[str]]] = {}
67
+ for a, b in dependency_graph["edges"]:
68
+ ca, cb = cluster_of.get(a), cluster_of.get(b)
69
+ if ca is None or cb is None or ca == cb:
70
+ continue
71
+ cross_pairs.setdefault((ca, cb), []).append([a, b])
72
+
73
+ cross_cluster_edges = [
74
+ {"from_cluster": ca, "to_cluster": cb, "count": len(edges), "edges": edges}
75
+ for (ca, cb), edges in sorted(cross_pairs.items())
76
+ ]
77
+
78
+ return clusters, cross_cluster_edges
79
+
80
+
81
+ def _classify_module_rank(rel_path: str, markers: dict[str, int]) -> tuple[str, int] | None:
82
+ parts = Path(rel_path).parts
83
+ for part in parts[:-1]:
84
+ if part in markers:
85
+ return part, markers[part]
86
+ return None
87
+
88
+
89
+ def detect_layer_violations(
90
+ dependency_graph: dict, custom_markers: dict[str, int] | None = None
91
+ ) -> dict:
92
+ effective_markers = {**LAYER_FOLDER_MARKERS, **(custom_markers or {})}
93
+
94
+ classifications: dict[str, tuple[str, int]] = {}
95
+ for node in dependency_graph["nodes"]:
96
+ result = _classify_module_rank(node, effective_markers)
97
+ if result is not None:
98
+ classifications[node] = result
99
+
100
+ distinct_ranks = {rank for _, rank in classifications.values()}
101
+
102
+ layer_folders: dict[str, set[str]] = {}
103
+ for node, (name, _rank) in classifications.items():
104
+ parts = Path(node).parts
105
+ idx = parts.index(name)
106
+ folder = str(Path(*parts[: idx + 1]))
107
+ layer_folders.setdefault(name, set()).add(folder)
108
+
109
+ layers = [
110
+ {"name": name, "rank": effective_markers[name], "folders": sorted(folders)}
111
+ for name, folders in sorted(layer_folders.items())
112
+ ]
113
+
114
+ custom_markers_contributed = bool(custom_markers) and any(
115
+ name in custom_markers for name in layer_folders
116
+ )
117
+ if len(distinct_ranks) < 2 and not custom_markers_contributed:
118
+ return {"convention_detected": False, "layers": [], "violations": []}
119
+
120
+ violations = []
121
+ for from_node, to_node in dependency_graph["edges"]:
122
+ from_info = classifications.get(from_node)
123
+ to_info = classifications.get(to_node)
124
+ if from_info is None or to_info is None:
125
+ continue
126
+ from_name, from_rank = from_info
127
+ to_name, to_rank = to_info
128
+ if from_rank < to_rank:
129
+ violations.append(
130
+ {
131
+ "from": from_node,
132
+ "to": to_node,
133
+ "reason": f"inner layer '{from_name}' imports outer layer '{to_name}'",
134
+ }
135
+ )
136
+
137
+ return {"convention_detected": True, "layers": layers, "violations": violations}
aletheore/cli.py ADDED
@@ -0,0 +1,417 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ import webbrowser
5
+ from pathlib import Path
6
+
7
+ import uvicorn
8
+
9
+ from aletheore.adapters.claude_code import AdapterInvocationError, ClaudeCodeAdapter
10
+ from aletheore.dashboard import build_app
11
+ from aletheore.evidence import scan_repository, write_evidence
12
+ from aletheore.healthcheck import run_healthcheck, save_healthcheck
13
+ from aletheore.history import compute_diff, list_snapshots, save_snapshot
14
+ from aletheore.mcp_server import build_server
15
+ from aletheore.query import (
16
+ BranchNotFoundInEvidenceError,
17
+ ModuleNotFoundInEvidenceError,
18
+ QUERY_FUNCTIONS,
19
+ )
20
+ from aletheore.report import (
21
+ AmbiguousAdapterError,
22
+ NoAdapterAvailableError,
23
+ run_reasoning_phase,
24
+ select_adapter,
25
+ )
26
+
27
+ KNOWN_ADAPTERS = [ClaudeCodeAdapter()]
28
+
29
+ MANUAL_DIR = str(Path(__file__).resolve().parent.parent / "manual")
30
+
31
+ SPONSOR_NOTE = """
32
+ ┌───────────────────────────────────────────────────────────┐
33
+ │ Aletheore is 100% open-source, local, and free. │
34
+ │ No accounts, no tracking — nothing leaves this machine. │
35
+ │ │
36
+ │ If it saved you time, consider supporting development: │
37
+ │ https://github.com/sponsors/ArihantK15 │
38
+ └───────────────────────────────────────────────────────────┘
39
+ """
40
+
41
+
42
+ def _scan(
43
+ repo_path: str,
44
+ check_vulnerabilities: bool,
45
+ scan_git_history: bool,
46
+ check_licenses: bool = True,
47
+ map_endpoints: bool = True,
48
+ ) -> tuple[int, dict, Path]:
49
+ repo = Path(repo_path).resolve()
50
+ print(f"Scanning {repo}...")
51
+ evidence = scan_repository(
52
+ repo,
53
+ check_vulnerabilities=check_vulnerabilities,
54
+ scan_git_history=scan_git_history,
55
+ check_licenses=check_licenses,
56
+ map_endpoints=map_endpoints,
57
+ )
58
+ evidence_path = write_evidence(evidence, repo)
59
+ print(f"Evidence written to {evidence_path}")
60
+ snapshot_path = save_snapshot(evidence, repo)
61
+ print(f"Snapshot saved to {snapshot_path}")
62
+ return 0, evidence, evidence_path
63
+
64
+
65
+ def _audit(
66
+ repo_path: str,
67
+ forced_agent: str | None,
68
+ check_vulnerabilities: bool,
69
+ scan_git_history: bool,
70
+ check_licenses: bool = True,
71
+ map_endpoints: bool = True,
72
+ ) -> int:
73
+ _exit_code, _evidence, evidence_path = _scan(
74
+ repo_path, check_vulnerabilities, scan_git_history, check_licenses, map_endpoints
75
+ )
76
+ repo = Path(repo_path).resolve()
77
+
78
+ try:
79
+ adapter = select_adapter(
80
+ KNOWN_ADAPTERS, forced_name=forced_agent, interactive=sys.stdin.isatty()
81
+ )
82
+ except (NoAdapterAvailableError, AmbiguousAdapterError) as exc:
83
+ print(f"error: {exc}")
84
+ print(f"Evidence is still available at {evidence_path} for manual use.")
85
+ return 1
86
+
87
+ print(f"Running audit with {adapter.name}...")
88
+ try:
89
+ report_path = run_reasoning_phase(adapter, repo_path=str(repo), manual_dir=MANUAL_DIR)
90
+ except AdapterInvocationError as exc:
91
+ print(f"error: {exc}")
92
+ print(f"Evidence is still available at {evidence_path} for manual use.")
93
+ return 1
94
+
95
+ print(f"Audit report written to {report_path}")
96
+ print(SPONSOR_NOTE)
97
+ return 0
98
+
99
+
100
+ def _query_changes(repo_path: str, full: bool) -> int:
101
+ repo = Path(repo_path).resolve()
102
+ snapshots = list_snapshots(repo)
103
+
104
+ if len(snapshots) < 2:
105
+ print("no prior snapshot to compare against - run 'aletheore scan' again later to compare")
106
+ return 0
107
+
108
+ try:
109
+ old = json.loads(snapshots[-2].read_text())
110
+ except json.JSONDecodeError:
111
+ print(f"error: most recent snapshot is unreadable ({snapshots[-2]})")
112
+ return 1
113
+
114
+ new = json.loads(snapshots[-1].read_text())
115
+ diff = compute_diff(old, new, full=full)
116
+ print(json.dumps(diff, indent=2))
117
+ return 0
118
+
119
+
120
+ def _query(kind: str, target: str | None, repo_path: str, full: bool = False) -> int:
121
+ if kind == "changes":
122
+ return _query_changes(repo_path, full)
123
+
124
+ repo = Path(repo_path).resolve()
125
+ evidence_path = repo / ".aletheore" / "evidence.json"
126
+ if not evidence_path.exists():
127
+ print(f"error: no evidence found at {evidence_path}")
128
+ print(f"Run 'aletheore scan {repo}' first.")
129
+ return 1
130
+
131
+ func, requires_target = QUERY_FUNCTIONS[kind]
132
+ if requires_target and target is None:
133
+ print(f"error: query type '{kind}' requires a target argument")
134
+ return 1
135
+
136
+ evidence = json.loads(evidence_path.read_text())
137
+ try:
138
+ result = func(evidence, target)
139
+ except (ModuleNotFoundInEvidenceError, BranchNotFoundInEvidenceError) as exc:
140
+ print(f"error: {exc}")
141
+ return 1
142
+
143
+ print(json.dumps(result, indent=2))
144
+ return 0
145
+
146
+
147
+ def _diff(
148
+ old_path: str,
149
+ new_path: str,
150
+ full: bool,
151
+ fail_on_new_secrets: bool,
152
+ fail_on_new_vulnerabilities: bool = False,
153
+ fail_on_new_layer_violations: bool = False,
154
+ ) -> int:
155
+ old_file = Path(old_path)
156
+ new_file = Path(new_path)
157
+
158
+ if not old_file.exists():
159
+ print(f"error: evidence file not found: {old_file}")
160
+ return 1
161
+ if not new_file.exists():
162
+ print(f"error: evidence file not found: {new_file}")
163
+ return 1
164
+
165
+ try:
166
+ old = json.loads(old_file.read_text())
167
+ except json.JSONDecodeError:
168
+ print(f"error: {old_file} is not valid JSON")
169
+ return 1
170
+ try:
171
+ new = json.loads(new_file.read_text())
172
+ except json.JSONDecodeError:
173
+ print(f"error: {new_file} is not valid JSON")
174
+ return 1
175
+
176
+ diff = compute_diff(old, new, full=full)
177
+ print(json.dumps(diff, indent=2))
178
+
179
+ if fail_on_new_secrets or fail_on_new_vulnerabilities or fail_on_new_layer_violations:
180
+ curated = diff if not full else compute_diff(old, new, full=False)
181
+ should_fail = False
182
+
183
+ if fail_on_new_secrets:
184
+ new_real_secrets = [
185
+ f
186
+ for f in curated["secrets"]["new"]
187
+ if not f.get("likely_placeholder", False) and not f.get("accepted", False)
188
+ ]
189
+ new_real_history_secrets = [
190
+ f
191
+ for f in curated["history_secrets"]["new"]
192
+ if not f.get("likely_placeholder", False) and not f.get("accepted", False)
193
+ ]
194
+ should_fail = should_fail or bool(new_real_secrets or new_real_history_secrets)
195
+
196
+ if fail_on_new_vulnerabilities:
197
+ should_fail = should_fail or bool(curated["vulnerabilities"]["new"])
198
+
199
+ if fail_on_new_layer_violations:
200
+ should_fail = should_fail or bool(curated["layer_violations"]["new"])
201
+
202
+ if should_fail:
203
+ return 1
204
+
205
+ return 0
206
+
207
+
208
+ def _healthcheck(repo_path: str, base_url: str) -> int:
209
+ repo = Path(repo_path).resolve()
210
+ evidence_path = repo / ".aletheore" / "evidence.json"
211
+ if not evidence_path.exists():
212
+ print(f"error: no evidence found at {evidence_path}")
213
+ print(f"Run 'aletheore scan {repo}' first.")
214
+ return 1
215
+
216
+ evidence = json.loads(evidence_path.read_text())
217
+ endpoints = evidence["repository"].get("api_endpoints", {}).get("endpoints", [])
218
+ result = run_healthcheck(endpoints, base_url)
219
+ save_healthcheck(result, repo)
220
+
221
+ for entry in result["results"]:
222
+ method = entry.get("method") or "?"
223
+ if entry.get("skipped"):
224
+ print(f"{method:6} {entry['path']:40} SKIPPED ({entry['reason']})")
225
+ else:
226
+ status = entry["status_code"] if entry["reachable"] else "UNREACHABLE"
227
+ note = f" ({entry['note']})" if entry.get("note") else ""
228
+ print(f"{method:6} {entry['path']:40} {status} {entry['latency_ms']}ms{note}")
229
+
230
+ return 0
231
+
232
+
233
+ def _mcp(repo_path: str) -> int:
234
+ repo = Path(repo_path).resolve()
235
+ server = build_server(repo)
236
+ server.run(transport="stdio")
237
+ return 0
238
+
239
+
240
+ def _dashboard(repo_path: str, port: int) -> int:
241
+ repo = Path(repo_path).resolve()
242
+ app = build_app(repo)
243
+ url = f"http://127.0.0.1:{port}"
244
+ print(f"Dashboard running at {url}")
245
+ webbrowser.open(url)
246
+ uvicorn.run(app, host="127.0.0.1", port=port)
247
+ return 0
248
+
249
+
250
+ def main() -> int:
251
+ parser = argparse.ArgumentParser(prog="aletheore")
252
+ subparsers = parser.add_subparsers(dest="command", required=True)
253
+
254
+ audit_parser = subparsers.add_parser("audit", help="audit a repository")
255
+ audit_parser.add_argument("path", nargs="?", default=".")
256
+ audit_parser.add_argument("--agent", default=None, help="force a specific agent adapter by name")
257
+ audit_parser.add_argument(
258
+ "--no-check-vulnerabilities",
259
+ dest="check_vulnerabilities",
260
+ action="store_false",
261
+ default=True,
262
+ help="skip the OSV.dev dependency-vulnerability check (on by default)",
263
+ )
264
+ audit_parser.add_argument(
265
+ "--no-scan-git-history",
266
+ dest="scan_git_history",
267
+ action="store_false",
268
+ default=True,
269
+ help="skip walking git history for secrets (on by default)",
270
+ )
271
+ audit_parser.add_argument(
272
+ "--no-check-licenses",
273
+ dest="check_licenses",
274
+ action="store_false",
275
+ default=True,
276
+ help="skip the dependency-license check (on by default)",
277
+ )
278
+ audit_parser.add_argument(
279
+ "--no-map-endpoints",
280
+ dest="map_endpoints",
281
+ action="store_false",
282
+ default=True,
283
+ help="skip static API endpoint mapping (on by default)",
284
+ )
285
+
286
+ scan_parser = subparsers.add_parser("scan", help="run only the deterministic scan phase")
287
+ scan_parser.add_argument("path", nargs="?", default=".")
288
+ scan_parser.add_argument(
289
+ "--no-check-vulnerabilities",
290
+ dest="check_vulnerabilities",
291
+ action="store_false",
292
+ default=True,
293
+ help="skip the OSV.dev dependency-vulnerability check (on by default)",
294
+ )
295
+ scan_parser.add_argument(
296
+ "--no-scan-git-history",
297
+ dest="scan_git_history",
298
+ action="store_false",
299
+ default=True,
300
+ help="skip walking git history for secrets (on by default)",
301
+ )
302
+ scan_parser.add_argument(
303
+ "--no-check-licenses",
304
+ dest="check_licenses",
305
+ action="store_false",
306
+ default=True,
307
+ help="skip the dependency-license check (on by default)",
308
+ )
309
+ scan_parser.add_argument(
310
+ "--no-map-endpoints",
311
+ dest="map_endpoints",
312
+ action="store_false",
313
+ default=True,
314
+ help="skip static API endpoint mapping (on by default)",
315
+ )
316
+
317
+ query_parser = subparsers.add_parser("query", help="query an existing evidence.json")
318
+ query_parser.add_argument("kind", choices=list(QUERY_FUNCTIONS.keys()) + ["changes"])
319
+ query_parser.add_argument("target", nargs="?", default=None)
320
+ query_parser.add_argument("--path", dest="repo_path", default=".")
321
+ query_parser.add_argument(
322
+ "--full",
323
+ action="store_true",
324
+ default=False,
325
+ help="show the full raw diff instead of the curated summary (only applies to 'changes')",
326
+ )
327
+
328
+ diff_parser = subparsers.add_parser("diff", help="compare two evidence.json files")
329
+ diff_parser.add_argument("old", help="path to the baseline evidence.json")
330
+ diff_parser.add_argument("new", help="path to the comparison evidence.json")
331
+ diff_parser.add_argument(
332
+ "--full",
333
+ action="store_true",
334
+ default=False,
335
+ help="show the full raw diff instead of the curated summary",
336
+ )
337
+ diff_parser.add_argument(
338
+ "--fail-on-new-secrets",
339
+ dest="fail_on_new_secrets",
340
+ action="store_true",
341
+ default=False,
342
+ help="exit 1 if a new real (non-placeholder) secret finding appears",
343
+ )
344
+ diff_parser.add_argument(
345
+ "--fail-on-new-vulnerabilities",
346
+ dest="fail_on_new_vulnerabilities",
347
+ action="store_true",
348
+ default=False,
349
+ help="exit 1 if a new dependency vulnerability finding appears",
350
+ )
351
+ diff_parser.add_argument(
352
+ "--fail-on-new-layer-violations",
353
+ dest="fail_on_new_layer_violations",
354
+ action="store_true",
355
+ default=False,
356
+ help="exit 1 if a new layer-convention violation appears",
357
+ )
358
+
359
+ mcp_parser = subparsers.add_parser("mcp", help="run an MCP server scoped to a repository")
360
+ mcp_parser.add_argument("path", nargs="?", default=".")
361
+
362
+ dashboard_parser = subparsers.add_parser(
363
+ "dashboard", help="run a live local dashboard scoped to a repository"
364
+ )
365
+ dashboard_parser.add_argument("path", nargs="?", default=".")
366
+ dashboard_parser.add_argument("--port", type=int, default=8420)
367
+
368
+ healthcheck_parser = subparsers.add_parser(
369
+ "healthcheck", help="GET-only live health check of mapped API endpoints"
370
+ )
371
+ healthcheck_parser.add_argument("path", nargs="?", default=".")
372
+ healthcheck_parser.add_argument("--base-url", required=True, dest="base_url")
373
+
374
+ args = parser.parse_args()
375
+
376
+ if args.command == "audit":
377
+ return _audit(
378
+ args.path,
379
+ args.agent,
380
+ args.check_vulnerabilities,
381
+ args.scan_git_history,
382
+ args.check_licenses,
383
+ args.map_endpoints,
384
+ )
385
+ if args.command == "scan":
386
+ exit_code, _evidence, _evidence_path = _scan(
387
+ args.path,
388
+ args.check_vulnerabilities,
389
+ args.scan_git_history,
390
+ args.check_licenses,
391
+ args.map_endpoints,
392
+ )
393
+ return exit_code
394
+ if args.command == "query":
395
+ return _query(args.kind, args.target, args.repo_path, args.full)
396
+ if args.command == "diff":
397
+ return _diff(
398
+ args.old,
399
+ args.new,
400
+ args.full,
401
+ args.fail_on_new_secrets,
402
+ args.fail_on_new_vulnerabilities,
403
+ args.fail_on_new_layer_violations,
404
+ )
405
+ if args.command == "mcp":
406
+ return _mcp(args.path)
407
+ if args.command == "dashboard":
408
+ return _dashboard(args.path, args.port)
409
+ if args.command == "healthcheck":
410
+ return _healthcheck(args.path, args.base_url)
411
+
412
+ parser.print_help()
413
+ return 1
414
+
415
+
416
+ if __name__ == "__main__":
417
+ sys.exit(main())