Skip to content

change-summary: an advisory digest of what changed on this branch, and why - #149

Open
Oleg67 wants to merge 17 commits into
constructorfabric:mainfrom
Oleg67:feat/change-summary-command
Open

change-summary: an advisory digest of what changed on this branch, and why#149
Oleg67 wants to merge 17 commits into
constructorfabric:mainfrom
Oleg67:feat/change-summary-command

Conversation

@Oleg67

@Oleg67 Oleg67 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #154. #133 has merged; the first eight commits here are #154's (the linkage follow-ups that missed that squash by minutes, and their review rounds), and this pull request adds the other nine: dfe74885, 33d7a61e, 5340020c, 9abcc595, 9d60c71c, 96ecd217, 396b574d, e232c1de, 31412cc0. Review #154 first, or read this one commit-by-commit. Once #154 merges I will rebase and the diff reduces to those nine.

Closes #131.

The gap

There is no short, trustworthy read of what a run changed and why. A reviewer's options today are the full diff, or cfs usage-report, which aggregates the decision log per method across all time — a cost lens, not a review lens. #125 landed the window and the decision-log selection, #133 the requirement linkage; neither renders anything a person reads.

The change

cfs change-summary — an advisory digest of at most ten lines, meant to be pasted into a review. Run on this branch against upstream/main it prints:

window: since 2026-09-03T19:10:35+03:00 against upstream/HEAD @ ce407d77
changes: 8 file(s): 4 reference requirements; 1 declare requirements
markers: 5 of 8 changed files carry requirement markers
requirements: cpt-studio-algo-core-infra-route-command, cpt-studio-algo-developer-experience-change-summary, cpt-studio-algo-developer-experience-change-summary-digest, cpt-studio-algo-developer-experience-doctor, cpt-studio-algo-developer-experience-pylint-rollout-phase-0 (+19 more)
why: 15 decision(s) in 7 run(s): validation ×15
runs: 81bf7966 ×3, c41860a1 ×1, 9b10cd15 ×3 (+4 more)

--json carries the same lines plus the full data behind each of them — every file with its status and IDs, every run, every count.

Why not usage-report --since

Reasonable question, so answering it up front. usage-report reports tokens and reads per method over the whole log. This reports what changed on a branch, why, and against which requirement. Different audience, different output, different window; folding them together produces one command with two unrelated modes.

