From 331ab8a55ba6905c7ab8625df2db5954d6891c81 Mon Sep 17 00:00:00 2001 From: YongxinFu <190456533+YongxinFu@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:54:07 +0800 Subject: [PATCH] feat: flag possible breaking changes Assisted-by: Codex (OpenAI) --- README.md | 1 + src/diffnote/analyze.py | 12 ++++++++++-- tests/test_analyze.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2b6200b..e3cfe9e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ That's the whole workflow. No config file, no sign-up, no telemetry. - 🔒 **Offline & private** — your code never leaves your machine. - 🧭 **Smart grouping** — files bucketed by area automatically. - 🚦 **Risk radar** — flags migrations, auth/security edits, dependency changes, huge diffs. +- ⚠️ **Breaking-change hints** — flags `!:` commit markers and diffs under public API paths. - 🏷 **Title & section** — infers a conventional-commit title and changelog bucket. - 🤖 **Optional local LLM** — `--llm` asks Ollama for a prose blurb; if Ollama isn't running it silently falls back to the offline summary. 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"])