diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd63f11..b1ab4f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project are documented here. Versions follow
## [Unreleased]
+## [1.14.1] — 2026-09-13
+
+### Changed
+
+- Improve report readability.
+
## [1.14.0] — 2026-09-11
### Added
diff --git a/compare_tool/__init__.py b/compare_tool/__init__.py
index f720ac1..eb55963 100644
--- a/compare_tool/__init__.py
+++ b/compare_tool/__init__.py
@@ -1,3 +1,3 @@
"""CodeGen Compare Tool - AUTOSAR MATLAB codegen diff with noise filtering."""
-__version__ = "1.14.0"
+__version__ = "1.14.1"
diff --git a/compare_tool/report.py b/compare_tool/report.py
index 2536500..28e964d 100644
--- a/compare_tool/report.py
+++ b/compare_tool/report.py
@@ -169,6 +169,11 @@
caption on the diff, not as a diff row. */
.fnhdr { font-family: Consolas, monospace; font-size: 11px; color: var(--fg-muted);
padding: 3px 8px 1px; }
+/* groups inside one scope share the first group's caption (see _groups_html).
+ Which caption comes first depends on the Reviewed badge folding signed-off
+ groups away: .dup repeats the one above while every group shows, .dup-unrev
+ while reviewed groups are hidden. */
+body:not(.hide-rev) .fnhdr.dup, body.hide-rev .fnhdr.dup-unrev { display: none; }
tr.mvnote td { text-align: center; color: var(--mv-fg); background: var(--panel-2);
font-size: 11px; }
/* Unimportant rows hide per ROW, not per group: a group used to be wrapped
@@ -661,6 +666,9 @@ def _groups_html(old_lines, new_lines, hunks, notes=None, language=None,
funcname.enclosing(new_lines, language))
out = []
rows_so_far = 0
+ # the scope named by the nearest code group above: once with every group
+ # on screen, once with signed-off groups folded away by the Reviewed badge
+ above = above_unrev = None
runs = _focus_runs(hunks)
for idx, (i, j, lean) in enumerate(runs):
# --max-diff-lines caps the diff embedded per file: a regenerate that
@@ -682,10 +690,24 @@ def _groups_html(old_lines, new_lines, hunks, notes=None, language=None,
cls = ' grp-rev' if done else ''
block = ['
'.format(' lean' if lean else '', cls)]
# a lean group is a collapsed noise placeholder with no code window, so
- # a caption over it would point at rows that are not shown
- label = None if lean else funcname.hunk_label(old_labels, new_labels, g[0])
- if label:
- block.append('
ƒ {}
'.format(_esc(label)))
+ # a caption over it would point at rows that are not shown; naming no
+ # scope, it does not break a run of groups inside one either
+ if not lean:
+ label = funcname.hunk_label(old_labels, new_labels, g[0])
+ # a caption only where the scope changes, not over every table of
+ # one function. The Reviewed badge can fold the group carrying it
+ # away, so a caption that repeats in only one badge state is still
+ # emitted, marked for the CSS to hide in that state. A signed-off
+ # group is itself hidden once reviewed groups are, so only the
+ # every-group-shown state decides its own caption.
+ dup = label == above
+ dup_unrev = dup if done else label == above_unrev
+ if label and not (dup and dup_unrev):
+ mark = ' dup' if dup else ' dup-unrev' if dup_unrev else ''
+ block.append('
ƒ {}
'.format(mark, _esc(label)))
+ above = label
+ if not done:
+ above_unrev = label
block.append(notes_html)
budget = (max_rows - rows_so_far) if max_rows else 0
block.append(_group_table(old_lines, new_lines, g, language, old_states,
diff --git a/docs/architecture.md b/docs/architecture.md
index 02192f2..5b83a01 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -360,11 +360,10 @@ until someone adds a new kind to one of them.
detach them on the next scan.
- **`funcname.enclosing`** — the scope name for each line (C/C++ function,
Python class/method, AUTOSAR SHORT-NAME, A2L block), one list the report and
- the viewer both read. The
- report captions each hunk group and lists a file's `Affected` functions from
- it; the viewer tracks a "current function" as the pane scrolls. It never
- decides a verdict — a wrong name costs a caption, so the heuristics say
- `None` rather than guess.
+ the viewer both read. The report shows it above each change group (once,
+ when consecutive groups share it); the viewer tracks a "current function" as
+ the pane scrolls. It never decides a verdict — a wrong name costs a caption,
+ so the heuristics say `None` rather than guess.
- **`langspec.SPECS`** — one table of each language's comment/string grammar
(what opens a comment, how a string escapes). Two very different surfaces read
it: `syntax.py` colours a comment, and the diff shadow (`langspec.shadow` for
diff --git a/docs/usage.md b/docs/usage.md
index 2384f2b..0507f97 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -287,6 +287,8 @@ Each real change shows three lines of context on either side of it — not the w
The lines themselves are always in the file — only the screen stays quiet about them. A file whose differences are purely comments doesn't even get a detail section; it just keeps its `≈` mark and `Comment` count in the tree. (If you're curious why the window is kept this tight rather than wider, that's covered in [architecture.md](architecture.md#decisions-worth-knowing-before-you-change-something).)
+Each change is labelled with the function it sits in (for ARXML the SHORT-NAME, for A2L the block). When several changes sit in the same function, the name appears once, above the first.
+
`Focus on changes`, next to the folder tree, narrows the tree down to files that actually changed — identical, comment-only and Unimportant rows drop out, and any folder left holding none of them goes with them. Like the viewer's `Hide identical`, this is purely a view: verdicts and counts underneath are untouched. A `☀ Light` / `☾ Dark` button sits in the top right; both palettes are embedded in the file itself, so switching between them fetches nothing and works fine on a machine with no internet connection at all.
## CI integration
diff --git a/docs/vi/architecture.md b/docs/vi/architecture.md
index 5255f51..27bb2a6 100644
--- a/docs/vi/architecture.md
+++ b/docs/vi/architecture.md
@@ -287,10 +287,10 @@ khớp nhau hoàn hảo cho tới lúc ai đó thêm một kind mới vào một
làm chúng rớt ra ở lần scan sau.
- **`funcname.enclosing`** — tên scope của mỗi dòng (hàm C/C++, class/method
Python, SHORT-NAME AUTOSAR, block A2L), một list mà cả report và viewer cùng
- đọc. Report chú thích mỗi nhóm
- hunk và liệt kê danh sách `Affected` của file từ đó; viewer bám theo "hàm hiện
- tại" khi pane cuộn. Nó không bao giờ quyết định verdict — tên sai chỉ tốn một
- caption — nên heuristic thà trả `None` còn hơn đoán.
+ đọc. Report hiện tên này phía trên mỗi nhóm thay đổi (chỉ một lần nếu các nhóm
+ liền nhau cùng scope); viewer bám theo "hàm hiện tại" khi pane cuộn. Nó không
+ bao giờ quyết định verdict — tên sai chỉ tốn một caption — nên heuristic thà
+ trả `None` còn hơn đoán.
- **`langspec.SPECS`** — một bảng duy nhất mô tả ngữ pháp comment/string của mỗi
ngôn ngữ (cái gì mở một comment, string escape ra sao). Hai surface rất khác
nhau cùng đọc nó: `syntax.py` tô màu comment, còn shadow của diff
diff --git a/docs/vi/usage.md b/docs/vi/usage.md
index 1c047f5..d7eb6cf 100644
--- a/docs/vi/usage.md
+++ b/docs/vi/usage.md
@@ -451,6 +451,10 @@ khác biệt *chỉ* là comment thì không có mục chi tiết riêng; nó ch
không rộng hơn:
[architecture.md](architecture.md#những-quyết-định-nên-biết-trước-khi-sửa).)
+Mỗi change được ghi tên hàm chứa nó (với ARXML là SHORT-NAME, với A2L là
+block). Nhiều change cùng nằm trong một hàm thì tên hàm chỉ hiện một lần, ở trên
+change đầu tiên.
+
`Focus on changes`, cạnh cây thư mục, thu cây lại còn đúng các file thật sự có
thay đổi — dòng identical, comment-only và Unimportant biến mất, thư mục nào
chỉ còn lại toàn những loại đó thì biến theo luôn. Giống `Hide identical` bên
diff --git a/tests/test_report.py b/tests/test_report.py
index f32d0ea..bee12a4 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -131,6 +131,89 @@ def test_report_shows_minor_hunks_in_modified_files(self):
self.assertIn('delm', page) # NoiseDemo.c has comment hunks too
+_CAPTION_RE = re.compile(r'
ƒ ([^<]*)
')
+
+
+def _scoped_c(scopes):
+ """C source with one function per ``(name, x, y)``, the ``x`` and ``y``
+ statements padded far enough apart -- and from the next function -- that
+ a change to either renders as its own group."""
+ lines = []
+ for name, x, y in scopes:
+ lines += ['void {}(void)'.format(name), '{']
+ for stmt in ('x = {};'.format(x), 'y = {};'.format(y), None):
+ # unique filler, so the line matcher has nothing to mis-anchor on
+ lines += [' pad{} = 0;'.format(len(lines) + k) for k in range(8)]
+ if stmt:
+ lines.append(' ' + stmt)
+ lines += ['}', '']
+ return '\n'.join(lines)
+
+
+class TestFunctionCaption(unittest.TestCase):
+ """The ``ƒ scope`` caption is printed where the scope changes, not over
+ every group: consecutive groups inside one function share the first one's.
+ The Reviewed badge can fold that first group away, so a repeat is marked
+ per badge state instead of being dropped."""
+
+ OLD = _scoped_c([('step_a', 1, 2), ('step_b', 3, 4)])
+
+ @staticmethod
+ def _render(old, new, rel, language, signed_off=()):
+ r = compare_pair(old, new, rel)
+ real = [h for h in r['hunks'] if h['kind'] == 'real']
+ # signed_off: indices into the real hunks
+ notes = {tuple(real[k]['old_range']) + tuple(real[k]['new_range']):
+ ('ok', True, '') for k in signed_off}
+ out = _groups_html(old.split('\n'), new.split('\n'), r['hunks'], notes,
+ language)
+ return real, out, _CAPTION_RE.findall(out)
+
+ def test_groups_inside_one_function_name_it_once(self):
+ new = _scoped_c([('step_a', 10, 20), ('step_b', 3, 4)])
+ real, out, caps = self._render(self.OLD, new, 'f.c', 'c')
+ self.assertEqual(len(real), 2)
+ self.assertEqual(out.count('
', out)
+ self.assertEqual(caps, [('fnhdr', 'step_a'), ('fnhdr dup', 'step_a')])
+ self.assertIn('body:not(.hide-rev) .fnhdr.dup, '
+ 'body.hide-rev .fnhdr.dup-unrev { display: none; }', _CSS)
+
+ def test_a_scope_resumed_below_a_signed_off_one_is_marked_for_that_state(self):
+ # Pkg / Comp, its nested Comp / Port (signed off), then Comp again.
+ # With every group shown the third caption follows Port, a different
+ # scope; with Port folded away it would repeat the first
+ def pad(tag):
+ return ['<{}{}/>'.format(tag, k) for k in range(8)]
+
+ def arxml(v):
+ return '\n'.join(
+ ['
', 'Pkg', '',
+ 'Comp', ''.format(v)]
+ + pad('P') + ['', 'Port'] + pad('Q')
+ + [''.format(v)] + pad('R') + [''] + pad('S')
+ + [''.format(v), '', '', ''])
+
+ real, _out, caps = self._render(arxml(1), arxml(2), 'f.arxml', 'arxml',
+ signed_off=(1,))
+ self.assertEqual(len(real), 3)
+ self.assertEqual(caps, [('fnhdr', 'Pkg / Comp'), ('fnhdr', 'Comp / Port'),
+ ('fnhdr dup-unrev', 'Pkg / Comp')])
+
+
class TestUnimportantToggle(unittest.TestCase):
"""Comment and Unimportant each hide behind their own badge -- per ROW, not
per group -- but ONLY when the hunk stands in a group with no real/moved