Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,20 @@ jobs:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Install pre-commit
run: python -m pip install pre-commit==3.8.0
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Install actionlint
run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.7
- name: Run pre-commit
run: pre-commit run --all-files
- name: Run actionlint
run: actionlint
- name: Run shellcheck
Expand Down
10 changes: 10 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
minimum_pre_commit_version: "3.8.0"

default_language_version:
python: python3

repos:
- repo: https://github.com/psf/black
rev: 25.9.0
hooks:
- id: black
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[tool.black]
line-length = 120
target-version = ["py313"]
8 changes: 6 additions & 2 deletions scripts/cb_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ def run_seed(repo: str, out: str, source_sha: str) -> None:
print(f"Seeded static-analysis baseline in {out} (clusters: {summary or 'none'})")


def run_head(repo: str, out: str, name: str, run_id: str, depth: int, base_ref: str, target_ref: str, source_sha: str) -> None:
def run_head(
repo: str, out: str, name: str, run_id: str, depth: int, base_ref: str, target_ref: str, source_sha: str
) -> None:
from codeboarding_workflows.analysis import BaselineUnavailableError, run_full, run_incremental
from diagram_analysis.exceptions import IncrementalCacheMissingError

Expand Down Expand Up @@ -243,7 +245,9 @@ def main(argv=None) -> int:
elif args.cmd == "seed":
run_seed(args.repo, args.out, args.source_sha)
elif args.cmd == "head":
run_head(args.repo, args.out, args.name, args.run_id, args.depth, args.base_ref, args.target_ref, args.source_sha)
run_head(
args.repo, args.out, args.name, args.run_id, args.depth, args.base_ref, args.target_ref, args.source_sha
)
elif args.cmd == "health":
Path(args.issues_out).write_text(str(run_health(args.artifact_dir, args.repo, args.name)))
elif args.cmd == "validate-base":
Expand Down
48 changes: 33 additions & 15 deletions scripts/diff_to_mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@
# GitHub's mermaid config caps (config.schema.yaml defaults; NOT raisable on
# GitHub). Exceeding either renders a red error box with no diagram, so we stay
# comfortably under and degrade to a changed-only / text fallback instead.
MAX_EDGES = 480 # hard cap 500
MAX_TEXT = 45_000 # hard cap 50000 chars
MAX_EDGES = 480 # hard cap 500
MAX_TEXT = 45_000 # hard cap 50000 chars

# Primer-ish fills that read on both light and dark GitHub backgrounds. White
# label text is set explicitly so it survives dark mode.
Expand Down Expand Up @@ -89,8 +89,7 @@ def _has_method_changes(base: dict, current: dict) -> bool:
base_by_file = _methods_by_file(base)
current_by_file = _methods_by_file(current)
return any(
base_by_file.get(fp, set()) != current_by_file.get(fp, set())
for fp in set(base_by_file) | set(current_by_file)
base_by_file.get(fp, set()) != current_by_file.get(fp, set()) for fp in set(base_by_file) | set(current_by_file)
)


Expand Down Expand Up @@ -122,7 +121,11 @@ def _diff_relations(base_rels: list, current_rels: list) -> list:
continue

if len(base_group) == 1 and len(current_group) == 1:
status = "unchanged" if (base_group[0].get("relation") or "") == (current_group[0].get("relation") or "") else "modified"
status = (
"unchanged"
if (base_group[0].get("relation") or "") == (current_group[0].get("relation") or "")
else "modified"
)
result.append({**current_group[0], "diff_status": status})
continue

Expand Down Expand Up @@ -227,9 +230,17 @@ def _sanitize(name: str) -> str:
# node label and breaks the whole diagram, so escape the shape chars too — not
# just ``#`` and ``"``.
_ESC_MAP = {
"&": "#amp;", '"': "#quot;", "<": "#lt;", ">": "#gt;",
"[": "#91;", "]": "#93;", "(": "#40;", ")": "#41;",
"{": "#123;", "}": "#125;", "|": "#124;",
"&": "#amp;",
'"': "#quot;",
"<": "#lt;",
">": "#gt;",
"[": "#91;",
"]": "#93;",
"(": "#40;",
")": "#41;",
"{": "#123;",
"}": "#125;",
"|": "#124;",
}


Expand Down Expand Up @@ -338,8 +349,7 @@ def touches(r: dict, side_id: str, side_name: str) -> bool:
rels = [
r
for r in relations
if r.get("diff_status") in CHANGED
or (touches(r, "src_id", "src_name") and touches(r, "dst_id", "dst_name"))
if r.get("diff_status") in CHANGED or (touches(r, "src_id", "src_name") and touches(r, "dst_id", "dst_name"))
]
return kept, rels

Expand Down Expand Up @@ -379,8 +389,7 @@ def _count_changed_components(components: list) -> int:
def _has_changed_relations(components: list, relations: list) -> bool:
"""Recursively: is any relation (at any nesting level) added/modified/deleted?"""
return _has_changes([], relations) or any(
_has_changed_relations(c.get("components") or [], c.get("components_relations") or [])
for c in components or []
_has_changed_relations(c.get("components") or [], c.get("components_relations") or []) for c in components or []
)


