agentopolis 0.6.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.
- agentopolis/__init__.py +0 -0
- agentopolis/cli.py +72 -0
- agentopolis/hooks.py +69 -0
- agentopolis/nation.py +129 -0
- agentopolis/seed.py +263 -0
- agentopolis/server.py +171 -0
- agentopolis/static/city-live.js +97 -0
- agentopolis/static/city-render.js +781 -0
- agentopolis/static/city-scape.js +275 -0
- agentopolis/static/hall-tiers.js +83 -0
- agentopolis/static/hall.js +210 -0
- agentopolis/static/hotel.js +232 -0
- agentopolis/static/index.html +241 -0
- agentopolis/static/nation.js +747 -0
- agentopolis/static/render.js +175 -0
- agentopolis/zone.py +70 -0
- agentopolis-0.6.0.dist-info/METADATA +73 -0
- agentopolis-0.6.0.dist-info/RECORD +21 -0
- agentopolis-0.6.0.dist-info/WHEEL +4 -0
- agentopolis-0.6.0.dist-info/entry_points.txt +2 -0
- agentopolis-0.6.0.dist-info/licenses/LICENSE +21 -0
agentopolis/__init__.py
ADDED
|
File without changes
|
agentopolis/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""agentopolis — run from any git repo to watch agents build its city."""
|
|
2
|
+
import argparse
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import webbrowser
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from . import hooks, nation, server
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def free_port(port: int) -> None:
|
|
14
|
+
holders = lambda: subprocess.run(["lsof", "-ti", f":{port}"],
|
|
15
|
+
capture_output=True, text=True).stdout.split()
|
|
16
|
+
pids = holders()
|
|
17
|
+
if not pids:
|
|
18
|
+
return
|
|
19
|
+
print(f"freeing port {port} (pid {', '.join(pids)})")
|
|
20
|
+
for sig in ("-TERM", "-KILL"):
|
|
21
|
+
subprocess.run(["kill", sig, *pids], capture_output=True)
|
|
22
|
+
for _ in range(20):
|
|
23
|
+
if not holders():
|
|
24
|
+
return
|
|
25
|
+
time.sleep(.1)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main() -> None:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="agentopolis",
|
|
31
|
+
description="Habbo-style visualization of Claude Code agents building your repo as a city.")
|
|
32
|
+
parser.add_argument("command", nargs="?", default="serve", choices=["serve", "attach", "detach"],
|
|
33
|
+
help="serve the city (default), or attach/detach Claude Code hooks")
|
|
34
|
+
parser.add_argument("--repo", default=".", help="git repo to map as the city (default: cwd)")
|
|
35
|
+
parser.add_argument("--zone", help="zoning manifest (default: <repo>/.agentopolis.json, else auto-zoned)")
|
|
36
|
+
parser.add_argument("--root", help="map every git repo under this dir as a nation of cities")
|
|
37
|
+
parser.add_argument("--port", type=int, default=4242)
|
|
38
|
+
parser.add_argument("--no-open", action="store_true", help="don't open the city in a browser")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
if args.command == "attach":
|
|
42
|
+
hooks.attach(args.port)
|
|
43
|
+
elif args.command == "detach":
|
|
44
|
+
hooks.detach()
|
|
45
|
+
else:
|
|
46
|
+
free_port(args.port)
|
|
47
|
+
server.configure(args.repo, args.zone)
|
|
48
|
+
root = args.root or (nation.is_mother(args.repo) and args.repo) # a mother repo is a nation
|
|
49
|
+
if root:
|
|
50
|
+
server.configure_nation(root, None)
|
|
51
|
+
where = f"nation: {root}" if root else f"repo: {args.repo}"
|
|
52
|
+
url = f"http://localhost:{args.port}"
|
|
53
|
+
print(f"Agentopolis {'Nation' if root else 'City'} on {url} ({where})")
|
|
54
|
+
if not hooks.is_attached():
|
|
55
|
+
print("tip: run `agentopolis attach` so Claude Code sessions report to the city")
|
|
56
|
+
if not args.no_open:
|
|
57
|
+
opener = threading.Timer(1, lambda: webbrowser.open(url))
|
|
58
|
+
opener.daemon = True
|
|
59
|
+
opener.start()
|
|
60
|
+
# SSE streams watch runner.should_exit so open browsers don't block Ctrl+C;
|
|
61
|
+
# the graceful-shutdown timeout is the backstop for any other slow request
|
|
62
|
+
runner = uvicorn.Server(uvicorn.Config(server.app, port=args.port,
|
|
63
|
+
log_level="warning", timeout_graceful_shutdown=2))
|
|
64
|
+
server.runner = runner
|
|
65
|
+
try:
|
|
66
|
+
runner.run()
|
|
67
|
+
except KeyboardInterrupt: # uvicorn re-raises the Ctrl+C after shutdown
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
agentopolis/hooks.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Attach/detach the city to Claude Code via ~/.claude/settings.json hooks.
|
|
2
|
+
|
|
3
|
+
Hooks fire-and-forget to localhost with a 1s timeout, so Claude Code is
|
|
4
|
+
never blocked when the city isn't running.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
10
|
+
BACKUP = SETTINGS.with_suffix(".json.agentopolis.bak")
|
|
11
|
+
EVENTS = [
|
|
12
|
+
"SessionStart",
|
|
13
|
+
"UserPromptSubmit",
|
|
14
|
+
"Notification",
|
|
15
|
+
"PreToolUse",
|
|
16
|
+
"PostToolUse",
|
|
17
|
+
"Stop",
|
|
18
|
+
"SubagentStop",
|
|
19
|
+
"SessionEnd",
|
|
20
|
+
]
|
|
21
|
+
def command(port: int) -> str:
|
|
22
|
+
return (
|
|
23
|
+
f"curl -sf -m 1 -X POST http://localhost:{port}/hook "
|
|
24
|
+
"-H 'Content-Type: application/json' --data-binary @- "
|
|
25
|
+
">/dev/null 2>&1 || true"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_hotel_hook(entries: list) -> bool:
|
|
30
|
+
return any(
|
|
31
|
+
"/hook" in hook.get("command", "") and "curl" in hook.get("command", "")
|
|
32
|
+
for entry in entries
|
|
33
|
+
for hook in entry.get("hooks", [])
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_attached() -> bool:
|
|
38
|
+
if not SETTINGS.exists():
|
|
39
|
+
return False
|
|
40
|
+
hooks = json.loads(SETTINGS.read_text()).get("hooks", {})
|
|
41
|
+
return any(has_hotel_hook(entries) for entries in hooks.values())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def attach(port: int = 4242) -> None:
|
|
45
|
+
settings = json.loads(SETTINGS.read_text()) if SETTINGS.exists() else {}
|
|
46
|
+
BACKUP.write_text(json.dumps(settings, indent=2))
|
|
47
|
+
hooks = settings.setdefault("hooks", {})
|
|
48
|
+
added = 0
|
|
49
|
+
for event in EVENTS:
|
|
50
|
+
entries = hooks.setdefault(event, [])
|
|
51
|
+
if not has_hotel_hook(entries):
|
|
52
|
+
entries.append({"matcher": "", "hooks": [{"type": "command", "command": command(port)}]})
|
|
53
|
+
added += 1
|
|
54
|
+
SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
|
|
55
|
+
print(f"attached {added} events ({SETTINGS}, backup at {BACKUP.name})")
|
|
56
|
+
print("new Claude Code sessions will now report to the city")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detach() -> None:
|
|
60
|
+
if not SETTINGS.exists():
|
|
61
|
+
return
|
|
62
|
+
settings = json.loads(SETTINGS.read_text())
|
|
63
|
+
hooks = settings.get("hooks", {})
|
|
64
|
+
for event in list(hooks):
|
|
65
|
+
hooks[event] = [e for e in hooks[event] if not has_hotel_hook([e])]
|
|
66
|
+
if not hooks[event]:
|
|
67
|
+
del hooks[event]
|
|
68
|
+
SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
|
|
69
|
+
print("detached — agentopolis hooks removed")
|
agentopolis/nation.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Nation view: many repos as one map, grouped into states.
|
|
2
|
+
|
|
3
|
+
discover_repos() finds git repos one level under a root. summarize() reads
|
|
4
|
+
cheap git stats per repo (no file-content reads, so it scales to dozens).
|
|
5
|
+
load_nation() folds in an optional .agentopolis-nation.json that names the
|
|
6
|
+
states (repo clusters).
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from collections import Counter
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .seed import git, tracked
|
|
14
|
+
|
|
15
|
+
PALETTE = ["#16a085", "#5b8dd9", "#c0395b", "#d4a953", "#8e5d9f",
|
|
16
|
+
"#b5651d", "#2980b9", "#4a6b5c", "#c9b78a", "#5c6b73"]
|
|
17
|
+
|
|
18
|
+
FAMILY_NAMES = {
|
|
19
|
+
"backend": "Logic Highlands",
|
|
20
|
+
"frontend": "Interface Coast",
|
|
21
|
+
"infra": "Iron Province",
|
|
22
|
+
"data": "The Mines",
|
|
23
|
+
"docs": "The Scriptorium",
|
|
24
|
+
"neutral": "The Hinterlands",
|
|
25
|
+
}
|
|
26
|
+
FAMILY_COLOR = {
|
|
27
|
+
"backend": "#2980b9",
|
|
28
|
+
"frontend": "#27ae60",
|
|
29
|
+
"infra": "#7f8c8d",
|
|
30
|
+
"data": "#d4a953",
|
|
31
|
+
"docs": "#c9b78a",
|
|
32
|
+
"neutral": "#7d6b8a",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# extension → archetype family; picks the family with the most tracked files (assets ignored)
|
|
36
|
+
FAMILY_EXT = {
|
|
37
|
+
"backend": {"py", "go", "rb", "rs", "java", "php", "cs", "kt", "scala", "ex", "clj"},
|
|
38
|
+
"frontend": {"js", "ts", "jsx", "tsx", "vue", "svelte"},
|
|
39
|
+
"docs": {"md", "rst", "txt", "adoc"},
|
|
40
|
+
"infra": {"tf", "yml", "yaml", "sh", "toml", "dockerfile"},
|
|
41
|
+
"data": {"sql", "csv", "parquet", "ipynb"},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def family_of(exts: Counter) -> str:
|
|
46
|
+
best, score = "neutral", 0
|
|
47
|
+
for fam, group in FAMILY_EXT.items():
|
|
48
|
+
n = sum(exts[e] for e in group)
|
|
49
|
+
if n > score:
|
|
50
|
+
best, score = fam, n
|
|
51
|
+
return best
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def discover_repos(root: str) -> list[str]:
|
|
55
|
+
out = []
|
|
56
|
+
for entry in sorted(Path(root).iterdir()):
|
|
57
|
+
if entry.is_symlink() or not entry.is_dir():
|
|
58
|
+
continue
|
|
59
|
+
if (entry / ".git").exists():
|
|
60
|
+
out.append(entry.name)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_mother(repo: str) -> bool:
|
|
65
|
+
"""A git repo that nests ≥2 git repos of its own — a metropolis, not a city."""
|
|
66
|
+
return (Path(repo) / ".git").exists() and len(discover_repos(repo)) >= 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def summarize(repo: str, exclude: set | None = None) -> dict:
|
|
70
|
+
files = tracked(repo, exclude)
|
|
71
|
+
last = git(repo, "log", "-1", "--format=%ct").strip()
|
|
72
|
+
commits = git(repo, "rev-list", "--count", "HEAD").strip()
|
|
73
|
+
exts = Counter(Path(f).suffix.lstrip(".").lower() for f in files if "." in f)
|
|
74
|
+
age = round((time.time() - int(last)) / 86400) if last else 9999
|
|
75
|
+
low = [f.lower() for f in files]
|
|
76
|
+
return {"files": len(files), "commits": int(commits or 0), "age_days": age,
|
|
77
|
+
"lang": (exts.most_common(1) or [("", 0)])[0][0],
|
|
78
|
+
"family": family_of(exts),
|
|
79
|
+
"hasInfra": any(f.endswith(".tf") or f.rsplit("/", 1)[-1].startswith("dockerfile")
|
|
80
|
+
or ".github/workflows/" in f for f in low),
|
|
81
|
+
"hasFrontend": any(f.endswith((".html", ".css", ".jsx", ".tsx", ".scss", ".vue")) for f in low),
|
|
82
|
+
"hasDocs": any(f.endswith(".md") for f in low)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
CAPITAL = "." # capital city id == the mother repo's own path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def mother_nation(root: str) -> dict:
|
|
89
|
+
"""A mother repo as a one-state metropolis: a capital (its own glue) + each subrepo a city."""
|
|
90
|
+
subs = discover_repos(root)
|
|
91
|
+
name = Path(root).resolve().name
|
|
92
|
+
cities = [{"repo": CAPITAL, "name": "⊙ capital", "state": name, **summarize(root, set(subs))}]
|
|
93
|
+
cities += [{"repo": r, "state": name, **summarize(str(Path(root) / r))} for r in subs]
|
|
94
|
+
states = [{"id": name, "name": name, "color": PALETTE[0], "repos": [CAPITAL, *subs]}]
|
|
95
|
+
return {"root": name, "states": states, "cities": cities}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def load_nation(root: str, manifest_path: str | None) -> dict:
|
|
99
|
+
path = Path(manifest_path) if manifest_path else Path(root) / ".agentopolis-nation.json"
|
|
100
|
+
if not path.exists() and is_mother(root): # an explicit manifest wins; a bare mother is a metropolis
|
|
101
|
+
return mother_nation(root)
|
|
102
|
+
repos = discover_repos(root)
|
|
103
|
+
man = json.loads(path.read_text()) if path.exists() else {}
|
|
104
|
+
|
|
105
|
+
summaries = {r: summarize(str(Path(root) / r)) for r in repos}
|
|
106
|
+
|
|
107
|
+
state_of, states = {}, []
|
|
108
|
+
for i, st in enumerate(man.get("states", [])):
|
|
109
|
+
members = [r for r in st["repos"] if r in repos]
|
|
110
|
+
if not members:
|
|
111
|
+
continue
|
|
112
|
+
for r in members:
|
|
113
|
+
state_of[r] = st["id"]
|
|
114
|
+
states.append({"id": st["id"], "name": st.get("name", st["id"]),
|
|
115
|
+
"color": st.get("color", PALETTE[i % len(PALETTE)]), "repos": members})
|
|
116
|
+
|
|
117
|
+
by_family: dict[str, list[str]] = {}
|
|
118
|
+
for r in repos:
|
|
119
|
+
if r not in state_of:
|
|
120
|
+
by_family.setdefault(summaries[r]["family"], []).append(r)
|
|
121
|
+
for fam, members in sorted(by_family.items()):
|
|
122
|
+
sid = f"auto_{fam}"
|
|
123
|
+
for r in members:
|
|
124
|
+
state_of[r] = sid
|
|
125
|
+
states.append({"id": sid, "name": FAMILY_NAMES[fam],
|
|
126
|
+
"color": FAMILY_COLOR[fam], "repos": members})
|
|
127
|
+
|
|
128
|
+
cities = [{"repo": r, "state": state_of[r], **summaries[r]} for r in repos]
|
|
129
|
+
return {"root": Path(root).resolve().name, "states": states, "cities": cities}
|
agentopolis/seed.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Build a city snapshot from a git repo + zoning manifest.
|
|
2
|
+
|
|
3
|
+
Per building: loc (mass), commits + age (attention), centrality (how many
|
|
4
|
+
other components it has co-committed with). A component may set "group": N
|
|
5
|
+
to aggregate files into one building per N-segment path prefix.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from collections import Counter
|
|
12
|
+
from fnmatch import fnmatch
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
CLASS = re.compile(r"^\s*(export |abstract |public |final )*(class|interface|struct|trait)\b")
|
|
16
|
+
IMPORT = re.compile(r"^\s*(import|from|require|use|#include)\b|=\s*require\(")
|
|
17
|
+
TODO = re.compile(r"TODO|FIXME|HACK")
|
|
18
|
+
FROM = re.compile(r"^\s*FROM\s+(\S+)", re.I)
|
|
19
|
+
FROM_AS = re.compile(r"\bAS\s+(\S+)", re.I)
|
|
20
|
+
|
|
21
|
+
# A dependency name (substring match) marks the district that declares it as a cloud client.
|
|
22
|
+
DEP_CLOUDS = [
|
|
23
|
+
("AWS", ("boto3", "botocore", "aioboto3", "aws-sdk", "aws-cdk")),
|
|
24
|
+
("GCP", ("google-cloud", "google-api-python-client", "firebase")),
|
|
25
|
+
("Azure", ("azure-", "@azure/")),
|
|
26
|
+
("Cloudflare", ("cloudflare", "wrangler")),
|
|
27
|
+
("Supabase", ("supabase",)),
|
|
28
|
+
("Vercel", ("@vercel",)),
|
|
29
|
+
("Stripe", ("stripe",)),
|
|
30
|
+
("OpenAI", ("openai",)),
|
|
31
|
+
("Anthropic", ("anthropic",)),
|
|
32
|
+
("Clerk", ("clerk",)),
|
|
33
|
+
("Twilio", ("twilio",)),
|
|
34
|
+
("Sentry", ("sentry",)),
|
|
35
|
+
("Datadog", ("datadog", "ddtrace")),
|
|
36
|
+
]
|
|
37
|
+
# A file (glob on full path or basename) marks the district that owns it as a cloud user.
|
|
38
|
+
FILE_CLOUDS = [
|
|
39
|
+
("GitHub Actions", ("*.github/workflows/*",)),
|
|
40
|
+
("Terraform", ("*.tf", "*.tf.json")),
|
|
41
|
+
("Vercel", ("vercel.json",)),
|
|
42
|
+
("Fly.io", ("fly.toml",)),
|
|
43
|
+
("Render", ("render.yaml", "render.yml")),
|
|
44
|
+
("Heroku", ("procfile",)),
|
|
45
|
+
("Netlify", ("netlify.toml",)),
|
|
46
|
+
("Cloudflare", ("wrangler.toml", "wrangler.json", "wrangler.jsonc")),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def git(repo: str, *args: str) -> str:
|
|
51
|
+
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True).stdout
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tracked(repo: str, exclude: set | None = None) -> list[str]:
|
|
55
|
+
files = git(repo, "ls-files").splitlines()
|
|
56
|
+
if exclude: # drop nested-repo dirs when seeding a mother's capital
|
|
57
|
+
files = [f for f in files if f.split("/", 1)[0] not in exclude]
|
|
58
|
+
return files
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def component_of(path: str, components: list) -> str | None:
|
|
62
|
+
for c in components:
|
|
63
|
+
if any(fnmatch(path, g) for g in c["globs"]):
|
|
64
|
+
return c["id"]
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def scan(path: Path) -> tuple[int, int, int, int]:
|
|
69
|
+
loc = classes = imports = todos = 0
|
|
70
|
+
try:
|
|
71
|
+
for line in open(path, errors="ignore"):
|
|
72
|
+
loc += 1
|
|
73
|
+
if CLASS.match(line):
|
|
74
|
+
classes += 1
|
|
75
|
+
elif IMPORT.search(line):
|
|
76
|
+
imports += 1
|
|
77
|
+
if TODO.search(line):
|
|
78
|
+
todos += 1
|
|
79
|
+
except OSError:
|
|
80
|
+
pass
|
|
81
|
+
return loc, classes, imports, todos
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_deps(repo: str, files: list[str]) -> list[str]:
|
|
85
|
+
import tomllib
|
|
86
|
+
found = set()
|
|
87
|
+
for f in files:
|
|
88
|
+
name = f.rsplit("/", 1)[-1].lower()
|
|
89
|
+
if name not in ("package.json", "pyproject.toml") and \
|
|
90
|
+
not (name.startswith("requirements") and name.endswith(".txt")):
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
text = Path(repo, f).read_text(errors="ignore")
|
|
94
|
+
if name == "package.json":
|
|
95
|
+
data = json.loads(text)
|
|
96
|
+
found |= set(data.get("dependencies", {})) | set(data.get("devDependencies", {}))
|
|
97
|
+
elif name == "pyproject.toml":
|
|
98
|
+
reqs = tomllib.loads(text).get("project", {}).get("dependencies", [])
|
|
99
|
+
found |= {re.split(r"[<>=~!\[; ]", r, 1)[0] for r in reqs}
|
|
100
|
+
else:
|
|
101
|
+
found |= {re.split(r"[<>=~!\[; ]", li.strip(), 1)[0]
|
|
102
|
+
for li in text.splitlines() if li.strip() and li.lstrip()[0] not in "#-"}
|
|
103
|
+
except (ValueError, OSError): # fixture/vendored manifests may be malformed
|
|
104
|
+
continue
|
|
105
|
+
return sorted(found)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def detect_clouds(repo: str, comp: dict[str, str | None]) -> list[dict]:
|
|
109
|
+
"""Fingerprint cloud providers from deps + config files; tether each to its district."""
|
|
110
|
+
real = lambda c: c and c not in ("civic", "commons")
|
|
111
|
+
files = Counter(c for c in comp.values() if c)
|
|
112
|
+
default = next((c for c, _ in files.most_common() if real(c)),
|
|
113
|
+
files.most_common(1)[0][0] if files else None)
|
|
114
|
+
votes: dict[str, Counter] = {}
|
|
115
|
+
for f, c in comp.items():
|
|
116
|
+
name = f.rsplit("/", 1)[-1].lower()
|
|
117
|
+
is_manifest = name in ("package.json", "pyproject.toml") or \
|
|
118
|
+
(name.startswith("requirements") and name.endswith(".txt"))
|
|
119
|
+
for dep in parse_deps(repo, [f]) if is_manifest else []:
|
|
120
|
+
for label, fps in DEP_CLOUDS:
|
|
121
|
+
if any(fp in dep for fp in fps):
|
|
122
|
+
votes.setdefault(label, Counter())[c or default] += 1
|
|
123
|
+
for label, pats in FILE_CLOUDS:
|
|
124
|
+
if any(fnmatch(f, p) or fnmatch(name, p) for p in pats):
|
|
125
|
+
votes.setdefault(label, Counter())[c or default] += 1
|
|
126
|
+
clouds = []
|
|
127
|
+
for label, counter in votes.items():
|
|
128
|
+
ranked = [cid for cid, _ in counter.most_common() if cid]
|
|
129
|
+
tether = next((c for c in ranked if real(c)), (ranked or [default])[0])
|
|
130
|
+
if tether:
|
|
131
|
+
clouds.append({"name": label, "tether": tether})
|
|
132
|
+
return clouds
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def compose_services(repo: str, f: str) -> list[str]:
|
|
136
|
+
"""Service keys under a compose file's top-level `services:` block (light indent parse, no yaml dep)."""
|
|
137
|
+
try:
|
|
138
|
+
lines = Path(repo, f).read_text(errors="ignore").splitlines()
|
|
139
|
+
except OSError:
|
|
140
|
+
return []
|
|
141
|
+
out: list[str] = []
|
|
142
|
+
in_services = indent = None
|
|
143
|
+
for line in lines:
|
|
144
|
+
body = line.strip()
|
|
145
|
+
if not body or body.startswith("#"):
|
|
146
|
+
continue
|
|
147
|
+
col = len(line) - len(line.lstrip())
|
|
148
|
+
if in_services is None:
|
|
149
|
+
in_services = body == "services:" or None
|
|
150
|
+
elif col == 0: # dedented out of the services block
|
|
151
|
+
break
|
|
152
|
+
else:
|
|
153
|
+
indent = col if indent is None else indent
|
|
154
|
+
if col == indent and body.endswith(":"):
|
|
155
|
+
out.append(body[:-1].strip())
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def dockerfile_images(repo: str, f: str) -> list[str]:
|
|
160
|
+
out: list[str] = []
|
|
161
|
+
stages: set[str] = set() # multi-stage names so `FROM builder` isn't a base image
|
|
162
|
+
try:
|
|
163
|
+
for line in open(Path(repo, f), errors="ignore"):
|
|
164
|
+
m = FROM.match(line)
|
|
165
|
+
if not m:
|
|
166
|
+
continue
|
|
167
|
+
img = m.group(1).rsplit("/", 1)[-1].split("@")[0]
|
|
168
|
+
if img.lower() != "scratch" and img not in stages and img not in out:
|
|
169
|
+
out.append(img)
|
|
170
|
+
stage = FROM_AS.search(line)
|
|
171
|
+
if stage:
|
|
172
|
+
stages.add(stage.group(1))
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def docker_manifest(repo: str, files) -> list[dict]:
|
|
179
|
+
"""Each compose/Dockerfile as a harbor ship; its items are the services / base images it runs."""
|
|
180
|
+
arts = []
|
|
181
|
+
for f in files:
|
|
182
|
+
name = f.rsplit("/", 1)[-1].lower()
|
|
183
|
+
if "compose.y" in name:
|
|
184
|
+
arts.append({"path": f, "kind": "compose", "items": compose_services(repo, f)})
|
|
185
|
+
elif "dockerfile" in name:
|
|
186
|
+
arts.append({"path": f, "kind": "image", "items": dockerfile_images(repo, f)})
|
|
187
|
+
return arts
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def dead_files(repo: str, alive: set[str]) -> list[dict]:
|
|
191
|
+
died: dict[str, int] = {} # path -> deletion commit time (newest deletion wins)
|
|
192
|
+
ts = 0
|
|
193
|
+
for line in git(repo, "log", "-M", "--diff-filter=D", "--name-only", "--pretty=%ct").splitlines():
|
|
194
|
+
if line.isdigit():
|
|
195
|
+
ts = int(line)
|
|
196
|
+
elif line and line not in alive and line not in died:
|
|
197
|
+
died[line] = ts
|
|
198
|
+
if len(died) == 24: # cemetery plot capacity
|
|
199
|
+
break
|
|
200
|
+
born: dict[str, int] = {} # earliest add: newest-first log, last write is oldest
|
|
201
|
+
ts = 0
|
|
202
|
+
for line in git(repo, "log", "--diff-filter=A", "--name-only", "--pretty=%ct").splitlines():
|
|
203
|
+
if line.isdigit():
|
|
204
|
+
ts = int(line)
|
|
205
|
+
elif line in died:
|
|
206
|
+
born[line] = ts
|
|
207
|
+
return [{"path": p, "born": born.get(p), "died": d} for p, d in died.items()]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def seed(repo: str, zone: dict, exclude: set | None = None) -> dict:
|
|
211
|
+
comp = {f: component_of(f, zone["components"]) for f in tracked(repo, exclude)}
|
|
212
|
+
files = [f for f, c in comp.items() if c]
|
|
213
|
+
group = {c["id"]: c.get("group") for c in zone["components"]}
|
|
214
|
+
|
|
215
|
+
commits = dict.fromkeys(files, 0)
|
|
216
|
+
last = dict.fromkeys(files, 0)
|
|
217
|
+
cocomp = {f: set() for f in files}
|
|
218
|
+
timestamp = 0
|
|
219
|
+
touched: list[str] = []
|
|
220
|
+
for line in git(repo, "log", "--name-only", "--pretty=%ct").splitlines() + [""]:
|
|
221
|
+
if line.isdigit() or not line: # commit boundary: flush previous
|
|
222
|
+
comps = {comp[f] for f in touched}
|
|
223
|
+
for f in touched:
|
|
224
|
+
commits[f] += 1
|
|
225
|
+
last[f] = max(last[f], timestamp)
|
|
226
|
+
if len(touched) <= 15: # mega-commits aren't coupling signal
|
|
227
|
+
cocomp[f] |= comps - {comp[f]}
|
|
228
|
+
touched = []
|
|
229
|
+
if line.isdigit():
|
|
230
|
+
timestamp = int(line)
|
|
231
|
+
elif line in commits:
|
|
232
|
+
touched.append(line)
|
|
233
|
+
|
|
234
|
+
buildings: dict[str, dict] = {}
|
|
235
|
+
exts: dict[str, Counter] = {}
|
|
236
|
+
now = time.time()
|
|
237
|
+
for f in sorted(files):
|
|
238
|
+
depth = group[comp[f]]
|
|
239
|
+
key = "/".join(f.split("/")[:depth]) if depth else f
|
|
240
|
+
b = buildings.setdefault(key, {"path": key, "component": comp[f], "loc": 0,
|
|
241
|
+
"commits": 0, "centrality": 0, "age_days": 9999, "files": 0,
|
|
242
|
+
"classes": 0, "imports": 0, "todos": 0})
|
|
243
|
+
loc, classes, imports, todos = scan(Path(repo, f))
|
|
244
|
+
b["loc"] += loc
|
|
245
|
+
b["classes"] += classes
|
|
246
|
+
b["imports"] += imports
|
|
247
|
+
b["todos"] += todos
|
|
248
|
+
b["commits"] += commits[f]
|
|
249
|
+
b["centrality"] = max(b["centrality"], len(cocomp[f]))
|
|
250
|
+
if last[f]:
|
|
251
|
+
b["age_days"] = min(b["age_days"], round((now - last[f]) / 86400))
|
|
252
|
+
b["files"] += 1
|
|
253
|
+
exts.setdefault(key, Counter())[Path(f).suffix.lstrip(".").lower()] += 1
|
|
254
|
+
for key, b in buildings.items():
|
|
255
|
+
b["ext"] = exts[key].most_common(1)[0][0]
|
|
256
|
+
|
|
257
|
+
docker = docker_manifest(repo, comp)
|
|
258
|
+
named = {c["name"].lower() for c in zone.get("clouds", [])} # manual clouds win
|
|
259
|
+
zone["clouds"] = zone.get("clouds", []) + \
|
|
260
|
+
[c for c in detect_clouds(repo, comp) if c["name"].lower() not in named]
|
|
261
|
+
return {"zone": zone, "buildings": list(buildings.values()),
|
|
262
|
+
"deps": parse_deps(repo, list(comp)), "docker": docker,
|
|
263
|
+
"dead": dead_files(repo, set(comp))}
|