diff --git a/README.md b/README.md index 46408cc..0aa8b32 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ Writing a PR description after the coding is finished is easy to postpone. diffn ## Features -### Choose your diff Summarize tracked working-tree changes, staged changes, a base ref, or a commit range. diff --git a/src/diffnote/analyze.py b/src/diffnote/analyze.py index 40e3d99..202df45 100644 --- a/src/diffnote/analyze.py +++ b/src/diffnote/analyze.py @@ -29,6 +29,8 @@ CONV_RE = re.compile(r"^(feat|fix|refactor|perf|chore|docs|test|style|build|ci)" r"(\([^)]+\))?!?:", re.I) +BREAKING_COMMIT_RE = re.compile(r"^[a-z][a-z0-9-]*(?:\([^)]+\))?!:", re.I) +PUBLIC_API_RE = re.compile(r"^(?:lib/|src/(?:[^/]+/)*api(?:/|$))", re.I) MINUS_RE = re.compile(r"^--- ") @@ -120,9 +122,11 @@ def _join_text(file): return "\n".join(file["lines"]) -def assess_risks(files): +def assess_risks(files, commit_messages=None): """Return list of risk dicts: {label, files:[...]}.""" risks = {} + breaking_commit = any(BREAKING_COMMIT_RE.match(message) for message in (commit_messages or [])) + breaking_files = [] for f in files: path = f["new"] content = _join_text(f) @@ -134,6 +138,10 @@ def assess_risks(files): risks.setdefault("dependency change", []).append(path) if f["added"] + f["deleted"] >= 500: risks.setdefault("large batch change (review carefully)", []).append(path) + if breaking_commit or PUBLIC_API_RE.match(path): + breaking_files.append(path) + if breaking_files: + risks["possible breaking change"] = breaking_files return [{"label": k, "files": v} for k, v in risks.items()] @@ -167,7 +175,7 @@ def build_summary(files, commit_messages=None): for t in _categorize(f["new"]): areas[t] += 1 - risks = assess_risks(files) + risks = assess_risks(files, commit_messages) title = suggest_title(files, commit_messages) section = suggest_section(files, commit_messages) diff --git a/tests/test_analyze.py b/tests/test_analyze.py index c9cd75b..d558cf5 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -1,5 +1,7 @@ +import json import textwrap from diffnote import analyze +from diffnote import render SAMPLE = textwrap.dedent( """ @@ -36,6 +38,20 @@ def f(): ).strip() +def _files_for(*paths): + diff = [] + for path in paths: + diff.extend([ + f"diff --git a/{path} b/{path}", + f"--- a/{path}", + f"+++ b/{path}", + "@@ -1 +1 @@", + "-old value", + "+new value", + ]) + return analyze.parse_diff("\n".join(diff)) + + def test_parse_diff_counts(): files = analyze.parse_diff(SAMPLE) assert len(files) == 4 @@ -67,3 +83,27 @@ def test_suggest_title_derives_migration(): files = analyze.parse_diff(SAMPLE) s = analyze.build_summary(files, commit_messages=[]) assert "schema" in s["title"] or s["title"].startswith("feat") + + +def test_breaking_risk_covers_public_api_paths_only(): + files = _files_for("lib/client.py", "src/app/api/routes.py", "src/app/apiary.py") + summary = analyze.build_summary(files) + risk = next(r for r in summary["risks"] if r["label"] == "possible breaking change") + assert risk["files"] == ["lib/client.py", "src/app/api/routes.py"] + + +def test_breaking_commit_marker_covers_all_changed_files(): + files = _files_for("src/app/core.py", "README.md") + summary = analyze.build_summary(files, ["feat(api)!: remove the old response field"]) + risk = next(r for r in summary["risks"] if r["label"] == "possible breaking change") + assert risk["files"] == ["src/app/core.py", "README.md"] + + +def test_breaking_risk_appears_in_terminal_markdown_and_json(): + summary = analyze.build_summary(_files_for("src/api/client.py"), ["fix!: change public return type"]) + terminal = render.render_terminal(summary, use_color=False) + markdown = render.render_markdown(summary) + machine = json.loads(render.render_json(summary)) + assert "possible breaking change" in terminal + assert "possible breaking change" in markdown + assert any(risk["label"] == "possible breaking change" for risk in machine["risks"])