Design principles

  • Advisory, enforced rather than promised. Exit 0 on every path except a usage error (2). Three enforcement points, each tested: a test forces every row of the behaviour matrix below and asserts the exit code did not move; a last-resort guard turns an exception the core is contractually never supposed to raise into a stated reason rather than a traceback (tested by making it raise, and asserting the exception's text — which could carry a path — does not appear); and a test greps the Makefile and the workflow files for the command's name, so it cannot be promoted into a gate without a red test.
  • Ten lines is a ceiling, not a quota. Only lines backed by data are emitted, then it stops. When the ceiling bites, the last line says how many lines were cut and that --json has them. Two single-line digests are deliberate: no window means nothing to scope the other dimensions to, so one reason plus the remedy (--since scopes decisions by time without git) rather than the same reason three times; no changes means nothing to review, so the log is not consulted for a line.
  • Every degraded line states its reason and its denominator. "0 of 9 changed files carry markers", "1 unparseable line(s) skipped", "shared via CFS_DECISION_LOG; decisions are not attributable to this project". A tally that is zero is not a line.
  • The digest never counts itself. The dispatcher records an invocation for every command, this one included. Telemetry events (invocation, read) are not what a reviewer means by "why", so they are excluded from that line; and this command's own invocations are excluded from the payload entirely, so two consecutive --json runs are byte-identical although each one appended an event to the very log it reads. Tested by running it three times from inside the project and checking the log grew.
  • Deterministic, no model call. Same repository state, byte-identical output, both renderings. The golden test pins commit dates so even the base sha in the fixture is reproducible across machines.
  • Every string in the output is encodable. Git paths arrive decoded with surrogateescape so a legal non-UTF-8 filename round-trips; a lone surrogate cannot be printed, and used to crash --json from inside print, past the last-resort guard. The payload — human lines included, keys included — is made encodable once: an undecodable filename byte renders as \xNN, and any other lone surrogate (a log line's "\ud800") as itself, so one bad field never costs the whole digest. Tested against a strictly encoding stream, since a StringIO never notices.
  • Privacy. The project root is deliberately absent from the payload — it is an absolute path — and neither rendering carries a home directory, username, commit author or commit message. socket.socket patched to raise → still works.
  • Nothing is published. No file written, nothing posted, nothing emitted from CI. The developer decides where the text goes.
  • The rules are in --help. Where the window comes from, the scope policy, the examined ceiling (quoted from the one constant the report uses), both marker directions, the line ceiling and its omission rule — so an excluded file or a capped change set is not mistaken for a bug by someone who never reads the source.
  • A breakdown names its population. When the scan is capped, the changes and markers lines say how many entries were examined rather than let "300 of 1,500" read as a breakdown of 1,500; and a project check that fails is reported as that, at warning level, not as "not a Studio project".

Behaviour matrix

Situation Digest Exit
Changes + log + markers present full digest, six lines here 0
Decision log disabled or absent git and marker lines; decisions: unavailable (decision log disabled) 0
Log present but corrupt parses what it can; decision log: N unparseable line(s) skipped 0
No changes against the base no changes against <ref> — one line 0
Changed files carry no markers markers: 0 of N changed files carry requirement markers; no requirements line 0
Not a git repository / git unavailable one line: the reason, and the --since remedy 0
Not inside a Studio project one line: the reason 0
Bad flag usage error 2

Non-goals

Gating anything. Judging whether code correctly implements its requirement. Replacing the diff. Offering the digest from the workflow-completion menu is deliberately not here either — it is a second surface and deserves its own change.

Round 3

The re-verification pass left four threads open; each is answered on its thread. One was a real defect, fixed in 228b2731: the run-prefix width was computed over the three runs shown, so a run folded into "(+N more)" that shared eight characters with a shown one left that label matching two runs in the log. The width now spans every run in the window, so a prefix printed is unique in the log it points into. The other three were pointers — the boundary tests exist at lines 324–341, the --base-with---since test lives in #154's diff, and the one residual assertion gap (the empty error list on a disabled ceiling) is closed on #154.

Round 4

Fifteen threads from the re-verification pass, each answered on its thread; the fixes are in 6253654a.

  • Events with no run id are now stated in the plain digestdecision log: N event(s) carry no run id — where before only the payload counted them and the runs line under-reported with no sign of it. The unattributed bucket is rendered whole, (unattributed), not cut to (unattri like an id.
  • The escape fallback leaves a trail. A lone surrogate that surrogateescape did not make — a corrupt log line — is escaped and warned about; the routine filename case stays quiet.
  • The invocation exclusion is by kind, documented and tested: another process's change-summary read is no more a decision than this one's, and excluding only the current run's would break the run-to-run determinism above.
  • Tests: the behaviour matrix as one exit-and-status truth table (seven rows); a real overflow at the default ceiling whose human suffix equals the payload's omitted; two invocations from distinct runs both excluded with the decision kept; the exact \xff\xfe escape surviving a JSON round-trip; the privacy test asserting a digest was produced before checking what it omits.
  • Argued, with the reasoning on the threads: run ids are the log's own identifiers and are not redacted; per-file and per-run detail stays in --json by design; a changed file under a vendored path is reported rather than hidden, as _in_project_scope documents; the "obsolete golden format" finding was against a superseded head.

Round 5

Two findings on 6253654a. One is a test, added in eb361580 (1d45e93d before the restacks): the degraded payload fields — an unreadable file's reason, a shared log's override — are where a path would leak if anywhere, so a test now forces both and asserts the serialised payload carries neither the project root, the override path, the home directory nor the user. The other, an eager rglob in the shared entry resolver, predates this series, is never entered by the linkage (it refuses non-files before calling the resolver), and changes every consumer's excluded count if fixed; it is answered as a change of its own, offered once these land.

Round 6

One spec finding on the re-verification pass, fixed in 26a39734 (doc only): the error-scenario line grouped the no-changes outcome with the paths that state a reason and a denominator, while the command deliberately prints one line naming the base. The no-changes outcome now has its own scenario line saying exactly that and why. Four re-verifications that ran against a superseded head were answered with the names and line numbers of the tests they could not find.

Round 7

Doc only, in 31412cc0: the scenario sentence promised a reason and a denominator for every degraded outcome, while an unavailable dimension has nothing to count and states its reason alone; the sentence now says so. CodeRabbit's point about the registered-scan growth test's starting content is fixed on #154 (bf0124af), which this branch is restacked on. Three re-verifications that reported tests as missing were answered with the tests quoted in full.

Round 8 — the granularity floor, measured against the current default branch

The floor check was being reported against the commit this stack was cut from. Re-measured against main as it stands now, because the merge is what CI scores:

measured on granularity gate
main @ 38434252 alone 0.4600 passes — with no margin left at all
#154 merged into that main, before its fix 0.4598 fails, exit 2
#154 merged, after its fixes 0.4603 passes
main @ ac9e06e9, with #154 now merged 0.4603 passes — 15.1 units of buffer, where there were none
this branch rebased onto that main (09f0ae46) 0.4605 passes — 25.4 units

The breach was #154's to fix and was fixed there, by declaring three further steps rather than by compressing anything. #154 has since merged, so the last two rows are now measured facts rather than predictions: the default branch went from sitting exactly on the floor to holding 15.1 line-granularity units of buffer, and this branch takes it to 25.4. This branch was never the one below the floor — its module carries enough declaration to lift the average — and every figure here is measured on the merge rather than on the branch alone.

Gates

Pinned to de51e662, the current head, rebased directly onto main now that #154 has merged (ac9e06e9). The diff is this PR's own four files and nothing else.

Gate Result
make test 5,637 passed, 4 skipped, 15 xfailed
new tests 74 — unit, real-CLI integration, golden, edge, invariant, privacy, fail-safe, determinism, scope reporting
line coverage, new module 100% (163 stmts, 0 missed); per-file ≥90% passes tree-wide
make pylint clean, 10.00/10
make vulture-ci clean — the handler is in the explicit-reference block
cfs validate 0 errors; the 270 warnings are pre-existing toc-heading-duplicate reports in architecture/DESIGN.md, untouched here
spec-coverage --system studio 0.4605 on main + this branch, against the 0.46 floor; coverage 90.8%. The new module is 12 traced blocks over 231 effective lines, g = 0.5195, above the system score

The module is CPT-traced with 12 blocks over 230 effective lines, g = 0.5217 — above the system score, so it raises the margin rather than consuming it. (An earlier revision of this table said 180 lines and g = 0.67; the measured figures are 230 and 0.5217.) Twelve mutation checks — the runless line dropped, the unattributed label cut like an id, the escape fallback silent again, the prefix width over the shown runs only, self-exclusion removed, telemetry counted, ceiling cutting silently, guard re-raising, project gate removed, one-liners removed, empty requirements line emitted, marker line dropped — each fail only the tests written for them.

Delivery

Last of the pull requests in this sequence — #125, #133 and #154 have all merged, so this one stands alone on main with no stacked dependency.

Summary by CodeRabbit

  • New Features
    • Added the cfs change-summary command, providing an advisory digest of recent changes, affected files, requirement references, decision-log activity, and scan limitations.
    • Added human-readable and JSON output options with privacy-safe details, deterministic exclusions, and escaped invalid characters.
    • Limited human-readable summaries to 10 lines while reporting omitted content and preserving scope counts when scans are incomplete.
    • Added fail-safe status reporting for project-check failures and unexpected errors; invalid command usage returns an error.

Round 11

Two test-coverage findings. The zero-wiring test was genuinely too narrow on both counts it was challenged on, and is rewritten in bbaccfa0: the gate files are now discovered (everything under .github, any depth, any extension) rather than globbed from workflows/*.yml, and both change-summary and change_summary are rejected, since the module path wires the command without ever writing the hyphen. Verified against three fixtures the previous version passed -- a settings.yml naming the command in required_status_checks, a nested composite action, and an underscore invocation in the Makefile. What a repository test cannot cover is stated in the docstring rather than implied: branch protection and rulesets are account-side, not repository text.

The privacy finding is not reproducible -- test_degraded_inputs_carry_no_path_either combines the unreadable-file row with an absolute CFS_DECISION_LOG override and asserts the project root, the override path, the home directory and the username are all absent from the serialised payload, with both degraded conditions asserted in force first so it cannot pass vacuously. It was present at 31412cc0, the commit the re-verification names.

Round 12

Two findings, both partly or wholly right, both fixed in 0ca73f2a.

The tally label was a real misreport: report.unreadable aggregates every reason no marker could be established -- undeterminable scope and an unexpected scan failure as well as a failed read -- while the line called it "could not be read or parsed", so a scope-policy failure was shown to a reader as a file-access one. Now "yielded no marker information". The counter was deliberately not split: the report's checkable invariant is that a marker-less row is in exactly one of deleted/unreadable/not_a_file, and four counters would put a counter on each cause and that invariant on none, for a distinction the per-file reason already carries exactly.

The exit truth table was missing the parser forms -- only an unknown flag asserted exit 2, while an option given without its value is the form a person actually hits. --base, --since and --root without values plus an unexpected positional are now rows of the same table, eleven in total. The baseline matrix the finding also asked for was already rows 1--6 of that table and is unchanged; that part is answered on the thread with the row-by-row table.

Round 13

Two findings. GIT_DISCOVERY_ACROSS_FILESYSTEM was a duplicate of the thread answered in round 12 and is fixed on #154, where the file lives.

The other is right and fixed in 157dd25e: assert all(lines) passes on an empty list, so the no-blank-lines test accepted a digest that had vanished entirely -- and the same flaw ran through the two not any(...) assertions above it, which are vacuously true on an empty list too, so all three of that test's assertions were satisfied by no output at all. A len(lines) >= 3 guard now comes first, sized to the three lines this fixture always yields rather than to a floor of one, which would still have accepted two of the three going missing.

Round 14

Three findings, two of them real gaps in the tests and both fixed in e252746b.

The whole-payload privacy sweep combined an unreadable file with a shared log, but every event in it was dated and attributed, so the undated and runless fields were never in force while the path assertions ran -- and those two had only been exercised at unit level against an EventSelection, never through the real serialisation. All four degradations now run in one CLI invocation, each asserted in force before absence is checked, so the test cannot pass by having nothing to leak.

Separately, two independent exclusion rules had only their agreeing cases asserted: _is_own_invocation drops by command so a digest never reports its own footprint, while _TELEMETRY_EVENTS drops by kind so no invocation is a decision whoever issued it. An invocation from another command is where they disagree, and that row is now pinned -- collapsing the two rules into one fails it.

The third, a missing regression for injected git configuration masking untracked files, is not reproducible: test_ambient_git_config_cannot_hide_a_new_file_from_the_listing is parametrized over all four injection interfaces and asserts the untracked file is still in the linkage, at line 592 of tests/test_change_summary_links.py in this PR's diff.

Round 15

One real defect, in the ordering rather than the counting, fixed in 73f4e92d.

The four decision log: lines are how the digest admits it could not see everything, and they were emitted after the per-run breakdown while the ceiling cuts from the end. So a change set large enough to reach ten lines made the digest stop reporting that three log lines were unreadable in order to keep printing which runs the readable ones came from -- the silent omission this command exists to avoid, arriving through the ordering. The breakdown now goes last, which also reads better: a caveat about the decision count sits beside that count.

Stated as a priority rather than a guarantee. My first draft of the test claimed the four integrity lines always fit; the fixture disproved it, because the omission summary takes a slot of its own so even the smallest overflow costs two lines. The claim is gone rather than weakened, and the mitigation is named: the omission count plus the JSON, which carries every one of these counts as a field whatever the human rendering had room for. Spec steps 6 and 7 now carry the rule, including the fact that made this a defect and was previously written down nowhere -- that the ceiling cuts from the end, so emission order is sacrifice order.

Three further re-verifications reported against this head are already fixed in it, each verifiable with one git show at 19c26a27: the empty-digest guard (line 403), the exclusion-rule divergence test (line 561), and the undated/runless conditions in the privacy sweep (lines 821-822). Answered individually on their threads.

Round 16 — rebased onto main, #154 having merged

#154 landed as ac9e06e9, squash-merged, so this branch is rebased directly onto main rather than restacked. Its fifteen commits replay unchanged and the diff is now exactly its own four files: the digest command, its CLI registration, its spec section and its test suite.

The granularity story closes better than it opened, which was the point of the gating task behind it. Before this series the default branch sat at 0.4600 — exactly on the floor, less than one rounding step of buffer, so the next branch to add lines without declarations would have failed the gate and its author would reasonably have read the failure as their own regression. With #154 merged main is at 0.4603 (15.1 line-granularity units), and this branch takes it to 0.4605 (25.4 units). Both new modules sit above the system score rather than near it: utils/change_summary.py at g = 0.5474 over 548 effective lines, commands/change_summary.py at g = 0.5195 over 231.

Re-verified on the new base: 5,635 passed, 4 skipped, 15 xfailed; cfs validate 0 errors; pylint 10.00; make vulture-ci clean.

Round 17

Three findings, one of them this PR's.

The no-decisions line mislabelled its count. len(events) is what is left after the window filter and after this command's own invocations are dropped, and the line called it "scanned" -- so a log holding a hundred older entries reported 0 event(s) scanned having scanned all hundred. The word was wrong rather than the number; it now says how many events fell inside the window.

Carrying the log's true total is refused, with a reason. selection.scanned counts this command's own logged invocations, so exporting it makes two consecutive digests of an unchanged repository differ by one -- the determinism _is_own_invocation exists to hold. I added it before checking and the existing determinism test caught it, so the omission is load-bearing and is now asserted as a deliberate absence. There is no self-excluded variant available: the selection keeps no events from outside the window, so their invocations cannot be identified and subtracted.

The other two findings are not in this PR's diff and are answered in a comment below: a real newline-translation defect in utils/change_summary.py, which merged with #154 and is fixed in #160 -- a path containing CR was reported under a name that opens nothing, so a tracked file present on disk came back as deleted -- and an observation about make ci discarding act's exit status, which came from #156 and is a maintainer's call.

Round 18

One finding, and a fair one: the two filtering rules were each covered in slices, so no single assertion failed on the partition itself. Rather than add a fifth overlapping fixture, the one category that was missing -- a foreign command's invocation, the case the two rules disagree on -- joined the existing telemetry test, whose assertions now cover the whole population in both renderings. The by_event equality is what makes it a partition check: two invocation events go in and exactly one comes out, so it fails whether the self-rule stops dropping ours or starts dropping the foreign one.

Verified by the review's own criterion -- mutating either rule alone fails it. Its narrower siblings stay, since they pin what this one does not: that the exclusion is by kind rather than by instance, and that the divergent case holds through the real CLI rather than a hand-built selection.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 10383107-20ae-4233-8f2c-c1cf1099f8b7

📥 Commits

Reviewing files that changed from the base of the PR and between 09f0ae4 and de51e66.

📒 Files selected for processing (2)
  • skills/studio/scripts/studio/commands/change_summary.py
  • tests/test_change_summary_cmd.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • skills/studio/scripts/studio/commands/change_summary.py

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Adds the cfs change-summary command. It resolves change windows, reports changed files and requirement markers, filters decision events, and renders bounded human or JSON output with fail-safe handling.

Changes

Change Summary Digest

Layer / File(s) Summary
Changed-file linkage and window resolution
skills/studio/scripts/studio/commands/change_summary.py, architecture/features/developer-experience.md
The command checks project scope, resolves windows, collects changed files, and links requirement markers with denominators and degradation reasons.
Digest composition and CLI integration
skills/studio/scripts/studio/commands/change_summary.py, skills/studio/scripts/studio/cli.py, architecture/features/developer-experience.md
The command filters telemetry and self-invocations, composes bounded JSON-safe digests, handles failures, and registers change-summary in the CLI.
Command contract validation
tests/test_change_summary_cmd.py, architecture/features/developer-experience.md
Tests cover output limits, degraded paths, privacy, determinism, escaping, registration, help text, and exclusion from repository gates.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to de51e

This adds an advisory, bounded change-summary command with human and JSON output. Current coverage supports its output, privacy, failure-handling, and non-gating behavior, with no unresolved merge risk identified.

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant ChangeSummaryCommand
  participant Git
  participant DecisionLog
  CLI->>ChangeSummaryCommand: invoke change-summary
  ChangeSummaryCommand->>Git: resolve window and changed files
  ChangeSummaryCommand->>DecisionLog: filter decision events
  ChangeSummaryCommand->>CLI: render bounded digest or JSON payload
Loading

Suggested reviewers: ainetx

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.61% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 221 functions across 12 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the advisory cfs change-summary command requested by issue #131. It covers change and decision-log summaries, deterministic output, degraded-state reasons and denominators, the ten…
Out of Scope Changes check ✅ Passed The changed documentation, CLI registration, command implementation, and tests directly support issue #131 and the PR objectives. No unrelated code changes are identified.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: adding the advisory cfs change-summary command and describing its purpose.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@code-ranker-app

code-ranker-app Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

code-ranker

Built on a fork. View full report ↗

python
Metric Baseline Current Δ
Structure
Files 131 132 +1
Edges 352 357 +5
Complexity
cognitive — Cognitive complexity 114 114 $\color{#2a7a30}{-0.577}$
cyclomatic — Cyclomatic complexity 116 116 $\color{#2a7a30}{-0.232}$
Coupling
fan_in — Incoming dependencies 3.6 3.6 -0.021
fan_out — Outgoing dependencies 4.2 4.2 +0.01
hk — God-object risk 1.5M 1.4M $\color{#2a7a30}{-34.8K}$
Halstead
bugs — Estimated bugs 3.3 3.3 $\color{#2a7a30}{-0.008}$
effort — Implementation effort 2M 2M $\color{#2a7a30}{-11K}$
length — Total tokens 1926 1923 $\color{#2a7a30}{-3.2}$
time — Coding time (s) 110K 109.4K $\color{#2a7a30}{-610}$
vocabulary — Distinct symbols 254 255 $\color{#c0392b}{+0.481}$
volume — Code volume 17.6K 17.6K $\color{#2a7a30}{-39.3}$
Lines of Code
blank — Blank lines 65.4 65.3 -0.103
cloc — Comment lines 120 121 +0.691
sloc — Source lines 412 411 -1.3
Maintainability
mi — Maintainability index 47.2 46.6 $\color{#c0392b}{-0.521}$

@Oleg67
Oleg67 force-pushed the feat/change-summary-command branch from bdb825e to 12a9252 Compare September 4, 2026 08:41
@Oleg67

Oleg67 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Restacked on #133's 34ddd47d, which fixes two CodeRabbit findings on the linkage half — the record query ran without the sanitised git environment, and a failed ls-files was treated as an empty untracked list. The digest command itself is unchanged; the head is now 12a92526.

On the restacked head: full suite 5,381 passed, 213 tests across the three change-summary suites, cfs validate 239/239, granularity 0.4619. Description refreshed and pinned to the new head.

Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/commands/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/codebase.py Outdated
Comment thread skills/studio/scripts/studio/commands/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/commands/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread architecture/features/developer-experience.md
@Oleg67
Oleg67 force-pushed the feat/change-summary-command branch 2 times, most recently from 4672e3d to 89ba388 Compare September 4, 2026 13:03
Comment thread skills/studio/scripts/studio/commands/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread architecture/features/developer-experience.md
Comment thread skills/studio/scripts/studio/commands/change_summary.py
@ainetx

ainetx commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

--json output can crash with UnicodeEncodeError on non-UTF-8 filenames

Severity: Major

Problem
_git_records() in change_summary.py decodes git's NUL-delimited stdout with errors="surrogateescape" specifically to round-trip non-UTF-8-but-legal filenames. Those decoded strings flow unmodified into FileLink.path and into report.files, which compose_digest() serializes into the JSON payload. ui.result() (skills/studio/scripts/studio/utils/ui.py) then does print(json.dumps(data, indent=2, ensure_ascii=False)). json.dumps happily returns a Python str containing the lone surrogate code points (it does not validate/escape them when ensure_ascii=False), but print() must encode that str to bytes for stdout using the interpreter's stdout encoding and its default 'strict' error handler. Encoding a lone surrogate under 'strict' raises UnicodeEncodeError, which is not caught anywhere in cmd_change_summary or _compose_safely (the try/except in _compose_safely wraps compose_digest, not the later ui.result call).

How to reproduce

  1. In a Studio project, create a git-tracked or untracked file whose name contains an invalid-UTF-8 byte (e.g. via printf to build a byte sequence like b'we\xffird.py' as the filename) so it appears as a changed/untracked entry.
  2. Run cfs change-summary --json (stdout typically UTF-8 with strict handler in most environments).
  3. _git_records decodes the filename with surrogateescape, producing a str with a lone surrogate; this flows into report.files[*].path in the JSON payload.
  4. ui.result() calls print(json.dumps(payload, ensure_ascii=False)); encoding the surrogate to stdout raises UnicodeEncodeError, propagating out of cmd_change_summary uncaught.

Expected behavior
Per the module's advertised contract ('never raises... a stated reason, not a traceback', exit 0 on every path except a usage error), a file with an undecodable name should still produce a valid --json payload and exit 0, with the path perhaps escaped/lossily represented.

Actual behavior
The process raises UnicodeEncodeError while writing the JSON result and exits with a non-zero code (an unhandled Python exception), contradicting the 'advisory, never raises' design this exact command markets.

git filename (non-UTF-8 bytes)
  -> _git_records() decode(errors='surrogateescape') -> str with lone surrogate
  -> FileLink.path / report.files -> compose_digest() JSON payload
  -> ui.result(): print(json.dumps(payload, ensure_ascii=False))
  -> encode to stdout (default 'strict') -> UnicodeEncodeError (uncaught) -> process crash / nonzero exit

Impact
A single oddly-named file (deliberately or accidentally present, e.g. from a bad extraction, a foreign-locale checkout, or a malicious repo) makes cfs change-summary --json crash instead of degrading gracefully, defeating the exit-0/advisory guarantee that is the command's core selling point and DoD item ('Exit 0 on every path except a usage error').

Suggested correction
Sanitize surrogate-escaped path strings before placing them in the JSON payload (e.g. re-encode with 'surrogateescape' and decode with 'backslashreplace'/'replace' for display), or have ui.result()/print use an explicit UTF-8 writer with errors='backslashreplace' when writing JSON output, and/or wrap the final print in the last-resort guard.

How to verify
Add a test that creates a changed file with a non-UTF-8-decodable name and runs cmd_change_summary(['--json']), asserting it returns 0 and produces valid JSON rather than raising.


Original location: skills/studio/scripts/studio/utils/ui.py:118 -- inline anchoring could not be resolved after 1 attempt(s).

Comment thread skills/studio/scripts/studio/commands/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
Comment thread skills/studio/scripts/studio/utils/change_summary.py
@Oleg67
Oleg67 force-pushed the feat/change-summary-command branch 2 times, most recently from e252746 to 19c26a2 Compare September 8, 2026 12:17
Comment thread tests/test_change_summary_core.py
Comment thread skills/studio/scripts/studio/commands/change_summary.py
@Oleg67
Oleg67 force-pushed the feat/change-summary-command branch from 19c26a2 to 73f4e92 Compare September 8, 2026 14:54
`cfs change-summary` composes at most ten lines from the window, the
changed-file linkage and the decision-log selection: the span covered,
what changed with its marker denominator, the requirements served, and
the decisions recorded while the work was done. `--json` carries the data
behind every line.

Advisory is structural, not asserted:

* exit 0 on every path except a usage error (2). A test forces each row
  of the behaviour matrix; a last-resort guard turns an unforeseen
  exception into a stated reason rather than a traceback; and a test
  greps the Makefile and workflows so the command cannot be promoted
  into a gate quietly;
* the ceiling is a ceiling — only lines backed by data are emitted, and
  when it bites the last line says how many were cut. No window and no
  changes are each one line, deliberately;
* every degraded dimension states its reason and its denominator;
* the digest never counts itself: telemetry events are not "why", and
  this command's own invocations are excluded from the payload, so
  consecutive runs are byte-identical although each logs an invocation;
* no absolute path in either rendering; no network; no commit author.

Registered in all five dispatch tables. Spec: a flow, a nine-rule
composition algo, a definition of done, the module row and an acceptance
criterion; TOC refreshed.

Tests: 34 across unit, real-CLI integration, golden fixture with pinned
commit dates, edge, invariant, privacy, fail-safe, determinism and scope
reporting; module at 100% line coverage. Eight mutation checks each fail
only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>
…rules

Maintainer round on the digest command, alongside the linkage fixes it
now sits on:

* The changes and markers lines named `changed` as their denominator
  while every tally was computed over the entries actually examined, so
  a capped scan read "300 of 1,500" as a breakdown of 1,500. When the
  cap bit, both lines now name the examined population, and the payload
  carries `examined` beside `changed`.
* A project check that *failed* — permission error, unreadable mount —
  was folded into "not inside a Studio project" and logged at debug. It
  now has its own reason carrying the exception type, and is logged at
  warning like the last-resort guard.
* The not-a-file tally from the linkage half is rendered, and the
  unreadable label says "read or parsed" now that a parse failure is
  reported as one.
* `--help` gains an epilog stating the rules that shape the output: the
  window's origin, the scope policy, the examined ceiling (quoted from
  the one constant the report uses), both marker directions, the line
  ceiling and its omission rule, and that nothing is published.
* The no-gate test also scans `*.yaml` workflows and any pre-commit
  configuration, and asserts it scanned at least two files.

The linker is called with the window alone, per its new signature. Six
mutation checks each fail only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>
… with its anchor

Maintainer round 2 on the digest command, alongside the linkage fixes it
now sits on:

* A changed file with a non-UTF-8 name crashed `--json`: git paths are
  decoded with surrogateescape so they round-trip, but a lone surrogate
  cannot be printed, and the UnicodeEncodeError rose from inside `print`,
  past the last-resort guard. Every string in the payload — the human
  lines included — is now made encodable once, in one place; undecodable
  bytes render as `\xNN` escapes. Tested against a strictly encoding
  output stream, since a StringIO would never have noticed.
* Two runs sharing their first eight characters rendered as identical
  labels. The prefix now grows until the shown ids differ, as git does.
* The window line reads "since <bound> against <ref> @ <sha>", so an
  explicit `--since` composed with `--base` (which the linkage half now
  supports) shows both, and `--help` says the two compose.
* Boundary tests at exactly the cap and one over, for the requirements
  and runs "+N more" arithmetic.

Three mutation checks each fail only the tests written for them.

Signed-off-by: ou <ou@constructor.tech>
…me kind

`surrogateescape` re-encodes only the surrogates it produced itself
(U+DC80–U+DCFF); any other lone surrogate — a `"\ud800"` that a decision-
log line carries through `json.loads` — made the sanitiser raise, and the
whole digest fell to the last-resort guard for one bad event field. Those
are now escaped as themselves, so the decisions dimension states the
value and the rest of the digest stands. Reverting the fallback fails the
test.

Signed-off-by: ou <ou@constructor.tech>
… shown or folded

The prefix width was computed over the three runs shown, so a fourth run
folded into "(+N more)" that shared its first eight characters with a
shown one left that label matching two runs in the log, and the digest
gave no sign of it. The width is now computed over every run in the
window: a prefix printed is a prefix unique in the log it points into.

Test: a folded run sharing eight characters with a shown one widens the
shown label to nine; computing the width over the shown runs alone fails
only that test.

Signed-off-by: ou <ou@constructor.tech>
…ributed bucket, and leave a trail on the escape fallback

Maintainer re-verification round on the digest command.

- The payload counted events with no run id; the plain digest never said
  so, and the runs line under-reported the breakdown with no sign of it.
  A `decision log: N event(s) carry no run id` line now appears when the
  count is non-zero, beside the skipped, undated and shared lines.
- The unattributed bucket was cut to eight characters like an id and read
  `(unattri`. It is a word, not an identifier, and is rendered whole.
- The fallback for a lone surrogate that `surrogateescape` did not make --
  a corrupt decision-log line -- escaped it silently. It now warns, at
  the level the module's other guards use, so a mangled field has a trail.
- `_is_own_invocation` documents that the exclusion is by kind, not by
  instance: every change-summary invocation is a read of the log, and
  excluding only the current run's would break the determinism the suite
  pins.

Tests: the exit-and-status truth table over the behaviour matrix; a real
overflow at the default ceiling agreeing with the payload's omission
count; two invocations from distinct runs both excluded with the decision
kept; the runless line and the full unattributed label; the exact
two-byte escape surviving JSON; the warning on a non-filename surrogate
and silence on a filename one; the privacy test asserting a digest was
produced before checking what it omits.

Signed-off-by: ou <ou@constructor.tech>
The privacy test covered the ordinary digest; the degraded fields -- a
file that could not be read carrying a reason, a shared log carrying the
fact of its override -- are where a path would leak if anywhere. With an
unreadable changed file and an absolute CFS_DECISION_LOG override both in
force, the serialised payload is asserted to contain neither the project
root, the override path, the home directory nor the user, and to show
that both degradations were actually present.

Signed-off-by: ou <ou@constructor.tech>
The error-scenario list grouped "no changes against the base" with the
outcomes that state a reason and a denominator, while the command prints
one line naming the base and nothing else -- deliberately, as the
description says. The spec now describes the one-line outcome as its own
scenario and says why no zero-file denominator is printed with it.

Signed-off-by: ou <ou@constructor.tech>
…mension that is unavailable

The scenario sentence promised a reason and a denominator for every
degraded outcome; an unavailable dimension has nothing to count and
states its reason alone, which is what the command prints. The sentence
now says so.

Signed-off-by: ou <ou@constructor.tech>
… the boundary

The count was pinned at one length. A review read that as an off-by-one and
proposed `len(lines) - LINE_CEILING`, which reports 1 where two source lines
are genuinely absent -- so the digest would understate its own omission at
every overflow, which is the one direction that matters for a command whose
purpose is making what it drops visible.

Parametrized at ceiling-1, ceiling, ceiling+1, ceiling+2 and well past, with
`dropped` counted by asking which input strings are missing from the output
rather than by any formula. Applying the proposed change fails this test at
three of the five lengths.

Signed-off-by: ou <ou@constructor.tech>
…, and reject both spellings

The zero-wiring promise was checked against an enumerated set --
`workflows/*.yml`, the Makefile, pre-commit -- and only against the
hyphenated CLI name. Both limits let the failure it exists to catch pass one
directory over: a gate added as a composite action, a nested workflow, or a
`settings.yml` naming required checks was never opened, and
`python -m studio.commands.change_summary` wires the command without ever
writing the hyphen.

Now everything under `.github` at any depth and whatever the extension, plus
the Makefile and any pre-commit config, with both spellings rejected.
Verified against three fixtures the previous version passed: a settings file
naming the command in `required_status_checks`, a nested composite action
using the module path, and an underscore invocation appended to the
Makefile. Each fails the test now; removing it passes.

Two limits stated in the docstring rather than left to be discovered again:
branch protection and rulesets are account-side configuration and not
repository text, so no test here can assert the required-status-check list;
and `pyproject.toml` is excluded by intent, since it declares tool
configuration rather than gate invocations and its coverage and vulture
sections name modules legitimately.

Signed-off-by: ou <ou@constructor.tech>
…ish the exit truth table

The tally label said "could not be read or parsed", but the counter behind
it holds every reason no marker could be established -- undeterminable
scope and an unexpected scan failure as well as a failed read. So a
scope-policy failure was reported to a reader as a file-access one. Now
"yielded no marker information", which is what the count actually means;
the per-file `reason` in the JSON still names the individual cause, so no
detail is lost, only the false specificity. Pinned by a test that forces a
real scope failure and asserts both halves: the row keeps
`REASON_SCOPE_UNKNOWN`, and the line does not name one cause for all of
them.

The exit truth table asserted its six baseline rows and one usage error, an
unknown flag. An option supplied without its value is the form a person
actually hits and was untested, so `--base`, `--since` and `--root` without
values and an unexpected positional are now rows of the same table, each
asserting exit 2 and status ERROR -- keeping "a usage error is the only
non-zero exit" checked across every way the parser can fail rather than one.

Signed-off-by: ou <ou@constructor.tech>
…ings about it

`assert all(lines)` passes on an empty list, and so does `not any(...)`, so
a regression suppressing the whole human digest satisfied all three
assertions in this test. The non-empty guard now comes first, which is what
gives the other three their force. Verified by returning `[]` from the
composition for this fixture: the test fails, and passes again once
restored.

Signed-off-by: ou <ou@constructor.tech>
…pin the two exclusion rules where they diverge

The whole-payload privacy sweep combined an unreadable file with a shared
log, but ran on events that were all dated and all attributed, so the
undated and runless fields -- separate reported fields, and as good a place
for a path to surface as any -- were never in force while those assertions
ran. They had only been exercised at unit level against an `EventSelection`,
never through the real serialisation. Both are now in the same sweep and
asserted in force before absence is checked, so the test cannot pass by
having nothing to leak.

Separately, two independent rules drop events and only their agreeing cases
were asserted. `_is_own_invocation` drops by command, so a digest never
reports its own footprint; `_TELEMETRY_EVENTS` drops by kind, so no
invocation or read is a decision whoever issued it. An `invocation` from
another command is where they disagree -- retained among `events` and in
`by_event`, contributing nothing to `decisions` -- and that case is now
pinned. Widening the first rule to drop any invocation, which collapses the
two into one, fails it.

Signed-off-by: ou <ou@constructor.tech>
…ty lines first

The digest emitted the per-run breakdown before the four "decision log:"
lines, and the ceiling cuts from the end -- so the lines admitting the log
could not be read fully were structurally the *first* thing dropped. A
change set large enough to reach ten lines therefore made the digest stop
reporting that three log lines were unreadable in order to keep printing
which runs the readable ones came from. That is the silent omission this
command exists to avoid, arriving through the ordering rather than through
the counting.

The breakdown now goes last. It also reads better: a caveat about the
decision count sits beside that count, and the per-run detail is what a
reader can most afford to lose.

Stated as a priority rather than a guarantee, in the code, the spec and the
test: the omission summary occupies a slot of its own, so even the smallest
overflow costs two lines and a deep enough one still reaches an integrity
line. What covers that case is the omission count plus the JSON, which
carries every one of these counts as a field whatever the human rendering
had room for. An earlier draft of the test claimed the four always fit; the
fixture disproved it, and the claim is gone rather than weakened.

One assertion elsewhere was pinned to a line index rather than its content
and moved with the order; it now matches on the label it was always about.

Signed-off-by: ou <ou@constructor.tech>
@Oleg67
Oleg67 force-pushed the feat/change-summary-command branch from 73f4e92 to 09f0ae4 Compare September 8, 2026 15:08
Comment thread tests/test_change_summary_cmd.py
@ainetx

ainetx commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Diff and untracked-sweep readers disagree on newline translation, breaking dedup for CR-bearing paths

Severity: Minor

Problem
_git_records (used for the tracked diff) invokes subprocess.run with text=True and no newline override, which enables universal-newlines translation on the decoded output before the record split on '\0'. _pump_records (used for the untracked sweep) decodes each raw NUL-delimited byte slice directly via bytes.decode(_PATH_ENCODING, _PATH_ERRORS), with no such translation. Both share the same encoding/errors constants, but only one of them normalizes '\r\n'/'\r' to '\n'.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. Create a tracked file modification and, separately, an untracked file whose name contains a literal carriage return byte (legal on POSIX, only NUL and '/' are forbidden in filenames). 2. Perform a git rm --cached on a file with such a name so it appears as deleted in the tracked diff and newly untracked in the sweep (the exact overlap scenario _collect_changed_entries's docstring describes). 3. The diff-derived string via _git_records has the CR translated away; the sweep-derived string via _pump_records keeps the raw CR. 4. seen.setdefault(rel_path, status) and skip=seen compare these as different keys.

Expected behavior
The same on-disk path, regardless of which git subcommand reported it, should dedupe to one key in seen/skip so it appears exactly once in the changed-entries listing.

Actual behavior
A path containing '\r' or '\r\n' is captured as two distinct strings by the two readers, so the untracked sweep's copy is not recognized as already-seen and the file can be double-counted/double-listed, reintroducing the exact duplicate-file bug this change's own docstring says it fixes for the encoding-mismatch case.

git diff -z --name-status --text=True(universal newlines)
"a\r\nb" -> decoded+translated -> "a\nb"
git ls-files -z --others (raw bytes, Popen pipe)
b"a\r\nb" -> decode only -> "a\r\nb"
seen["a\nb"] != skip-lookup("a\r\nb") => not deduped

Impact
A rare but real class of filenames (containing CR bytes) can be reported twice in the changed-files digest, inflating counts and producing a contradictory duplicate row for one physical file -- the same failure mode the surrounding comments explicitly call out as unacceptable.

Suggested correction
Pass newline='' (or otherwise disable universal-newlines translation) to the subprocess.run call in _git_records so its decoding matches _pump_records exactly, or perform the split on raw bytes before decoding in both readers.

How to verify
Add a git fixture with a tracked-then-uncached file whose name contains '\r', run _collect_changed_entries, and assert the entry is reported exactly once with the same string in both code paths.


Original location: skills/studio/scripts/studio/utils/change_summary.py:811 -- inline anchoring could not be resolved after 1 attempt(s).

@ainetx

ainetx commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

ci target only checks for an empty job list, not act's exit status

Severity: Minor

Problem
In the ci target, jobs="$(act push --list $(ACT_FLAGS) 2>/dev/null | tail -n +2 | awk '{print $2}' | grep -Ev '^(sonarqube|code-ranker)$')" captures only stdout through a pipeline; the exit code of act is discarded (no set -o pipefail, no ${PIPESTATUS[0]} check). The subsequent guard is if [ -z "$jobs" ]; then... exit 1; fi, which only detects a fully empty result.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. act crashes or errors partway through listing jobs (e.g. a malformed workflow further down the list, a permission error, a binary bug) after already printing a header line and a few valid job rows to stdout, with a non-zero exit code and diagnostic text sent to stderr (discarded by 2>/dev/null).
  2. jobs is assigned the partial, truncated list of job names that were printed before the failure.
  3. [ -z "$jobs" ] is false because the string is non-empty.
  4. The for job in $$jobs loop runs only over the truncated job set and the target can report ✓ All CI jobs passed. despite act's own listing invocation having failed and omitting jobs.

Expected behavior
The guard should fail loudly whenever act push --list itself did not succeed, regardless of whether it produced some non-empty (but potentially truncated) output — e.g. by checking ${PIPESTATUS[0]} (with SHELL:= bash and appropriate .SHELLFLAGS) or restructuring to capture and check act's own exit code before piping.

Actual behavior
Only the string emptiness of the final piped result is checked; a partial/truncated job list from a failing act invocation passes the guard and runs a smaller-than-intended job set while reporting overall success.

act push --list (crashes mid-list, exit != 0, stderr->/dev/null)
-> partial stdout (some job rows) 
-> tail/awk/grep -> jobs="job1 job2" (non-empty)
-> [ -z "$jobs" ] is FALSE -> guard passes
-> loop runs only job1, job2 -> "✓ All CI jobs passed." (misleading)

Impact
A partial or corrupted job discovery from act can silently narrow the set of CI jobs actually run locally, while the Makefile reports full success — exactly the 'zero-iteration loop exits 0' failure mode the surrounding comment says it wants to prevent, just triggered by a non-empty-but-wrong list instead of a fully empty one.

Suggested correction
Capture act's own exit status explicitly, e.g. run act push --list $(ACT_FLAGS) > /tmp/act_jobs.$$ 2>&1; rc=$$?; jobs=$$(tail -n +2 /tmp/act_jobs.$$ | awk '{print $$2}' | grep -Ev '^(sonarqube|code-ranker)$$'); if [ $$rc -ne 0 ]; then echo 'ERROR: act push --list failed' >&2; exit 1; fi or set SHELL:= /bin/bash with .SHELLFLAGS:= -eu -o pipefail -c and check ${PIPESTATUS[0]}.

How to verify
Simulate an act failure that still emits partial stdout before exiting non-zero (a wrapper script/stub) and confirm make ci now exits non-zero with a stated error, instead of proceeding to run the truncated job list.


Original location: Makefile:384 -- inline anchoring could not be resolved after 1 attempt(s).

"changed": report.changed, "examined": report.examined, "marked": _marked(report),
"linked": report.linked, "declaring": report.declaring,
"excluded": report.excluded, "deleted": report.deleted,
"unreadable": report.unreadable, "not_a_file": report.not_a_file,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision-log scan denominator is omitted and human output mislabels the filtered count

Severity: Minor

Problem
EventSelection.scanned counts every parseable decision-log event before timestamp filtering, but the command exports only the number of retained events. The human no-decisions line labels len(events) as 'scanned' even though events has already been filtered to the window and stripped of change-summary invocation events. Thus the command hides the actual denominator and can report '0 event(s) scanned' after it did scan older or self-invocation events.

Reproduction, impact, suggested fix, verification

How to reproduce

  1. Put a valid event before the resolved window boundary in the decision log. 2. Run cfs change-summary in human and JSON modes. 3. Observe EventSelection.scanned is 1, decisions.events is 0, JSON has no scanned field, and the human output can say '0 event(s) scanned'.

Expected behavior
Both JSON and human output should distinguish total parseable events scanned from events retained in the window, using EventSelection.scanned as the denominator.

Actual behavior
_decisions_payload omits selection.scanned. _decision_lines uses len(events), a filtered and self-invocation-excluded list, in text labelled 'scanned'.

decision-log snapshot -> parseable events (selection.scanned) -> timestamp window filter -> selection.events -> remove self invocations -> displayed events/decisions.\nCurrent output exposes only the final stage but labels it scanned.

Impact
Machine consumers cannot determine whether an empty decision digest examined no entries or excluded entries outside the window. Human readers can receive a false scan count, weakening the command's stated denominator and degraded-result diagnostics.

Suggested correction
Add decisions.scanned to _decisions_payload. Render selection.scanned distinctly in human output, for example 'no decisions recorded in this window (0 retained of 1 event scanned)', and add fixtures containing pre-window and self-invocation events.

How to verify
Verified on reviewed commit 09f0ae4. select_events increments scanned before timestamp filtering in utils/change_summary.py lines 709-718 and stores it at line 729. commands/change_summary.py _decisions_payload lines 341-350 omits it; _decision_lines lines 230-246 reports len(filtered events) as scanned.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Half right, and the right half is fixed in 84050ef1. The other half is refused with a reason, and I nearly made the change before checking.

The label was wrong. len(events) is the events left after the window filter and after this command's own invocations were dropped, and the line called it "scanned". So a log holding a hundred older entries reported 0 event(s) scanned having scanned all hundred — a denominator that was not one. The word is wrong rather than the number, so the word is what changed:

lines = [f"why: no decisions recorded in this window ({len(events)} event(s) in it)"]

Pinned by test_the_no_decisions_line_counts_the_window_not_the_whole_log, which sets scanned=100 against a single in-window event and asserts the line says 1, does not contain the word "scanned", and does not contain 100.

Carrying selection.scanned in the payload is the part I cannot do, and it is not an oversight. It counts every parseable event in the log — including this command's own invocations, which the dispatcher records on every run. So exporting it makes two consecutive digests of an unchanged repository differ by one. That is precisely the determinism _is_own_invocation exists to hold, and it is already pinned:

assert len({out for _, out in as_json}) == 1, \
    "the payload's totals too — telemetry is excluded from 'why', but only the " \
    "self-exclusion keeps the event count from growing by one per run"

I added "scanned": selection.scanned first and let the suite decide. It failed:

FAILED test_consecutive_runs_are_byte_identical_although_each_logs_an_invocation

So the omission is load-bearing. And there is no self-excluded variant to offer instead: EventSelection keeps no events from outside the window — only the count — so the invocations among them cannot be identified and subtracted. A denominator that is right for one run and wrong for the next is worse than a narrower one that is always right, in a command whose output is meant to be diffable.

It is now asserted as a deliberate absence, test_the_payload_omits_the_log_total_because_it_grows_every_run, because adding it looks like an improvement — it is the change that test exists to stop. The stable counts are all carried and asserted present: events, decisions, undated, runless, skipped_lines.

If the log's true total is wanted despite the drift, the honest way is for the core to expose a count that excludes invocations by command — which is a change to EventSelection in utils/change_summary.py and belongs in its own PR rather than here. Say the word and I will open it.

Suite at 84050ef1: 5,637 passed, cfs validate 0 errors, granularity 0.4605, pylint and vulture-ci clean.

The no-decisions line printed the events left after the window filter and
after this command's own invocations were dropped, and labelled that number
"scanned". So a log holding a hundred older entries reported "0 event(s)
scanned" having scanned all hundred -- a denominator that was not one.

The word is wrong, not the number, so the word is what changed: the line now
says how many events fell inside the window, which is what it counts.

The review also asked for `selection.scanned` -- the log's true total -- to
be carried in the payload, and that one is refused with a reason. It counts
this command's own logged invocations, so exporting it makes two consecutive
digests of an unchanged repository differ by one, which is the determinism
`_is_own_invocation` exists to hold. Adding it fails
`test_consecutive_runs_are_byte_identical_although_each_logs_an_invocation`,
which documents exactly that. There is no self-excluded variant to offer
instead: the selection keeps no events from outside the window, so their
invocations cannot be identified and subtracted.

I had made that addition before checking, and the determinism test caught it.
It is now asserted as a deliberate absence, because adding it looks like an
improvement.

Signed-off-by: ou <ou@constructor.tech>
@Oleg67

Oleg67 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

The two findings that could not be anchored inline

Both landed on this PR but neither is in this PR's diff — it is four files now that #154 has merged: the digest command, its CLI registration, its spec section and its test suite. Answering them here rather than leaving them unaddressed.


1. Newline translation between the two readers — real, and worse than reported. Fixed in #160

This one is correct and precise. _git_records used text=True, which switches on universal newlines as well as decoding, and subprocess exposes no way to turn it off. The sweep's pump decodes raw slices and does not translate. Measured on a repository holding sweep\rname.txt:

text=True path : 'sweep\nname.txt'
raw-bytes path : 'sweep\rname.txt'
EQUAL? False

The reported consequence — deduplication missing the file — is real. But there is a second one that is worse, and it is a false statement rather than an incomplete one: the translated name opens nothing, so a tracked file present on disk came back as deleted.

rows: [('tracked\nname.txt', 'A', 'file no longer present')]
deleted: 1

The code is in utils/change_summary.py, which merged with #154, so it is not this PR's to fix. Opened as #160 against main: the captured reader now takes bytes and decodes each record with the same call the pump makes, so nothing in -z output is translated — only NUL separates records, and a path may contain any byte but NUL and /. Five tests, parametrized over cr / crlf / lf because the translation rewrites each differently, plus the tracked-not-deleted case and the git rm --cached overlap. Reverting the fix fails four of the five.

Worth noting the earlier round on #154 shared the codec between the readers for exactly this class of bug and I treated that as closing it. It did not: the codec and the newline translation are two separate behaviours of text=True, and only one was addressed.


2. make ci discards act's exit status — valid, but not this PR's code

Makefile is not in this PR's diff. That target came from #156 (fix(ci): make ci fail loudly when act job discovery yields no jobs), which merged separately.

The observation looks right to me on reading it: the pipeline captures stdout only, so with no set -o pipefail and no ${PIPESTATUS[0]} check, act failing outright is indistinguishable from act succeeding with every job filtered out. The [ -z "$jobs" ] guard catches the empty-result case, which is what #156 set out to fix, but an act that exits non-zero while printing a usable job list on stdout would still pass — and so would one whose failure happens to leave the list non-empty.

I have not tested that, and I am not going to change a CI target from a PR about the change-summary command. Flagging it as a maintainer's call: if it wants fixing, it belongs on its own change against Makefile, and I am happy to open one.

Comment thread skills/studio/scripts/studio/commands/change_summary.py
…ture, both renderings

Two independent rules filter events -- self-invocations by command,
telemetry by kind -- and each test exercised a slice of the result. So a
regression could retain self events or misclassify a foreign command's
telemetry without any single assertion failing on the *partition* itself.

The existing telemetry test already covered both renderings and held a
telemetry-only run; what it lacked was the one category the two rules
disagree on, a foreign command's invocation, which survives the
self-exclusion and is dropped by the telemetry rule. Rather than add a
fifth overlapping fixture, that case joins this one and the assertions are
extended to the partition: `by_event` must show exactly one surviving
invocation -- the foreign one, not ours -- and neither telemetry-only run
may be named in the breakdown.

Verified as the review asked: mutating either rule alone fails it. Dropping
the self-exclusion retains our own invocation, and emptying
`_TELEMETRY_EVENTS` makes all three events decisions.

Its narrower siblings stay, since they pin properties this one does not:
that the exclusion is by kind rather than by instance, and that the
divergent case holds through the real CLI rather than a hand-built selection.

Signed-off-by: ou <ou@constructor.tech>
@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

change-summary: add an advisory 'what changed and why' digest command

3 participants