Expand Down Expand Up @@ -522,9 +531,18 @@ def main() -> int:
p.add_argument("--out", required=True, type=Path, help="Where to write the ```mermaid block")
p.add_argument("--direction", default="LR", choices=["LR", "TD", "TB", "RL", "BT"])
p.add_argument("--changed-only", action="store_true", help="Render only changed components + incident edges")
p.add_argument("--no-edge-labels", dest="edge_labels", action="store_false", help="Draw arrows without relation labels")
p.add_argument("--render-depth", type=int, default=1, help="Component levels to draw: 1=top-level flat, 2=+one nesting level, ...")
p.add_argument("--font-size", type=int, default=None, help="Node label font size in px (bigger label ⇒ bigger node)")
p.add_argument(
"--no-edge-labels", dest="edge_labels", action="store_false", help="Draw arrows without relation labels"
)
p.add_argument(
"--render-depth",
type=int,
default=1,
help="Component levels to draw: 1=top-level flat, 2=+one nesting level, ...",
)
p.add_argument(
"--font-size", type=int, default=None, help="Node label font size in px (bigger label ⇒ bigger node)"
)
p.add_argument("--node-padding", type=int, default=None, help="Interior padding around each node label")
p.add_argument("--node-spacing", type=int, default=None, help="Space between nodes in the same rank")
p.add_argument("--rank-spacing", type=int, default=None, help="Space between ranks")
Expand Down
3 changes: 2 additions & 1 deletion tests/test_build_cta.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ def test_no_proxy_links_editor_to_https_listing_no_get_extension(self):
def test_no_proxy_vscode_marketplace_https_no_banner_at_zero(self):
out = bc.build_cta("", "o", "r", "1", repo_with()) # neither dir, no issues
self.assertIn(
"[**Open in VS Code →**](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding)", out
"[**Open in VS Code →**](https://marketplace.visualstudio.com/items?itemName=Codeboarding.codeboarding)",
out,
)
self.assertNotIn("vscode:extension", out) # custom scheme stripped by GitHub
self.assertNotIn("Get the extension", out)
Expand Down
103 changes: 69 additions & 34 deletions tests/test_cb_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@
import cb_engine # noqa: E402

_STUBBED = [
"codeboarding_workflows", "codeboarding_workflows.analysis",
"diagram_analysis", "diagram_analysis.exceptions",
"health", "health.models", "health.runner",
"static_analyzer", "static_analyzer.analysis_cache",
"codeboarding_workflows",
"codeboarding_workflows.analysis",
"diagram_analysis",
"diagram_analysis.exceptions",
"health",
"health.models",
"health.runner",
"static_analyzer",
"static_analyzer.analysis_cache",
"static_analyzer.cluster_helpers",
]

Expand Down Expand Up @@ -85,46 +90,70 @@ def test_base_calls_run_full(self):
def test_main_parses_depth_as_int(self):
rf = _Rec()
self._install(run_full=rf)
cb_engine.main([
"base",
"--repo", "/repo",
"--out", "/out",
"--name", "myrepo",
"--run-id", "rid-base",
"--depth", "2",
"--source-sha", "abc123",
])
cb_engine.main(
[
"base",
"--repo",
"/repo",
"--out",
"/out",
"--name",
"myrepo",
"--run-id",
"rid-base",
"--depth",
"2",
"--source-sha",
"abc123",
]
)
self.assertEqual(rf.calls[0]["depth_level"], 2)

def test_main_sets_github_action_source(self):
rf = _Rec()
self._install(run_full=rf)
with patch.dict(os.environ, {}, clear=True):
cb_engine.main([
"base",
"--repo", "/repo",
"--out", "/out",
"--name", "myrepo",
"--run-id", "rid-base",
"--depth", "2",
"--source-sha", "abc123",
])
cb_engine.main(
[
"base",
"--repo",
"/repo",
"--out",
"/out",
"--name",
"myrepo",
"--run-id",
"rid-base",
"--depth",
"2",
"--source-sha",
"abc123",
]
)
self.assertEqual(os.environ["CODEBOARDING_SOURCE"], "github_action")

def test_main_rejects_invalid_depth(self):
for depth in ("0", "4", "x"):
with self.subTest(depth=depth):
with redirect_stderr(StringIO()):
with self.assertRaises(SystemExit):
cb_engine.main([
"base",
"--repo", "/repo",
"--out", "/out",
"--name", "myrepo",
"--run-id", "rid-base",
"--depth", depth,
"--source-sha", "abc123",
])
cb_engine.main(
[
"base",
"--repo",
"/repo",
"--out",
"/out",
"--name",
"myrepo",
"--run-id",
"rid-base",
"--depth",
depth,
"--source-sha",
"abc123",
]
)

