Updated on 2026-08-14
This commit is contained in:
parent
5b27f5191e
commit
67e847a672
7 changed files with 2454 additions and 1 deletions
277
.claude/skills/navigation-graph/scripts/build_graph.py
Normal file
277
.claude/skills/navigation-graph/scripts/build_graph.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_graph.py — scan the live codebase and refresh the managed regions of
|
||||
.claude/docs/navigation-graph.md (the source of truth for module-connectivity.html).
|
||||
|
||||
What it extracts from code:
|
||||
- features/domain/data Gradle project dependencies -> area graph + module graph
|
||||
- AppRoute screens + every `AppRoute.X` reference -> screen navigation graph
|
||||
|
||||
What it preserves:
|
||||
- all hand-written prose ABOVE the <!-- NAVGRAPH:BEGIN --> marker
|
||||
- the curated CONFIG block (groups / owners / teams) inside the managed region
|
||||
|
||||
Run from anywhere inside the repo: python3 build_graph.py
|
||||
Then render the HTML: python3 render_html.py
|
||||
"""
|
||||
import os, re, json, sys
|
||||
from collections import defaultdict
|
||||
|
||||
# ---------------------------------------------------------------- paths
|
||||
def find_root(start):
|
||||
d = os.path.abspath(start)
|
||||
while d != os.path.dirname(d):
|
||||
if os.path.exists(os.path.join(d, "settings.gradle.kts")):
|
||||
return d
|
||||
d = os.path.dirname(d)
|
||||
sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).")
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = find_root(os.getcwd())
|
||||
SKILL = os.path.dirname(SCRIPT_DIR)
|
||||
DOCS = os.path.join(ROOT, ".claude", "docs")
|
||||
MD = os.path.join(DOCS, "navigation-graph.md")
|
||||
SEED = os.path.join(SKILL, "assets", "config.seed.json")
|
||||
APPROUTE = os.path.join(ROOT, "common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt")
|
||||
|
||||
# ---------------------------------------------------------------- managed-region helpers
|
||||
BEGIN = "<!-- NAVGRAPH:BEGIN — managed by the navigation-graph skill. Edit only the CONFIG json; the rest is generated from code. -->"
|
||||
END = "<!-- NAVGRAPH:END -->"
|
||||
|
||||
def block(text, key):
|
||||
"""Return the JSON string inside the <!-- NAVGRAPH:key:BEGIN/END --> markers, or None."""
|
||||
m = re.search(r"<!-- NAVGRAPH:%s:BEGIN -->\s*```json\s*(.*?)\s*```\s*<!-- NAVGRAPH:%s:END -->"
|
||||
% (re.escape(key), re.escape(key)), text, re.S)
|
||||
return m.group(1) if m else None
|
||||
|
||||
def wrap(key, payload):
|
||||
return f"<!-- NAVGRAPH:{key}:BEGIN -->\n```json\n{payload}\n```\n<!-- NAVGRAPH:{key}:END -->"
|
||||
|
||||
# ---------------------------------------------------------------- gradle dependency scan
|
||||
def camel_to_kebab(s): return re.sub(r'(?<!^)(?=[A-Z])', '-', s).lower()
|
||||
def accessor_to_path(acc): return ':' + ':'.join(camel_to_kebab(p) for p in acc.split('.'))
|
||||
def dir_to_path(d): return ':' + os.path.relpath(d, ROOT).replace(os.sep, ':')
|
||||
|
||||
PROJ_RE = re.compile(r'(api|implementation|testImplementation|androidTestImplementation|'
|
||||
r'compileOnly|kapt|ksp|debugImplementation)\s*\(\s*projects\.([A-Za-z0-9_.]+)\s*\)')
|
||||
|
||||
def scan_modules():
|
||||
modules = {}
|
||||
edges = []
|
||||
for dp, _, fns in os.walk(ROOT):
|
||||
if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue
|
||||
if 'build.gradle.kts' not in fns: continue
|
||||
if dp == ROOT: continue
|
||||
modules[dir_to_path(dp)] = dp
|
||||
for path, dp in modules.items():
|
||||
txt = open(os.path.join(dp, 'build.gradle.kts'), encoding='utf-8', errors='ignore').read()
|
||||
for m in PROJ_RE.finditer(txt):
|
||||
edges.append((path, accessor_to_path(m.group(2)), m.group(1)))
|
||||
return modules, edges
|
||||
|
||||
SCOPED_LAYERS = ('features', 'domain', 'data')
|
||||
def is_scoped(m): return any(m.startswith(':' + layer) for layer in SCOPED_LAYERS)
|
||||
def area_of(m):
|
||||
p = m.split(':')[1:]
|
||||
return f"{p[0]}:{p[1]}" if len(p) > 1 and p[0] in SCOPED_LAYERS else None
|
||||
|
||||
def build_area_graph(modules, edges):
|
||||
agg = defaultdict(lambda: {"w": 0, "api": False})
|
||||
for s, d, c in edges:
|
||||
if not (is_scoped(s) and is_scoped(d)): continue
|
||||
sa, da = area_of(s), area_of(d)
|
||||
if not sa or not da or sa == da: continue
|
||||
agg[(sa, da)]["w"] += 1
|
||||
if c == 'api': agg[(sa, da)]["api"] = True
|
||||
indeg, outdeg = defaultdict(int), defaultdict(int)
|
||||
for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1
|
||||
nodes = sorted({n for e in agg for n in e})
|
||||
N = [[n, n.split(':')[1], n.split(':')[0], indeg[n], outdeg[n]] for n in nodes]
|
||||
E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()]
|
||||
return {"n": N, "e": E}
|
||||
|
||||
def build_module_graph(modules, edges):
|
||||
agg = defaultdict(lambda: {"w": 0, "api": False})
|
||||
for s, d, c in edges:
|
||||
if not (is_scoped(s) and is_scoped(d)) or s == d: continue
|
||||
agg[(s, d)]["w"] += 1
|
||||
if c == 'api': agg[(s, d)]["api"] = True
|
||||
indeg, outdeg = defaultdict(int), defaultdict(int)
|
||||
for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1
|
||||
conn = {n for e in agg for n in e}
|
||||
N = [[m, ':'.join(m.split(':')[2:]), m.split(':')[1], indeg[m], outdeg[m]]
|
||||
for m in sorted(conn)]
|
||||
E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()]
|
||||
return {"n": N, "e": E}
|
||||
|
||||
# ---------------------------------------------------------------- AppRoute scan
|
||||
REF_RE = re.compile(r'AppRoute\.([A-Z][A-Za-z0-9]+)')
|
||||
DECL_RE = re.compile(r'^ (?:data )?(?:object|class) (\w+)', re.M)
|
||||
|
||||
def read_kotlin_string(text, i):
|
||||
"""text[i] must be '"'. Return (content, index_after_closing_quote), handling \\"
|
||||
escapes and ${...} template expressions (nested braces/strings copied verbatim) so a
|
||||
path with string templates isn't truncated at the first inner quote."""
|
||||
out, j = [], i + 1
|
||||
while j < len(text):
|
||||
c = text[j]
|
||||
if c == '\\':
|
||||
out.append(text[j:j + 2]); j += 2; continue
|
||||
if c == '"':
|
||||
return ''.join(out), j + 1
|
||||
if c == '$' and j + 1 < len(text) and text[j + 1] == '{':
|
||||
out.append('${'); j += 2; depth = 1
|
||||
while j < len(text) and depth > 0:
|
||||
ck = text[j]
|
||||
if ck == '"':
|
||||
s, j = read_kotlin_string(text, j); out.append('"' + s + '"'); continue
|
||||
if ck == '{': depth += 1
|
||||
elif ck == '}': depth -= 1
|
||||
if depth > 0: out.append(ck)
|
||||
j += 1
|
||||
out.append('}'); continue
|
||||
out.append(c); j += 1
|
||||
return ''.join(out), j
|
||||
|
||||
def extract_path(block):
|
||||
"""First string literal of the `path = …` argument within one screen's source block.
|
||||
Block-scoped (so multi-line `AppRoute(` blocks resolve) and template-aware (so paths
|
||||
aren't truncated); for a non-literal RHS (e.g. `path = when {…}`) it takes the first
|
||||
branch literal. Returns None when the block has no `path =`."""
|
||||
m = re.search(r'\bpath\s*=\s*', block)
|
||||
if not m: return None
|
||||
i = m.end()
|
||||
while i < len(block) and block[i] in ' \t\r\n': i += 1
|
||||
if i < len(block) and block[i] == '"':
|
||||
return read_kotlin_string(block, i)[0]
|
||||
q = block.find('"', i)
|
||||
return read_kotlin_string(block, q)[0] if q != -1 else None
|
||||
|
||||
def scan_routes():
|
||||
src = open(APPROUTE, encoding='utf-8').read()
|
||||
decls = [(m.group(1), m.start()) for m in DECL_RE.finditer(src)]
|
||||
screens = {name for name, _ in decls}
|
||||
paths = {}
|
||||
for idx, (name, start) in enumerate(decls):
|
||||
end = decls[idx + 1][1] if idx + 1 < len(decls) else len(src)
|
||||
p = extract_path(src[start:end])
|
||||
if p is not None: paths.setdefault(name, p)
|
||||
usage = defaultdict(lambda: defaultdict(int))
|
||||
nav = defaultdict(int)
|
||||
def area(fp):
|
||||
parts = os.path.relpath(fp, ROOT).split(os.sep)
|
||||
top = parts[0]
|
||||
return f"{top}:{parts[1]}" if top in ('features','domain','data','core','common','libs') and len(parts) > 1 else top
|
||||
for dp, _, fns in os.walk(ROOT):
|
||||
if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue
|
||||
for fn in fns:
|
||||
if not fn.endswith('.kt'): continue
|
||||
fp = os.path.join(dp, fn)
|
||||
if os.path.samefile(fp, APPROUTE) if os.path.exists(APPROUTE) else False: continue
|
||||
try: txt = open(fp, encoding='utf-8', errors='ignore').read()
|
||||
except Exception: continue
|
||||
if 'AppRoute.' not in txt: continue
|
||||
a = area(fp)
|
||||
for m in REF_RE.finditer(txt):
|
||||
if m.group(1) in screens: usage[m.group(1)][a] += 1
|
||||
for nm in re.finditer(r'\b(push|replaceCurrent|replaceAll|popTo)\s*\(', txt):
|
||||
r2 = REF_RE.search(txt[nm.end():nm.end()+160])
|
||||
if r2 and r2.group(1) in screens: nav[(a, r2.group(1))] += 1
|
||||
return screens, paths, usage, nav
|
||||
|
||||
def build_screen_graph(screens, paths, usage, nav, cfg):
|
||||
total = {s: sum(usage[s].values()) for s in screens}
|
||||
sg, so = cfg["screenGroups"], cfg["screenOwners"]
|
||||
owned = defaultdict(list)
|
||||
for s in sorted(screens): owned[so.get(s, cfg["defaultOwner"])].append(s)
|
||||
def fnorm(a): return a.split(':')[-1].replace('-', '').lower()
|
||||
def main_of(a, ss):
|
||||
epon = [s for s in ss if s.lower() == fnorm(a)]
|
||||
return epon[0] if epon else max(ss, key=lambda x: (total[x], x)) # deterministic tie-break
|
||||
main = {a: main_of(a, ss) for a, ss in owned.items()}
|
||||
APPSHELL = 'AppShell'
|
||||
edge = defaultdict(int)
|
||||
for (a, t), c in nav.items():
|
||||
src = main.get(a, APPSHELL)
|
||||
if src == t: continue
|
||||
edge[(src, t)] += c
|
||||
use_shell = any(s == APPSHELL for s, _ in edge)
|
||||
indeg, outdeg = defaultdict(int), defaultdict(int)
|
||||
for (s, t) in edge: outdeg[s] += 1; indeg[t] += 1
|
||||
N = [[s, s, sg.get(s, cfg["defaultGroup"]), indeg[s], outdeg[s]] for s in sorted(screens)]
|
||||
if use_shell: N.append([APPSHELL, 'App shell', 'shell', indeg[APPSHELL], outdeg[APPSHELL]])
|
||||
E = [[s, t, w, 0] for (s, t), w in edge.items()]
|
||||
meta = {}
|
||||
for s in sorted(screens):
|
||||
meta[s] = {"path": paths.get(s, ""), "owner": so.get(s, cfg["defaultOwner"]),
|
||||
"group": sg.get(s, cfg["defaultGroup"]), "total": total[s],
|
||||
"refs": sorted(usage[s].items(), key=lambda kv: -kv[1])}
|
||||
return {"n": N, "e": E}, meta
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
def main():
|
||||
if not os.path.exists(APPROUTE):
|
||||
sys.exit(f"ERROR: AppRoute.kt not found at {APPROUTE}")
|
||||
old = open(MD, encoding='utf-8').read() if os.path.exists(MD) else ""
|
||||
|
||||
# config: prefer the one already in the doc (human edits win), else seed
|
||||
cfg_str = block(old, "config")
|
||||
cfg = json.loads(cfg_str) if cfg_str else json.load(open(SEED))
|
||||
|
||||
modules, dep_edges = scan_modules()
|
||||
area_g = build_area_graph(modules, dep_edges)
|
||||
mod_g = build_module_graph(modules, dep_edges)
|
||||
screens, paths, usage, nav = scan_routes()
|
||||
|
||||
# reconcile config with the screens actually present in code
|
||||
warns = []
|
||||
for s in sorted(screens):
|
||||
if s not in cfg["screenGroups"]:
|
||||
cfg["screenGroups"][s] = cfg["defaultGroup"]; warns.append(f"NEW screen '{s}': group defaulted to '{cfg['defaultGroup']}' — set it in CONFIG")
|
||||
if s not in cfg["screenOwners"]:
|
||||
cfg["screenOwners"][s] = cfg["defaultOwner"]; warns.append(f"NEW screen '{s}': owner defaulted to '{cfg['defaultOwner']}' — set it in CONFIG")
|
||||
stale = [s for s in cfg["screenGroups"] if s not in screens]
|
||||
|
||||
screen_g, screen_meta = build_screen_graph(screens, paths, usage, nav, cfg)
|
||||
|
||||
inv_area = sum(1 for s, t, w, a in area_g["e"] if t.startswith('features:') and not s.startswith('features:'))
|
||||
|
||||
cj = lambda o: json.dumps(o, separators=(',', ':'))
|
||||
summary = (
|
||||
f"_Auto-generated from code by the `navigation-graph` skill. Edit only the CONFIG block below._\n\n"
|
||||
f"- **Screens (AppRoute):** {len(screens)} · screen-nav edges {len(screen_g['e'])}\n"
|
||||
f"- **Area graph:** {len(area_g['n'])} feature/domain/data areas · {len(area_g['e'])} dependency edges · {inv_area} inverted (domain/data→features)\n"
|
||||
f"- **Module graph:** {len(mod_g['n'])} modules · {len(mod_g['e'])} edges\n"
|
||||
)
|
||||
if warns: summary += "\n**Action needed:**\n" + "\n".join(f"- {w}" for w in warns) + "\n"
|
||||
if stale: summary += f"\n_Config has {len(stale)} screen(s) no longer in code (kept, harmless): {', '.join(stale)}_\n"
|
||||
|
||||
managed = "\n\n".join([
|
||||
BEGIN,
|
||||
"## Connectivity data (generated)\n\n" + summary,
|
||||
"### Curated config (editable — preserved across refreshes)\n\n" + wrap("config", json.dumps(cfg, indent=2)),
|
||||
"### Graph data (auto — overwritten every refresh; do not hand-edit)\n\n" +
|
||||
wrap("areaGraph", cj(area_g)) + "\n\n" + wrap("moduleGraph", cj(mod_g)) + "\n\n" +
|
||||
wrap("screensGraph", cj(screen_g)) + "\n\n" + wrap("screensMeta", cj(screen_meta)),
|
||||
END,
|
||||
])
|
||||
|
||||
if BEGIN in old and END in old:
|
||||
head = old[:old.index(BEGIN)].rstrip() + "\n\n"
|
||||
tail = old[old.index(END) + len(END):]
|
||||
new = head + managed + tail
|
||||
elif old.strip():
|
||||
new = old.rstrip() + "\n\n" + managed + "\n"
|
||||
else:
|
||||
new = "# Navigation Graph\n\n" + managed + "\n"
|
||||
|
||||
os.makedirs(DOCS, exist_ok=True)
|
||||
open(MD, "w", encoding='utf-8').write(new)
|
||||
print(f"✓ updated {os.path.relpath(MD, ROOT)}")
|
||||
print(f" screens={len(screens)} screen-edges={len(screen_g['e'])} | "
|
||||
f"areas={len(area_g['n'])}/{len(area_g['e'])} | modules={len(mod_g['n'])}/{len(mod_g['e'])}")
|
||||
for w in warns: print(" ⚠ " + w)
|
||||
print("Next: python3 " + os.path.join(SCRIPT_DIR, "render_html.py"))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue