Skip to content

8: Diff renderer - #25

Merged
wjduenow merged 34 commits into
devfrom
feature/8-diff-renderer
May 3, 2026
Merged

8: Diff renderer#25
wjduenow merged 34 commits into
devfrom
feature/8-diff-renderer

Conversation

@wjduenow

@wjduenow wjduenow commented May 2, 2026

Copy link
Copy Markdown
Owner

Summary

Super plan for #8 — kept/dropped table + unified schema.yml diff.

Phase: detailing (awaiting approval)
Stories: 12 implementation + Quality Gate + Patterns & Memory = 14
Decisions: 21 captured (DEC-001 … DEC-021)
Reviews: security / performance / data-model+API / observability / testing — 3 blockers, 14 concerns, 38 passes; all blockers resolved into DEC-### items inside the locked design.

Plan document

plans/super/8-diff-renderer.md — full plan (1075 lines).

Locked design highlights

  • New signalforge.diff subpackage; sits between grade (Quality grader: rubric scoring of surviving artifacts #7) and CLI (CLI: signalforge generate command, config, exit codes #9). Zero new deps (PyYAML present, difflib stdlib, raw ANSI ~30 lines, plain text fixtures).
  • Three rendering surfaces: AnsiRenderer (terminal), MarkdownRenderer (GitHub-flavored, 60k char cap), JsonRenderer (sidecar). Concretes private; orchestrator selects via config.render_kind.
  • Always-emit sidecar <project_dir>/.signalforge/diff.json as the durable record (no separate JSONL audit log — Q5 → A; this is the first stage to skip the fail-closed JSONL pattern, justified by render-is-not-a-decision).
  • <MODEL_SQL>-style protections: _strip_ansi_escapes runs unconditionally on user content (DEC-007); _escape_markdown_scalar for table cells (DEC-008).
  • Three boundary checks at orchestrator entry per grade-layer.md precedent (DEC-002).
  • Reproducibility hashes (candidate_hash, prune_result_hash, grading_report_hash) on every DiffReport (DEC-016).

Out of scope (deferred to v0.2+)

  • ruamel.yaml round-trip / comment preservation
  • In-place edit ("apply") mode
  • Multi-model batched diffs
  • GitHub Action workflow itself (the renderer ships; the workflow is v0.3)
  • Structural (key-by-key) diff

Next steps

  • Review the plan in this PR
  • Approve in Claude Code (or comment "approved") to proceed to devolve (beads creation)

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added comprehensive diff rendering subsystem for explainable test changes, showing kept/dropped/flagged test artifacts with grading feedback
    • Multi-format output support: ANSI colors for terminal, Markdown for PR comments, and JSON for programmatic access
    • New diff: configuration namespace in signalforge.yml for rendering customization (output kind, diff truncation, size limits)
    • Automatic .signalforge/diff.json sidecar generation with reproducibility hashes

Super plan for #8 — kept/dropped table + unified schema.yml diff.
14 stories (12 implementation + Quality Gate + Patterns & Memory),
21 decisions, architecture review across security / performance /
data-model+API / observability / testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • feature/*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 99cbfe2a-9d1c-4e42-be0d-2f99fa14c390

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

wjduenow and others added 28 commits May 2, 2026 15:25
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_safety (US-004)

Two leaf modules for the diff renderer (#8):

- `signalforge.diff._ansi_safety.strip_ansi_escapes` — regex-based
  stripper for ANSI CSI escape sequences (DEC-007). Defends the
  Markdown sink against terminal-control injection from upstream
  manifest fields, LLM-drafted artifact text, and prune/grade
  reasons.
- `signalforge.diff._markdown_safety.escape_markdown_scalar` —
  escapes backtick/pipe/backslash; HTML-entity-encodes pipe and
  row-breaking control chars in table-cell mode (DEC-008).
  Backslash is processed first so subsequent escapes can't be
  unwound by a crafted trailing-backslash.

Tests cover bare CSI, color codes, reset, compound SGR, cursor
movement, no-escape passthrough, idempotence, empty string for
ANSI; backtick/pipe/backslash escaping, table-cell entity encoding,
control-char encoding, idempotence, empty string for Markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven-class typed exception hierarchy under signalforge.diff.errors,
mirroring the safety / draft / prune / grade precedent: every error
carries a class-level default_remediation that __str__ renders on a
separate ↳ Remediation: line, and every user-supplied string flowing
into a message routes through repr() (DEC-022 of #6) so adversarial
input cannot inject ANSI escapes or control characters into log
viewers.

Classes: DiffError (base) + DiffCandidateModelMismatchError,
DiffPruneResultModelMismatchError, DiffGradingReportModelMismatchError
(DEC-002 boundary checks at orchestrator entry);
DiffInputTooLargeError (DEC-006 existing-schema YAML byte cap);
DiffSidecarRecordTooLargeError (DEC-009 sidecar size cap);
DiffSidecarWriteError (fail-closed sidecar-write seam wrapping
underlying I/O cause via __cause__).

24 tests covering default_remediation rendering, repr() escaping for
ANSI-bearing inputs, subclass-of relationships, and field exposure.
Subpackage __init__.py ships only the error hierarchy in US-001;
later stories (US-002 result models, US-003 config, US-010
orchestrator) extend the public surface.

Traces to plans/super/8-diff-renderer.md US-001 (DEC-002, DEC-006,
DEC-009).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add signalforge.diff._emitter.emit_proposed_yaml(candidate, prune_result)
that filters tests to PruneDecision.decision == "kept", preserves column
declaration order, and sorts tests within each column by (type, args_hash)
for deterministic output. Mirrors the grade layer's args_hash convention
(blake2b-4 of canonical-sorted JSON of test args, DEC-009) so the
emitter, grader, and diff renderer agree on test identity.

yaml.safe_dump invoked with sort_keys=False, default_flow_style=False,
width=4096, allow_unicode=True. AR-9 round-trip test verifies edge-case
descriptions ('---', '!tag', triple-backticks, embedded newlines, leading
quote/pipe/gt, unicode) survive emit -> yaml.safe_load byte-identical.

Leaf module — depends only on existing signalforge.draft and
signalforge.prune model types. No __init__.py created (US-001 owns it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add src/signalforge/diff/_artifact_id.py with byte-equal mirror of
signalforge.grade.engine._artifact_id_for. Six dotted-path shapes
(column.<col>.{description,rationale}, model.{description,rationale},
test.column.<col>.<type>[.<args_hash>], test.model.<type>[.<args_hash>])
plus _model_test_args_hash and compute_args_hashes helpers.

Cross-stage parity is load-bearing: the diff renderer joins grade-
sidecar JSON to its rendered diff via (run_id, artifact_id,
criterion_id); a single shape disagreement would silently drop grade
rows. tests/diff/test_artifact_id.py is the documented single allowed
cross-stage import seam — production diff code must not import from
signalforge.grade at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds DiffEntry and DiffReport read-back-stable Pydantic v2 models to
src/signalforge/diff/models.py per US-002 of issue #8. Both models are
frozen=True, extra="ignore" with custom __repr__ per DEC-020 that omits
prose/diff text fields. DiffEntry.tier is a Literal["kept","dropped",
"flagged"] per DEC-012; DiffReport carries reproducibility-hash fields
(candidate_hash, prune_result_hash, grading_report_hash) per DEC-016.

Drift detector at tests/diff/test_models.py mirrors the prune / grade
precedent: StrictDiffEntry / StrictDiffReport extra="forbid" mirrors
validated against tests/fixtures/diff/diff_report_v1.json (which
exercises all three Tier values), plus field-set parity and
extra="forbid" sanity-floor checks.

Public-API re-exports deferred to US-012 per the bead instructions —
diff/__init__.py is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the `diff:` top-level namespace in `signalforge.yml` per
DEC-010 of plans/super/8-diff-renderer.md. `DiffConfig(extra="forbid",
frozen=True)` carries the nine locked knobs (context_lines,
max_why_chars, narrow_terminal_threshold, markdown_max_diff_chars,
existing_schema_size_limit_bytes, existing_schema_warn_at_bytes,
sidecar_size_limit_bytes, render_kind, respect_no_color_env);
`_DiffConfigFile(extra="ignore")` outer wrapper silently tolerates
sibling stage namespaces (`safety:`, `llm:`, `prune:`, `grade:`).
`load_diff_config(project_dir, path=None) -> DiffConfig` matches the
prior-stage signatures (load_grade_config / load_prune_config /
load_draft_config / load_safety_config) verbatim.

Numeric knobs route through a single positive-integer validator —
zero/negative caps would silently disable the DEC-006/DEC-009
protections or render an empty table. The literal `render_kind`
discriminator catches unknown render targets at config-load time.

Per US-003 task scope, the loader raises the base `DiffError` (with
remediation) for config-load failures rather than introducing a new
`DiffConfigError` subclass; the seven-class hierarchy from US-001 stays
intact, and a future `DiffConfigError` is a clean v0.2 refinement that
won't break imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Renderer ABC + AnsiRenderer concrete in
src/signalforge/diff/_renderers.py per US-008 of plans/super/8-diff-renderer.md.

* DEC-007 — strip_ansi_escapes runs UNCONDITIONALLY on every
  user-content field (artifact_id, why, drop_reason, test_type,
  unified_diff body, model_unique_id) BEFORE the renderer's own colour
  codes are emitted. Defence-in-depth: a malicious "\x1b[31mEVIL\x1b[0m"
  in upstream content renders as the literal text "EVIL" in BOTH
  coloured and non-coloured output.
* DEC-013 — narrow-TTY compact mode triggers when the effective
  terminal width is below DiffConfig.narrow_terminal_threshold (60
  by default). Compact mode drops the WHY column from the table and
  emits each entry's why as an indented "└─ ..." follow-up line.
* DEC-021 — six-step colour-precedence chain documented as
  early-returns in _should_emit_color: respect_no_color_env=False >
  force_color=False > force_color=True > FORCE_COLOR env > NO_COLOR
  env > sys.stdout.isatty().
* Module structured so US-009 can append MarkdownRenderer without
  restructuring; only the ABC + AnsiRenderer ship in this ticket.
* No _LOGGER calls; renderer does no I/O (returns string).

Tests at tests/diff/test_renderers.py cover the ABC contract, wide-TTY
6-column table, narrow-TTY compact mode, all six DEC-021 precedence
positions individually, and unconditional ANSI stripping in both
coloured and non-coloured output across the artifact_id, why,
follow-up why, model_unique_id, and unified_diff body fields. 25
tests, all passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add `signalforge.diff._sidecar` — fail-closed JSON sidecar writer for the
diff renderer (DEC-009). Mirrors `grade-layer.md` DEC-006/012 verbatim:
single-document overwrite, ``O_WRONLY | O_CREAT | O_TRUNC | 0o600``,
single ``os.write`` (looped on short returns), ``os.fsync``,
descriptor-release-only ``try / finally``. No ``except`` handler around
write/fsync — the propagation IS the defence.

* ``_DIFF_SIDECAR_RECORD_LIMIT_BYTES = 10_000_000`` (10 MB; an order of
  magnitude above the grade sidecar's 1 MB cap because diff text is
  larger by nature). Pre-write size check raises
  ``DiffSidecarRecordTooLargeError`` BEFORE any ``os.open`` so an
  oversize payload leaves no on-disk artefact.
* Symlink-hardened path canonicalisation at writer entry via
  ``signalforge.warehouse._path_safety.canonicalise_path``. Containment
  is gated against the **caller-supplied** ``project_dir``, not a
  derivation of ``sidecar_path`` (mirrors grade's post-QG fix). Failures
  wrap as ``DiffSidecarWriteError(cause=...)``.
* AST defence test in ``tests/diff/test_sidecar.py`` walks the module's
  syntax tree and asserts: (a) exactly one ``ast.Try`` block guards the
  ``os.write`` / ``os.fsync`` syscalls (the descriptor-release ``try /
  finally``), and (b) that block has zero ``except`` handlers. An
  accidental ``try / except OSError`` around the write/fsync would
  silently swallow the exact failure mode the fail-closed pattern
  exists to surface.

Test coverage (13 cases): happy-path round-trip via ``json.loads``,
``0o600`` mode bits, parent-directory creation, ``O_TRUNC`` overwrite
semantics, single ``fsync``, oversize pre-flight (no on-disk artefact),
symlink-escape rejection (containment gate against caller-supplied
``project_dir``), absolute-path-outside-project rejection, symlink-loop
rejection, short-write loop covering the full payload, zero-byte ``write``
raises ``OSError``, ``OSError`` propagation, and the AST defence above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends `signalforge.diff._renderers` with `MarkdownRenderer`:

- GitHub-flavored Markdown output (heading + count summary + GFM
  pipe-table + fenced ```diff block).
- Per-cell escaping via `escape_markdown_scalar(in_table_cell=True)`
  per DEC-008; pipe / backtick / backslash / row-breaking control
  chars are entity-encoded inside cells, while the fenced ```diff
  block carries raw upstream content (the fence is the defence).
- DEC-005 truncation: when `unified_diff` exceeds
  `config.markdown_max_diff_chars` (default 60_000), the body is
  truncated at the last complete `@@` hunk boundary; a footer
  `... (N more lines truncated — see <project_dir>/.signalforge/diff.json
  for full diff)` lives INSIDE the fenced block. Falls back to a
  line-boundary cut for hunk-less bodies.
- DEC-007 strip-then-escape composes: `strip_ansi_escapes` runs
  unconditionally on every user-content field (model_unique_id,
  artifact_id, why, drop_reason, test_type, diff body) BEFORE
  markdown-escaping or fence-wrapping.
- Empty `unified_diff` suppresses the diff block entirely; empty
  entries tuple emits `_(no candidate artifacts)_`.
- `project_dir` constructor kwarg renders into the truncation footer
  (placeholder `<project_dir>` when None — stable for snapshots).

Tests cover: ABC subclass-of, pipe-table well-formedness, fenced
diff passthrough vs. table-cell escaping (the same pipe / header
content is encoded in cells but raw inside the fence), truncation
at last hunk + dropped-line count + fence-internal footer
placement, project_dir rendering, ANSI strip in cells + diff body,
and the empty-diff / empty-entries fallbacks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (US-010)

Wires every prior story (errors US-001, models US-002, config US-003,
safety US-004, emitter US-005, artifact-id US-006, sidecar US-007,
renderers US-008/009) into one public seam: render_diff(model,
candidate, prune_result, *, grading_report, existing_schema, config,
output_path, sidecar_path, project_dir) -> DiffReport.

Boundary checks (DEC-002) raise BEFORE any other work; existing_schema
size cap (DEC-006) + soft-warn (DEC-014) gate yaml.safe_load;
symlink-hardened path canonicalisation against project_dir for both
output_path and sidecar_path mirrors the grade-engine post-QG fix
verbatim. Reproducibility hashes (DEC-016) computed via canonical-sort
blake2b-8 of each input. Single INFO log at happy-path end with
lazy-format json.dumps payload (DEC-015). JsonRenderer added to
_renderers.py — model_dump_json(indent=2, by_alias=True), stateless.

19 new tests cover the boundary-check trio, the size-cap + soft-warn
pair, renderer dispatch across all three concretes, JsonRenderer
round-trip, sidecar write, output_path / sidecar_path symlink
containment, INFO log shape, and an end-to-end happy path that
asserts kept/dropped/flagged tier assignment and DEC-016 hash
presence.
…ft detector (US-011)

Commits the 10-case fixture matrix (DEC-017 of #8) under
tests/fixtures/diff/, the deterministic snapshot-input builder at
tests/diff/_snapshot_inputs.py, the regenerate script
tests/fixtures/diff/regenerate.sh (mirrors tests/fixtures/regenerate.sh
shape), the byte-equality snapshot tests at
tests/diff/test_snapshot_fixtures.py, and the schema-drift detector
tests/diff/test_drift_detector.py with StrictDiffReport / StrictDiffEntry
mirrors validated against diff_report_v1.json and the new
diff_entry_v1.json fixture.

The 11 fixture artefacts on disk cover:

  full_with_grade.{ansi,md,json}  — happy path, three surfaces.
  no_existing_schema.ansi         — /dev/null source for unified diff.
  kept_only.ansi                  — every artifact tier=kept.
  dropped_only.ansi               — every artifact tier=dropped.
  no_grading_report.ansi          — score columns null; no flagged tier.
  plain_no_color.txt              — ANSI surface w/ NO_COLOR=1.
  narrow_terminal.ansi            — 40-col TTY (DEC-013 compact mode).
  injection_payloads.{ansi,md}    — adversarial content (DEC-007/008).

Static checks on the plain_no_color and injection_payloads fixtures
pin DEC-007 (strip user-content ANSI escapes UNCONDITIONALLY) and
DEC-021 (NO_COLOR + force_color=False produces zero ANSI escapes).
The drift detector ships the standard four-test pattern (validate
fixture, validate each entry, field-set parity, sanity-floor reject
unknown field) per the prune / grade precedent.

Validation: ruff / pyright / pytest all green; full suite 1178 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…S-012)

Export the v0.1 public surface from signalforge.diff per DEC-004 of
plans/super/8-diff-renderer.md: render_diff orchestrator, DiffConfig +
load_diff_config, DiffReport / DiffEntry / Tier result models, and the
seven-class DiffError hierarchy. Concrete renderers (AnsiRenderer,
MarkdownRenderer, JsonRenderer) and internal helpers (_emitter,
_sidecar, _artifact_id, _ansi_safety, _markdown_safety) stay private —
reachable via dotted import for internal callers but absent from
__all__.

Add docs/diff-ops.md mirroring docs/grade-ops.md as the operational
reference: overview, public API surface, configuration block,
renderer-kind selection, sidecar JSON schema, reproducibility hash
fields, decision matrix, operational notes (symlink hardening, log
events, ANSI / Markdown injection escaping), snapshot fixture matrix,
debugging, and failure-mode cross-reference.

Extend tests/llm/test_logger_grep_gate.py to scan
src/signalforge/diff/ as the fifth directory per DEC-019 — the
project-wide ANSI-safe lazy-format logger gate now covers llm, draft,
prune, grade, and diff. Planted-violation self-check confirms the
gate fires on an f-string interpolated _LOGGER call in
signalforge.diff.engine.

Add tests/diff/test_public_api.py asserting __all__ matches the
documented surface, that every public name imports via
``from signalforge.diff import ...``, that concrete renderers and
internal helpers stay out of __all__, that the concrete renderers
remain reachable via dotted import (the documented escape hatch),
and that all six DiffError subclasses inherit from DiffError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes from 4-pass code review on the diff renderer epic (#8):

1. Inverted soft-warn / hard-cap defaults in DiffConfig (DEC-014 was
   dead code). Swap so warn_at_bytes (1MB) < size_limit_bytes (10MB),
   and add a model_validator that fails loud at config-load time when
   warn_at >= size_limit.
2. DiffConfig.sidecar_size_limit_bytes was silently ignored — wire it
   through render_diff to write_sidecar via a new size_limit_bytes
   kwarg. Adds end-to-end test pinning the orchestrator-level error.
3. Drop dead DiffSidecarRecordTooLargeError catch from the
   _write_rendered_text exception ladder (output_path branch can't
   raise that error; only sidecar path can).

CodeRabbit skill not available in this session; skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…S-014)

Distil DEC-001..DEC-021 of the diff renderer (#8) into
.claude/rules/diff-renderer.md, mirroring grade-layer.md section
structure verbatim. Bake the post-QG-fix lessons (inverted
warn/limit defaults; sidecar_size_limit_bytes wiring; dead exception
catch) and the load-bearing rules (tier classification + no-grading
degrade, fail-closed sidecar, ANSI strip unconditional, Markdown
hunk-boundary truncation, three boundary checks at orchestrator entry,
reproducibility hashes, drift detector, logger grep gate fifth dir).

Update CLAUDE.md Repository status to "Eight issues shipped" with a
new bullet for #8 matching the prior format; extend the Public API
surface bullet list and Internals listing; remove "diff renderer #8"
from the remaining-feature-work line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow
wjduenow marked this pull request as ready for review May 3, 2026 00:01
@wjduenow
wjduenow requested a review from Copilot May 3, 2026 00:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Despite the plan-oriented PR metadata, this change appears to add the first end-to-end implementation of issue #8’s diff-renderer stage in SignalForge, sitting after prune/grade and before the future CLI flow. It introduces the new signalforge.diff package, snapshot-driven test coverage, and repo documentation/rules updates to treat diff rendering as a shipped subsystem.

Changes:

  • Adds the signalforge.diff subpackage: public API, config, models, errors, artifact-id logic, YAML emission, ANSI/Markdown sanitization, rendering, orchestration, and sidecar persistence.
  • Adds extensive tests and committed fixtures covering snapshots, drift/public API enforcement, config/error behavior, emitter logic, artifact IDs, renderer safety, and sidecar durability.
  • Updates repo guidance and safety gates so the diff layer is part of the documented architecture and logger-format enforcement.

Reviewed changes

Copilot reviewed 45 out of 45 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
tests/llm/test_logger_grep_gate.py Extends the lazy-logger grep gate to scan signalforge.diff.
tests/fixtures/diff/regenerate.sh Shell entrypoint for regenerating committed diff snapshot fixtures.
tests/fixtures/diff/plain_no_color.txt Golden snapshot for plain/no-color ANSI rendering.
tests/fixtures/diff/no_grading_report.ansi Golden ANSI snapshot for runs without grading input.
tests/fixtures/diff/no_existing_schema.ansi Golden ANSI snapshot for /dev/null existing-schema diffs.
tests/fixtures/diff/narrow_terminal.ansi Golden ANSI snapshot for narrow-terminal layout.
tests/fixtures/diff/kept_only.ansi Golden ANSI snapshot for all-kept output.
tests/fixtures/diff/injection_payloads.md Golden Markdown snapshot for hostile input payloads.
tests/fixtures/diff/injection_payloads.ansi Golden ANSI snapshot for hostile input payloads.
tests/fixtures/diff/full_with_grade.md Golden Markdown snapshot for full graded output.
tests/fixtures/diff/full_with_grade.json Golden JSON snapshot for full graded output.
tests/fixtures/diff/full_with_grade.ansi Golden ANSI snapshot for full graded output.
tests/fixtures/diff/dropped_only.ansi Golden ANSI snapshot for all-dropped output.
tests/fixtures/diff/diff_report_v1.json Committed fixture for DiffReport drift detection.
tests/fixtures/diff/diff_entry_v1.json Committed fixture for DiffEntry drift detection.
tests/fixtures/diff/_regenerate.py Python helper that renders and writes all snapshot fixtures.
tests/diff/test_snapshot_fixtures.py Byte-for-byte snapshot tests and fixture presence/count checks.
tests/diff/test_sidecar.py Sidecar writer tests for durability, containment, and fail-closed behavior.
tests/diff/test_public_api.py Public-surface and private-export enforcement for signalforge.diff.
tests/diff/test_models.py Model construction, repr, round-trip, and drift/parity tests.
tests/diff/test_markdown_safety.py Unit tests for Markdown cell escaping behavior.
tests/diff/test_errors.py Typed error hierarchy and message/remediation tests.
tests/diff/test_engine.py Orchestrator tests for validation, rendering, sidecar, logging, and happy paths.
tests/diff/test_emitter.py Tests for deterministic YAML emission of kept artifacts.
tests/diff/test_drift_detector.py Separate strict-mirror drift detector for diff result shapes.
tests/diff/test_config.py Config loading/default/validation tests for the diff: block.
tests/diff/test_artifact_id.py Artifact ID shape/collision/parity tests against grade logic.
tests/diff/test_ansi_safety.py Unit tests for ANSI stripping behavior.
tests/diff/_snapshot_inputs.py Deterministic builders and recipes for snapshot cases.
src/signalforge/diff/models.py Defines DiffEntry, DiffReport, and Tier.
src/signalforge/diff/errors.py Defines the diff layer’s typed exception hierarchy.
src/signalforge/diff/config.py Adds DiffConfig and load_diff_config.
src/signalforge/diff/_sidecar.py Implements fail-closed JSON sidecar persistence.
src/signalforge/diff/_markdown_safety.py Adds Markdown escaping for rendered table content.
src/signalforge/diff/_emitter.py Emits canonical proposed schema.yml from kept artifacts.
src/signalforge/diff/_artifact_id.py Mirrors grade-layer artifact ID generation.
src/signalforge/diff/_ansi_safety.py Adds ANSI escape stripping for user-controlled output.
src/signalforge/diff/_renderers.py Implements ANSI/Markdown/JSON renderers.
src/signalforge/diff/engine.py Wires validation, diff generation, rendering, and persistence.
src/signalforge/diff/init.py Exposes the package’s public API.
CLAUDE.md Updates repo status/public API docs to include issue #8 as shipped.
.claude/rules/diff-renderer.md Adds repository rules/conventions for the diff-renderer subsystem.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/signalforge/diff/engine.py Outdated
Comment thread src/signalforge/diff/_renderers.py Outdated
Comment thread src/signalforge/diff/engine.py Outdated
Comment thread src/signalforge/diff/errors.py
Comment thread src/signalforge/diff/engine.py Outdated
Comment thread src/signalforge/diff/_ansi_safety.py Outdated
Comment thread src/signalforge/diff/_markdown_safety.py
Comment thread src/signalforge/diff/engine.py Outdated
Comment thread src/signalforge/diff/engine.py
Comment thread CLAUDE.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 17

🧹 Nitpick comments (1)
tests/diff/test_drift_detector.py (1)

181-214: ⚡ Quick win

Field-name parity alone won’t catch schema-type drift.

These checks only compare keys, so a production change like str -> Literal[...] or a default-value change can still slip through if the current fixtures happen to validate under both shapes. Since this file is the drift gate, it’s worth comparing annotations/defaults too.

Suggested fix
 def test_diff_entry_field_set_parity() -> None:
@@
-    strict_fields = set(StrictDiffEntry.model_fields.keys())
-    prod_fields = set(DiffEntry.model_fields.keys())
-    missing_in_strict = prod_fields - strict_fields
-    extra_in_strict = strict_fields - prod_fields
+    strict_fields = StrictDiffEntry.model_fields
+    prod_fields = DiffEntry.model_fields
+    missing_in_strict = set(prod_fields) - set(strict_fields)
+    extra_in_strict = set(strict_fields) - set(prod_fields)
@@
     assert not extra_in_strict, (
         f"StrictDiffEntry has fields absent from DiffEntry: "
         f"{extra_in_strict}. Remove from StrictDiffEntry or add to DiffEntry."
     )
+    for name in prod_fields:
+        assert strict_fields[name].annotation == prod_fields[name].annotation
+        assert strict_fields[name].default == prod_fields[name].default
@@
 def test_diff_report_field_set_parity() -> None:
@@
-    strict_fields = set(StrictDiffReport.model_fields.keys())
-    prod_fields = set(DiffReport.model_fields.keys())
-    missing_in_strict = prod_fields - strict_fields
-    extra_in_strict = strict_fields - prod_fields
+    strict_fields = StrictDiffReport.model_fields
+    prod_fields = DiffReport.model_fields
+    missing_in_strict = set(prod_fields) - set(strict_fields)
+    extra_in_strict = set(strict_fields) - set(prod_fields)
@@
     assert not extra_in_strict, (
         f"StrictDiffReport has fields absent from DiffReport: "
         f"{extra_in_strict}. Remove from StrictDiffReport or add to DiffReport."
     )
+    for name in prod_fields:
+        assert strict_fields[name].annotation == prod_fields[name].annotation
+        assert strict_fields[name].default == prod_fields[name].default
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/diff/test_drift_detector.py` around lines 181 - 214, The tests
test_diff_entry_field_set_parity and test_diff_report_field_set_parity only
compare the sets of keys on StrictDiffEntry.model_fields vs
DiffEntry.model_fields (and StrictDiffReport vs DiffReport), so schema-type or
default-value drift can be missed; update these tests to iterate each common
field name and assert that the corresponding model field attributes (e.g.,
model_fields[name].annotation or .outer_type_ and .default / .required flags)
are equal between StrictDiffEntry and DiffEntry (and between StrictDiffReport
and DiffReport), failing with a clear message identifying the field and the
mismatched attribute to force detection of type/default changes as well as
missing/extra fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/rules/diff-renderer.md:
- Around line 90-92: The fenced code block in diff-renderer.md is missing a
language tag and triggers markdownlint MD040; update the example fence in
.claude/rules/diff-renderer.md to include a language (e.g., change the opening
triple-backtick from ``` to ```text) so the fenced example is tagged as text and
the MD040 rule is satisfied.

In `@docs/diff-ops.md`:
- Around line 307-323: The two fenced code blocks showing the log lines (the one
starting with "WARNING signalforge.diff.engine: large existing schema.yml: ..."
and the one starting with "INFO signalforge.diff.engine: rendered diff: ...")
need language tags to satisfy markdownlint MD040; update each triple-backtick
fence to include a language identifier such as text or log (e.g., change ``` to
```text or ```log) so both examples are tagged while leaving their content
unchanged.
- Around line 123-126: The sample config has existing_schema_warn_at_bytes
(10485760) greater than existing_schema_size_limit_bytes (1000000) making the
warn path unreachable; update the documented default so warn_at < size_limit
(e.g., set existing_schema_warn_at_bytes to a value lower than 1000000 or raise
existing_schema_size_limit_bytes above 10485760) and update the prose to state
the invariant clearly; also mention/enforce the DiffConfig model-validator rule
that existing_schema_warn_at_bytes < existing_schema_size_limit_bytes (DEC-014)
and ensure the symbols markdown_max_diff_chars,
existing_schema_size_limit_bytes, existing_schema_warn_at_bytes, and
sidecar_size_limit_bytes are consistent in the example.

In `@plans/super/8-diff-renderer.md`:
- Line 644: The inline code examples showing triple-backticks contain extra
spaces (e.g. "` ``` `") which trigger MD038; update the text in the markdown
around the `escape_markdown_scalar` signature so the inline code spans remove
the internal spaces (use "```" instead of " ``` "), and do the same for the
other occurrence of the triple-backtick example referenced near the second
example; ensure both instances continue to show the escaping semantics for
`escape_markdown_scalar(text: str, *, in_table_cell: bool = False) -> str` but
with corrected inline code formatting.
- Around line 351-353: The fenced code block in plans/super/8-diff-renderer.md
(the block currently starting with just ``` around the truncated diff) is
missing a language hint which triggers MD040; fix it by changing the opening
fence to include a language specifier (e.g., replace ``` with ```text) so the
code block becomes ```text and leave the block contents unchanged. Locate the
plain triple-backtick block shown in the diff and update its opening fence
accordingly.
- Around line 481-483: Replace the validation command string in the plan section
that currently runs "ruff check . && ruff format --check . && pyright && pytest"
with the repository's canonical validation command that includes the editable
install step; update the command to perform the editable install of dev deps
first (pip install -e ".[dev]") followed by ruff check, ruff format --check,
pyright and pytest so the plan uses the same validation flow as the rest of the
repo.
- Around line 1097-1099: Remove the duplicated "## Beads Manifest" heading in
the markdown so only one "## Beads Manifest" section remains; locate the
repeated heading string "## Beads Manifest" in plans/super/8-diff-renderer.md
(the block that currently reads "## Beads Manifest" followed by "*To be filled
at devolve time.*") and delete the extra occurrence, keeping the intended single
section and preserving the "*To be filled at devolve time.*" content.

In `@src/signalforge/diff/_ansi_safety.py`:
- Line 23: The CSI regex _ANSI_CSI_RE currently only matches escape sequences
ending with letters; update it to match any CSI final byte in the range '@'
through '~' by changing the character class at the end to '@-~' (so the pattern
matches \x1b\[[0-9;]*[`@-`~]) to ensure sequences like '\x1b[3~' and '\x1b[200~'
are also sanitized.

In `@src/signalforge/diff/_markdown_safety.py`:
- Around line 46-80: escape_markdown_scalar currently never neutralizes raw HTML
metacharacters; update escape_markdown_scalar to HTML-entity-encode '&', '<',
and '>' (as &amp;, &lt;, &gt;) before any other escaping so user-controlled HTML
cannot be injected, keeping the existing in_table_cell handling for '|' and
_TABLE_CELL_CONTROL_CHARS; ensure you replace '&' first to avoid
double-escaping, then '<' and '>', and then continue with the existing
backslash/`/|` logic in escape_markdown_scalar.

In `@src/signalforge/diff/_renderers.py`:
- Around line 642-649: The table cell values are still allowing raw HTML like
"<...>" through because escape_markdown_scalar(..., in_table_cell=True) only
neutralizes pipes/backticks/control chars; update the markdown safety pipeline
to also entity-encode '<', '>', and '&' (use or extend the existing
_markdown_safety) and apply unconditional ANSI stripping (_ansi_safety) before
escaping for every user-content field (e.g., where escape_markdown_scalar is
called for entry.tier, clean_artifact, clean_test_type, clean_drop_reason,
score_text, clean_why); ensure escape_markdown_scalar delegates to or calls
_ansi_safety and then _markdown_safety that replaces '<'=>'&lt;', '>'=>'&gt;',
'&'=>'&amp;' when in_table_cell is true so no raw HTML can be emitted in
markdown table cells.
- Around line 675-690: The current renderer uses a static "```" fence around the
diff (in the method that builds clean_body and returns the fenced block), which
can be prematurely closed if clean_body contains a run of backticks; change the
fencing to be dynamic: scan clean_body (and the footer if present) for the
longest consecutive run of backticks, compute a fence delimiter longer than that
(e.g. longest_run + 1 or +3 backticks), and use that computed fence instead of
the static "```" when building the final return string in the function that
calls _markdown_max_diff_chars(), _truncate_at_last_hunk(), and
_truncation_footer(). Ensure you also apply the same fence length to both the
opening and closing fences so the fenced block cannot be closed by content.

In `@src/signalforge/diff/config.py`:
- Around line 235-252: The code reads config_file with
config_file.read_text(...) without symlink-hardening or OSError translation;
update the load logic in the function that uses config_file (the branch that
sets config_file from path or project_dir/_DEFAULT_CONFIG_FILENAME) to
canonicalize and validate the path (e.g., resolve the config_file and the
project_dir via Path.resolve(strict=False) and ensure the resolved config_file
is inside the resolved project_dir for the default lookup), and wrap the
config_file.read_text call in a try/except that catches OSError and raises
DiffError (using the same remediation style as the YAML/validation branches) so
IsADirectoryError/PermissionError are translated into DiffError; keep references
to DiffError, DiffConfig, _DEFAULT_CONFIG_FILENAME and the config_file/read_text
usage to locate where to apply the change.

In `@src/signalforge/diff/engine.py`:
- Around line 255-267: The DiffEntry currently always uses decision.why even
when grading flips a kept artifact to flagged; change the logic after computing
grading_results, score, passed and tier (functions _aggregate_grading and
_tier_for_kept) so that when tier == Tier.FLAGGED the DiffEntry.why is derived
from the grading_results (e.g., pick the first failing rule's message or
synthesize a one-line summary of failing grading_results), otherwise keep
decision.why; ensure the change is applied where DiffEntry(...) is constructed
so flagged rows show the grading reason instead of the original decision.why.
- Around line 149-176: _lookup uses Python's built-in id() instead of the test
object's stable id() method, so change the args_hash lookup to use the test's
own id() accessor (e.g. args_hashes.get(decision.test.id())) in
_resolve_test_artifact_id and the other occurrence referenced (around the second
spot mentioned) so parameterized tests recover their args_hash across
reconstructed CandidateTest instances and artifact_id_for remains byte-equal
with the grade engine.
- Around line 275-304: The code currently returns None for ungraded
doc/rationale artifacts, causing them to disappear from DiffReport.entries;
instead always construct and return a DiffEntry even when grading_results is
empty: detect the empty case (grading_index.get(artifact_id, []) == []) and
create a DiffEntry using description as the one-line why, set score to a
sentinel (e.g. 0.0) and passed to False and derive tier by calling
_tier_for_kept(0.0, False) (or the appropriate kept-tier helper), and keep the
same fields (artifact_id, test_type=None, drop_reason=None); apply the same
change to the analogous block referenced at lines 336-341 so all doc artifacts
are recorded rather than dropped.

In `@tests/diff/test_models.py`:
- Around line 46-47: The test currently imports production literals Tier and
DropReason which lets upstream changes alter the test fixtures; instead keep the
drift mirror independent by removing the imports of Tier and DropReason and
defining test-local literal/enum aliases (or use plain string literals) that
match the expected mirror values, then use those local symbols when building
StrictDiffEntry/DiffEntry/DiffReport fixtures (reference the test uses
DiffEntry, DiffReport and mentions StrictDiffEntry) so the test no longer
inherits production alias expansions and will correctly gate intended
schema-drift updates.

In `@tests/fixtures/diff/injection_payloads.md`:
- Line 8: The markdown table cell sanitizer escape_markdown_scalar currently
encodes pipes and control chars but not angle brackets; update the function
(escape_markdown_scalar in src/signalforge/diff/_markdown_safety.py) to also
replace '<' with '&lt;' and '>' with '&gt;' inside the in_table_cell=True
branch, then add a unit test case in tests/diff/test_markdown_safety.py that
asserts table-cell content containing '</details>' becomes '&lt;/details&gt;'
(and/or similar '<'/'>' examples), and finally regenerate the fixtures/snapshots
in tests/fixtures/diff/injection_payloads.md to reflect the escaped output.

---

Nitpick comments:
In `@tests/diff/test_drift_detector.py`:
- Around line 181-214: The tests test_diff_entry_field_set_parity and
test_diff_report_field_set_parity only compare the sets of keys on
StrictDiffEntry.model_fields vs DiffEntry.model_fields (and StrictDiffReport vs
DiffReport), so schema-type or default-value drift can be missed; update these
tests to iterate each common field name and assert that the corresponding model
field attributes (e.g., model_fields[name].annotation or .outer_type_ and
.default / .required flags) are equal between StrictDiffEntry and DiffEntry (and
between StrictDiffReport and DiffReport), failing with a clear message
identifying the field and the mismatched attribute to force detection of
type/default changes as well as missing/extra fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a8c21bbe-9ee7-4cae-8567-062136a0fb4b

📥 Commits

Reviewing files that changed from the base of the PR and between cbedd2e and e40c317.

📒 Files selected for processing (45)
  • .claude/rules/diff-renderer.md
  • CLAUDE.md
  • docs/diff-ops.md
  • plans/super/8-diff-renderer.md
  • src/signalforge/diff/__init__.py
  • src/signalforge/diff/_ansi_safety.py
  • src/signalforge/diff/_artifact_id.py
  • src/signalforge/diff/_emitter.py
  • src/signalforge/diff/_markdown_safety.py
  • src/signalforge/diff/_renderers.py
  • src/signalforge/diff/_sidecar.py
  • src/signalforge/diff/config.py
  • src/signalforge/diff/engine.py
  • src/signalforge/diff/errors.py
  • src/signalforge/diff/models.py
  • tests/diff/_snapshot_inputs.py
  • tests/diff/test_ansi_safety.py
  • tests/diff/test_artifact_id.py
  • tests/diff/test_config.py
  • tests/diff/test_drift_detector.py
  • tests/diff/test_emitter.py
  • tests/diff/test_engine.py
  • tests/diff/test_errors.py
  • tests/diff/test_markdown_safety.py
  • tests/diff/test_models.py
  • tests/diff/test_public_api.py
  • tests/diff/test_renderers.py
  • tests/diff/test_sidecar.py
  • tests/diff/test_snapshot_fixtures.py
  • tests/fixtures/diff/_regenerate.py
  • tests/fixtures/diff/diff_entry_v1.json
  • tests/fixtures/diff/diff_report_v1.json
  • tests/fixtures/diff/dropped_only.ansi
  • tests/fixtures/diff/full_with_grade.ansi
  • tests/fixtures/diff/full_with_grade.json
  • tests/fixtures/diff/full_with_grade.md
  • tests/fixtures/diff/injection_payloads.ansi
  • tests/fixtures/diff/injection_payloads.md
  • tests/fixtures/diff/kept_only.ansi
  • tests/fixtures/diff/narrow_terminal.ansi
  • tests/fixtures/diff/no_existing_schema.ansi
  • tests/fixtures/diff/no_grading_report.ansi
  • tests/fixtures/diff/plain_no_color.txt
  • tests/fixtures/diff/regenerate.sh
  • tests/llm/test_logger_grep_gate.py

Comment thread .claude/rules/diff-renderer.md Outdated
Comment thread docs/diff-ops.md
Comment thread docs/diff-ops.md Outdated
Comment thread plans/super/8-diff-renderer.md Outdated
Comment thread plans/super/8-diff-renderer.md
Comment thread src/signalforge/diff/engine.py
Comment thread src/signalforge/diff/engine.py
Comment thread src/signalforge/diff/engine.py Outdated
Comment thread tests/diff/test_models.py
Comment thread tests/fixtures/diff/injection_payloads.md Outdated
@wjduenow wjduenow changed the title 8: Diff renderer (plan) 8: Diff renderer May 3, 2026
wjduenow and others added 2 commits May 3, 2026 10:06
…ngine.py + design Q1/Q3)

Six fixes to the diff orchestrator from PR #25 code review:

- #1: wrap mkdir in both output_path and sidecar_path branches with
  try/except → raise DiffSidecarWriteError(cause=...). A project_dir
  whose parent is an existing FILE no longer leaks an untyped OSError
  out of the diff layer.
- #2: doc artifacts (column/model description/rationale) ALWAYS emit
  a DiffEntry per Architectural Commitment #5. When grading is absent
  or no matching result exists, the row gets tier="kept", score=None,
  passed=None, why="kept (no grading)". Prune-only runs now surface
  every present description text in the kept/dropped/flagged table.
- #3: flagged-tier why now reflects the GRADING reason, not the prune
  decision why. Format: "failed grading: <criterion_id> — <reasoning>"
  (truncated to max_why_chars). The first failing criterion drives it
  for determinism.
- #4: replace id(decision.test) lookup with structural keying so the
  args_hash join survives JSON-rehydration of the prune result.
  Cross-stage parity with the grade engine artifact_ids is preserved
  (post-QG fix to enable diff↔grade-sidecar joins after the prune
  result has been persisted+reloaded).
- #5 (Q1=A): default sidecar_path to <project_dir>/.signalforge/
  diff.json when write_sidecar=True (default). New write_sidecar
  kwarg disables the sidecar entirely. Makes the diff sidecar an
  always-on durable record by default, mirroring grade/prune audit
  precedent.
- #6 (Q3=A): skip renderer.render(report) entirely when there's no
  consumer (no output_path AND write_sidecar=False / sidecar serialises
  the report directly). Move duration_seconds capture to immediately
  before the INFO log so it reflects the full wall-clock including
  renderer + writes.

Tests: 8 new regression tests added; one existing test updated for the
sidecar default change; one existing assertion updated for the doc-row
emission count change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on-engine fixes)

Code fixes:
- _ansi_safety: broaden CSI regex to cover the full final-byte range
  (@-~), not just letters; add tests for tilde-terminated key
  sequences (`\x1b[3~`) and bracketed-paste markers (`\x1b[200~`).
- _markdown_safety: HTML-entity-encode `&`, `<`, `>` before any
  Markdown escape (defence against `<script>`, `</details>`,
  `<img src=x>` smuggling). Order matters: `&` first to avoid
  double-encoding subsequent escape entities.
- _renderers (MarkdownRenderer): four bug fixes —
  (a) dropped_line_count off-by-one when body ends with `\n`;
  (b) the WHOLE rendered diff block now fits under
      `markdown_max_diff_chars` (subtract fence + footer overhead
      before truncating);
  (c) when the first hunk alone exceeds the cap, emit only the
      `---`/`+++` file-header lines rather than a mid-hunk character
      cut (which would produce malformed unified-diff output);
  (d) dynamic fence length: scan the body for the longest backtick
      run and pick `max(3, longest_run + 1)` so a YAML payload
      containing literal triple-backticks cannot close the outer
      fence prematurely.
- config: wrap `read_text` in try/except for OSError /
  IsADirectoryError / PermissionError; symlink-harden the resolved
  config path via `signalforge.warehouse._path_safety.canonicalise_path`
  before any read (mirrors the orchestrator-level treatment of
  `output_path` / `sidecar_path`).

Docs / plan fixes:
- errors.py: `DiffInputTooLargeError.default_remediation` now
  matches production's 10 MB cap (not the obsolete 1 MB literal).
- docs/diff-ops.md: every reference to
  `existing_schema_size_limit_bytes` (10 MB) and
  `existing_schema_warn_at_bytes` (1 MB) defaults consistent;
  fenced log-output blocks tagged as `text` (MD040); soft-warn
  prose corrected ("warn 1 MB fires below hard cap 10 MB").
- .claude/rules/diff-renderer.md: tagged the truncation-footer
  fence as `text`.
- plans/super/8-diff-renderer.md: DEC-006 / AR-5 / AR-11 / AR-13 /
  the `DiffConfig` snippet now carry the post-QG-fix defaults
  (10 MB hard cap, 1 MB soft warn — original plan had them
  inverted before commit c3dc6ab); validation command snippet now
  prefixes `pip install -e ".[dev]"`; MD038 inline-code-span
  spaces removed; duplicate `## Beads Manifest` heading dropped;
  truncation-footer fence tagged as `text`.

Tests:
- New regression tests in tests/diff/test_ansi_safety.py (3),
  test_markdown_safety.py (7), test_renderers.py (5) covering
  every code fix above.
- tests/diff/test_config.py: regression tests for IsADirectoryError
  wrap and the symlink-escape rejection path.

Fixtures:
- tests/fixtures/diff/injection_payloads.md regenerated via
  `tests/fixtures/diff/regenerate.sh` to capture the new
  HTML-escaped `&lt;/details&gt;` rendering of the hostile input.

Validation: `ruff check .`, `ruff format --check .`, `pyright`,
`pytest` all green (1222 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow

wjduenow commented May 3, 2026

Copy link
Copy Markdown
Owner Author

PR Review Summary — Address all 31 threads

Two parallel worker passes landed on feature/8-diff-renderer:

  • f560b4e — engine.py + design Q1/Q3 + heavy-lift args_hash structural keying
  • ea7fcaa — non-engine fixes (ANSI/Markdown safety, renderers, config, errors, docs/plan/rules)

All four checks green: ruff check + ruff format --check + pyright (0 errors) + pytest (1222 passed, 10 deselected).

Fixed — code (13 threads)

Area Fix Commit
_ansi_safety.py Broaden CSI regex to full ECMA-48 grammar (\x1b\[[0-?]*[ -/]*[@-~]) — covers tilde-terminated \x1b[3~, bracketed-paste \x1b[200~/\x1b[201~, intermediate-byte sequences ea7fcaa
_markdown_safety.py HTML-escape &&amp; first, then <&lt;, >&gt; (both table-cell and non-cell paths). Defence-in-depth for GFM table-cell injection ea7fcaa
_renderers.py (4 bugs) (a) dropped_line_count off-by-one for trailing-newline diffs; (b) truncation cap subtracts footer + closing fence overhead so final block ≤ markdown_max_diff_chars; (c) first-hunk-too-big now emits only file headers + footer (no mid-hunk cut); (d) dynamic fence length = max(3, longest_backtick_run + 1) so unchanged lines containing ``` can't close the outer fence ea7fcaa
config.py Wrap read_text for OSError/IsADirectoryError/PermissionError → typed DiffError; route resolved config path through canonicalise_path for symlink hardening ea7fcaa
engine.py Wrap project_dir.mkdir in both output_path + sidecar branches → DiffSidecarWriteError(cause=...) (was leaking raw FileExistsError/PermissionError) f560b4e
engine.py Doc artifacts (column/model description + rationale) now always emit a DiffEntry even with no grading_report (Architectural Commitment #5: every artifact gets a one-line "why"). Fallback string "kept (no grading)" f560b4e
engine.py Flagged-tier why now derives from the failing GradingResult.reasoning ("failed grading: <criterion> — <reason>"), not the prune decision.why f560b4e
engine.py (heavy lift) Replace id(decision.test) args_hash lookup with structural keying (scope, column, test.type, _model_test_args_hash(test)). Survives JSON-rehydrated prune results; cross-stage parity with grade engine preserved f560b4e

Fixed — design questions (3 threads, user-confirmed)

Q Decision Implementation
Q1 sidecar default A — default sidecar_path = <project_dir>/.signalforge/diff.json New write_sidecar: bool = True kwarg; matches PR description's "always emit durable record" claim
Q3 render skip + duration capture A — skip rendering when output_path is None (sidecar uses model_dump_json directly); move duration_seconds capture to immediately before INFO log so timing reflects full wall-clock f560b4e
Q4 drift mirror literal independence B — leave inherited (single source of truth for the literal) No change
Q2 existing_schema filename in diff headers B — keep synthetic a/models/<model>.yml, defer real-filename support to CLI #9 No change

Fixed — docs (9 threads)

Area Fix Commit
errors.py DiffInputTooLargeError.default_remediation now references the production 10 MB cap (was stale "1 MB") ea7fcaa
docs/diff-ops.md All references to existing_schema_size_limit_bytes / existing_schema_warn_at_bytes defaults updated to match production (limit=10 MB, warn=1 MB). Two fenced log blocks tagged text (MD040). Soft-warn prose corrected ea7fcaa
.claude/rules/diff-renderer.md Truncation-footer fence tagged text (MD040) ea7fcaa
plans/super/8-diff-renderer.md DEC-006 / AR-5 / AR-11 / AR-13 + both DiffConfig snippets sync'd to post-QG-fix defaults; validation snippet prefixed with pip install -e ".[dev]"; truncation-footer fence tagged text; MD038 inline-code spaces removed (escape_markdown_scalar + round-trip test list); duplicate trailing ## Beads Manifest heading dropped ea7fcaa

Fixture regenerations

  • tests/fixtures/diff/injection_payloads.md — regenerated via tests/fixtures/diff/regenerate.sh to capture the new HTML-escape transform (</details>&lt;/details&gt;). Verified the diff is exactly the HTML-escape transform; no other fixture affected.

Other

  • PR title renamed 8: Diff renderer (plan)8: Diff renderer since the work moved beyond the plan phase.

False positives — none

Every thread either landed a code fix, a doc fix, or implements a user-confirmed design decision. No deferrals.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 45 out of 45 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.claude/rules/diff-renderer.md:
- Around line 31-32: The docs claim that sidecar_path=None skips sidecar writing
but the code actually defaults to writing to
<project_dir>/.signalforge/diff.json when sidecar writing is enabled; update the
sentence referencing sidecar_path to reflect the real behavior: state that
sidecar_path=None disables sidecar output, but when sidecar writing is enabled
and no explicit path is provided the runtime/CLI will default to
<project_dir>/.signalforge/diff.json (mention the CLI `#9` wiring if relevant),
and ensure references to the diff layer, sidecar_path, and the default path are
consistent with the implementation.
- Around line 65-66: The documentation for the ANSI-strip regex is out of date:
update the text describing signalforge.diff._ansi_safety.strip_ansi_escapes so
it reflects the broader CSI/ANSI grammar actually handled (including
intermediate bytes and alternate terminators like tilde) rather than the old
strict pattern `\x1b\[[0-9;]*[a-zA-Z]`; mention that both AnsiRenderer and
MarkdownRenderer call strip_ansi_escapes on user fields (`description`,
`rationale`, `evidence`, `reasoning`, `why`, `drop_reason`, `artifact_id`)
before adding color/markdown, and either document the precise regex/grammar now
used or state clearly that the implementation accepts extended CSI sequences
(intermediates and non-letter terminators) to match tests.

In `@docs/diff-ops.md`:
- Around line 76-77: Update the docs to match the current render_diff signature
and default sidecar behavior: document that render_diff(model, candidate,
prune_result, *, grading_report=None, existing_schema=None, config=None,
output_path=None, sidecar_path=None, project_dir=None, write_sidecar: bool =
True) -> DiffReport includes the boolean write_sidecar flag (defaults to True)
and that when enabled the sidecar is written by default to
<project_dir>/.signalforge/diff.json; adjust the descriptive text around
render_diff (and the corresponding sections mentioned at lines ~192-200) to
reflect the opt-out (write_sidecar=False) semantics and the default sidecar path
and mention project_dir defaulting to Path.cwd() as used for canonicalisation.

In `@src/signalforge/diff/engine.py`:
- Around line 193-204: The current assignment out[key] = base_hash if ordinal ==
1 else f"{base_hash}:{ordinal - 1}" collapses exact-duplicate tests to a single
scalar per structural key and causes artifact_id parity loss; change the storage
for structural keys (the dict referenced as out) from a single scalar to a
list/queue of suffixes (e.g., map _StructuralKey -> List[str]) and populate it
in the same place where base_hash/ordinal are computed (the block with out[key]
assignment), then update the decision traversal/lookup logic to use a per-key
consumption counter to pop or index the next suffix deterministically for each
occurrence so duplicates are disambiguated in order across JSON rehydration and
grading joins.
- Around line 801-809: The DiffReport is constructed with a placeholder
duration_seconds but never updated before the report (and sidecar payload) are
written/returned; update report.duration_seconds to time.monotonic() -
started_at at the same point where the final wall-clock duration is
computed/logged (the code that currently logs the final duration), and ensure
any sidecar/write call and the return value use this updated report instance so
the persisted and returned DiffReport contains the true final duration_seconds;
look for references to DiffReport, report, duration_seconds, started_at and the
final logging call and refresh the field immediately prior to writing/returning.

In `@tests/diff/test_engine.py`:
- Around line 282-283: The test filters caplog.records only by level (e.g.,
warns = [rec for rec in caplog.records if rec.levelname == "WARNING"]) which can
match other loggers and cause flakiness; update each occurrence (the list
comprehension assigning warns at the three locations) to also scope to the
engine logger by adding a logger-name check (e.g., rec.name == "engine" or
rec.name.startswith("engine")) so the comprehension becomes something like: [rec
for rec in caplog.records if rec.levelname == "WARNING" and rec.name ==
"engine"]; apply the same change to the other two occurrences mentioned so all
assertions only consider engine logger records.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 310d025a-996d-406f-94fd-7363dfa214a5

📥 Commits

Reviewing files that changed from the base of the PR and between e40c317 and ea7fcaa.

📒 Files selected for processing (15)
  • .claude/rules/diff-renderer.md
  • docs/diff-ops.md
  • plans/super/8-diff-renderer.md
  • src/signalforge/diff/_ansi_safety.py
  • src/signalforge/diff/_markdown_safety.py
  • src/signalforge/diff/_renderers.py
  • src/signalforge/diff/config.py
  • src/signalforge/diff/engine.py
  • src/signalforge/diff/errors.py
  • tests/diff/test_ansi_safety.py
  • tests/diff/test_config.py
  • tests/diff/test_engine.py
  • tests/diff/test_markdown_safety.py
  • tests/diff/test_renderers.py
  • tests/fixtures/diff/injection_payloads.md
✅ Files skipped from review due to trivial changes (2)
  • tests/fixtures/diff/injection_payloads.md
  • src/signalforge/diff/_ansi_safety.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/signalforge/diff/_markdown_safety.py
  • src/signalforge/diff/config.py
  • src/signalforge/diff/_renderers.py
  • src/signalforge/diff/errors.py

Comment thread .claude/rules/diff-renderer.md Outdated
Comment thread .claude/rules/diff-renderer.md Outdated
Comment thread docs/diff-ops.md Outdated
Comment thread src/signalforge/diff/engine.py Outdated
Comment thread src/signalforge/diff/engine.py
Comment thread tests/diff/test_engine.py Outdated
…Rabbit on second pass)

Six fixes from the second-pass CodeRabbit review:

1. engine.py — exact-duplicate structural-key collapse. Replace the
   ``dict[_StructuralKey, str | None]`` that lost duplicate args_hash
   entries (last-assignment-wins) with a per-key queue
   ``dict[_StructuralKey, list[str | None]]``. ``_resolve_test_artifact_id``
   now pops from the front of the queue per-decision so two byte-identical
   ``CandidateTest`` instances in ``prune_result.decisions`` get distinct
   artifact_ids matching grade engine's id()-keyed ordinal suffixes.
   Iteration order in the queue matches
   ``signalforge.prune.engine._iter_candidate_tests`` (columns first, then
   model-level) so positional consumption aligns with the prune walk.

2. engine.py — ``DiffReport.duration_seconds`` refresh. Construct the
   report with placeholder ``0.0``, refresh via ``model_copy`` after
   rendering and the optional output_path write but BEFORE the sidecar
   write, so the persisted JSON, returned report, and INFO log all
   carry the same wall-clock value. The sidecar write block moved
   below the refresh.

3. tests/diff/test_engine.py — caplog scoping. Three call sites that
   filtered by ``levelname`` only now also gate on
   ``rec.name == "signalforge.diff.engine"`` so unrelated logger
   records can't false-positive the assertions.

4. docs/diff-ops.md — sidecar API/default sync. The ``render_diff``
   signature now shows the ``write_sidecar=True`` kwarg; the sidecar
   section documents the on-by-default semantics with the
   ``<project_dir>/.signalforge/diff.json`` path and the
   ``write_sidecar=False`` opt-out.

5. .claude/rules/diff-renderer.md — sidecar default semantics. The
   rule prose now reflects the post-Q1 default-on shape.

6. .claude/rules/diff-renderer.md — ANSI strip regex contract. The
   documented regex now matches the broadened ECMA-48 / ISO 6429 CSI
   grammar (``\x1b\[[0-?]*[ -/]*[@-~]``) shipped during US-014.

Tests added (3): exact-duplicate not_null, exact-duplicate
accepted_values, and a cross-stage parity test that compares
diff-side artifact_ids against grade engine's ``_artifact_id_for``
output for the duplicate scenario.

Validation: ruff clean, ruff format clean, pyright clean,
pytest 1225 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@wjduenow

wjduenow commented May 3, 2026

Copy link
Copy Markdown
Owner Author

PR Review Round 2 — Address all 6 follow-up threads

CodeRabbit re-reviewed after f560b4e + ea7fcaa and found 6 more issues. All fixed in d054306.

Path:Line Severity Fix
engine.py:193-204 🟠 Major (heavy lift) Replace dict[_StructuralKey, str | None] with dict[..., list[str | None]] — one queue per structural key. New _consume_args_hash helper pops the front per-decision. Iteration order matches prune.engine._iter_candidate_tests. Cross-stage parity with grade.engine._test_args_hashes preserved byte-for-byte for exact duplicates (:1, :2, ... ordinal suffixes).
engine.py:802-822 + :929-941 🟠 Major DiffReport constructed with duration_seconds=0.0 placeholder; refreshed via report.model_copy(update={"duration_seconds": time.monotonic() - started_at}) AFTER rendering + output_path write but BEFORE sidecar write — so returned report, sidecar JSON, and INFO log all carry the same final wall-clock duration.
test_engine.py:282, :561, :1003 🟡 Minor Tighten three caplog filter list-comprehensions to gate on rec.name == "signalforge.diff.engine" and rec.levelno == logging.<LEVEL> (was: filtering on levelname only — flaky against other loggers).
docs/diff-ops.md:76 + :192-205 🟡 Minor render_diff signature now includes write_sidecar=True; sidecar section rewritten to describe on-by-default at <project_dir>/.signalforge/diff.json with write_sidecar=False opt-out.
.claude/rules/diff-renderer.md:31 🟡 Minor "opt-in via sidecar_path=None skips" → "on by default; pass write_sidecar=False to skip".
.claude/rules/diff-renderer.md:65 🟡 Minor Documented regex updated from \x1b\[[0-9;]*[a-zA-Z] (old narrow) → \x1b\[[0-?]*[ -/]*[@-~] (full ECMA-48 / ISO 6429 CSI grammar).

New tests (3)

  • test_exact_duplicate_not_null_tests_get_distinct_artifact_ids — two byte-identical not_null tests on same column → distinct artifact_ids, one carries :1
  • test_exact_duplicate_accepted_values_get_distinct_artifact_ids — same with accepted_values
  • test_exact_duplicate_cross_stage_parity_with_grade_engine — load-bearing cross-stage parity gate: diff-side artifact_id set equals grade-side _artifact_id_for output

Validation

  • ruff check .
  • ruff format --check . ✅ (152 files)
  • pyright ✅ (0 errors)
  • pytest -q1225 passed, 10 deselected

False positives — none

Every thread landed a real fix.

Commit: d054306 (pushed to origin/feature/8-diff-renderer).

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.

2 participants