def test_head_uses_incremental(self):
ri, rf = _Rec(), _Rec()
Expand Down Expand Up @@ -239,7 +268,9 @@ def save(self, res, source_sha=None):
log.append(("save", res, source_sha))

sa = _mod("static_analyzer", get_static_analysis=get_static_analysis)
sa.cluster_helpers = _mod("static_analyzer.cluster_helpers", build_all_cluster_results=build_all_cluster_results)
sa.cluster_helpers = _mod(
"static_analyzer.cluster_helpers", build_all_cluster_results=build_all_cluster_results
)
sa.analysis_cache = _mod("static_analyzer.analysis_cache", StaticAnalysisCache=_Cache)
return log, results

Expand Down Expand Up @@ -288,9 +319,13 @@ def get(self):

_mod("health.models", Severity=Severity)
_mod("health.runner", run_health_checks=lambda sa, repo_name, repo_path: report)
_mod("health", )
_mod(
"health",
)
_mod("static_analyzer.analysis_cache", StaticAnalysisCache=_Cache)
_mod("static_analyzer", )
_mod(
"static_analyzer",
)
return Severity

def test_counts_warning_and_critical(self):
Expand Down
45 changes: 36 additions & 9 deletions tests/test_diff_to_mermaid.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,14 @@ def test_parallel_relation_deletion_is_not_label_modification(self):

class TestRender(unittest.TestCase):
def _diff(self):
base = {"components": [comp("A"), comp("B"), comp("Gone")], "components_relations": [rel("A", "B"), rel("A", "Gone")]}
head = {"components": [comp("A", {"x.py": ["f"]}), comp("B"), comp("New")], "components_relations": [rel("A", "B"), rel("A", "New")]}
base = {
"components": [comp("A"), comp("B"), comp("Gone")],
"components_relations": [rel("A", "B"), rel("A", "Gone")],
}
head = {
"components": [comp("A", {"x.py": ["f"]}), comp("B"), comp("New")],
"components_relations": [rel("A", "B"), rel("A", "New")],
}
return dm.build_diff(base, head)

def test_flat_default_has_no_subgraphs(self):
Expand All @@ -88,8 +94,14 @@ def test_flat_default_has_no_subgraphs(self):
self.assertTrue(linkstyle_indices_in_range(text))

def test_nested_subgraphs_balanced_and_valid(self):
base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2")])], "components_relations": []}
head = {"components": [comp("P", subs=[comp("c1"), comp("c3")], subrels=[rel("c1", "c3")])], "components_relations": []}
base = {
"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2")])],
"components_relations": [],
}
head = {
"components": [comp("P", subs=[comp("c1"), comp("c3")], subrels=[rel("c1", "c3")])],
"components_relations": [],
}
text, _ = dm.render_mermaid(dm.build_diff(base, head), render_depth=2)
sg = sum(1 for line in text.splitlines() if line.strip().startswith("subgraph "))
en = sum(1 for line in text.splitlines() if line.strip() == "end")
Expand Down Expand Up @@ -164,16 +176,25 @@ def test_nested_method_change_highlights_collapsed_parent(self):
self.assertIn("class n_P modified;", text)

def test_nested_relation_change_highlights_collapsed_parent(self):
base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "uses")])], "components_relations": []}
head = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "calls")])], "components_relations": []}
base = {
"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "uses")])],
"components_relations": [],
}
head = {
"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[rel("c1", "c2", "calls")])],
"components_relations": [],
}
text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=1)
self.assertEqual(meta["n_changed"], 0)
self.assertTrue(meta["changed"])
self.assertIn("class n_P modified;", text)

def test_changed_only_keeps_nested_change(self):
base = {"components": [comp("P", subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
head = {"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]}), comp("c2")], subrels=[])], "components_relations": []}
head = {
"components": [comp("P", subs=[comp("c1", {"x.py": ["f"]}), comp("c2")], subrels=[])],
"components_relations": [],
}
text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2, changed_only=True)
self.assertIsNotNone(text)
self.assertTrue(meta["changed"])
Expand All @@ -183,8 +204,14 @@ def test_changed_only_keeps_nested_change(self):
self.assertNotIn('n_c2["c2"]', text)

def test_changed_only_prunes_unchanged_children_of_modified_parent(self):
base = {"components": [comp("P", {"p.py": ["old"]}, subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
head = {"components": [comp("P", {"p.py": ["old", "new"]}, subs=[comp("c1"), comp("c2")], subrels=[])], "components_relations": []}
base = {
"components": [comp("P", {"p.py": ["old"]}, subs=[comp("c1"), comp("c2")], subrels=[])],
"components_relations": [],
}
head = {
"components": [comp("P", {"p.py": ["old", "new"]}, subs=[comp("c1"), comp("c2")], subrels=[])],
"components_relations": [],
}
text, meta = dm.render_mermaid(dm.build_diff(base, head), render_depth=2, changed_only=True)
self.assertIsNotNone(text)
self.assertTrue(meta["changed"])
Expand Down
Loading