Skip to content

Slice View

Specification of how Aburi clusters the set of changed Symbols from aburi diff into connected components over the Symbol call graph and renders them as vertical slices — one per feature that cuts through Controller / Service / Repository (or any equivalent chain), as opposed to the flat horizontal-layer view that groups Controllers with Controllers and Services with Services.

References:

  • overview.md §3.2 — the vertical Slice View concept sketched in three sentences; this document is the full specification
  • call-resolution.md §7 — the CallEdge contract this pass consumes; §7.3 — the "no cross-language edges" invariant
  • effect-propagation.md §2 — sibling pass placement; §5.1, §9 — the propagated: true discriminator and its diff-side rendering, which drive Node selection (§4.4)
  • diff-algorithm.md §3–§5 — how base/head Symbols are paired and the six status values that gate Node inclusion; §7.2 — the Markdown section order Slice View slots into; §10.1 — the diff-schema compatibility policy this pass MUST respect
  • markdown-projection.md §2 — "1 output artifact = 1 file" convention; §3 — shared display conventions (paths, badges, dropped folds); §6 — the existing diff.md section skeleton; §9 — the stub this document resolves
  • ir-schema.md §3 — Symbol id shape used verbatim in cluster identity; §15.2 — non-breaking optional-field policy for the schema extension in §11
  • multi-language-id.md (planned — see roadmap.md) — future cross-language slice composition
  • lsp-enrichment.md — indirectly affects Slice View by lifting more resolved: null calls to real CallEdge records

1. Purpose

The horizontal L0 / L1 / L2 views of overview.md §3.1 group Symbols by kind (Controllers with Controllers, Services with Services). This is the right shape for reading a codebase on first contact, but the wrong shape for reading a pull request — a single feature addition typically cuts vertically through Controller → Service → Repository → Migration and scatters across every horizontal band.

overview.md §3.2 states the intent verbatim: "cluster the set of changed symbols into connected components over the call graph and display them as vertical slices (per feature). It is derived from the same L3 IR." This document specifies how that clustering is performed, how the resulting clusters are identified and ordered, and how they are projected into out/diff.md.

The pass consumes the output of aburi diff (paired Symbols with status) plus the CallEdge[] from both the base and the head IR (call-resolution.md §7). It produces a list of Slice records (§11) that the Markdown projection renders in a new ## 🧵 Slice View section (§12). The existing flat status sections in diff-algorithm.md §7.2 remain unchanged; the Slice View is additional, not a replacement — see §14.10 for why.

Slice naming (assigning a human-readable label like "Refund flow" to a cluster) is out of scope. This document only specifies deterministic cluster identity and membership; naming is a human or LLM concern per roadmap.md "Under consideration". The reviewer sees slice:<anchor-id> and the member list; the interpretation of the cluster's meaning is theirs.

2. Pass Placement in the Pipeline

Slice clustering is a new stage in the aburi diff pipeline, positioned after matching / status determination / delta computation and before Markdown projection:

baseIR, headIR
  ↓ diff match (§3 of diff-algorithm.md)
paired symbols + status
  ↓ delta compute (§5 of diff-algorithm.md)
paired symbols + status + delta
  ↓ slice clustering                          ← THIS PASS
paired symbols + status + delta + slices[]
  ↓ Markdown projection (§7.2 of diff-algorithm.md + §12 of this document)
out/diff.md

Why a diff pass and not an IR pass. effect-propagation.md §2 runs on the IR (its output is a shape of the IR itself, Symbol.effects[]). Slice View, by contrast, is defined only on the changed-Symbol set of a specific PR — it has no meaning at aburi scan time when there is no base to compare against. Running it as an IR pass would either produce an artifact that changes on every scan of the workspace (defeating overview.md §2 "diff stability") or force one arbitrary baseline. Neither is acceptable. Placement in the diff pipeline localizes the pass to exactly the moment its inputs are meaningful.

Where the code lives. The graph traversal itself — building an undirected adjacency structure and running weakly-connected-components via Union-Find — is a language-independent utility exported from @aburi/core (alongside the SCC utility used by effect-propagation.md §6). @aburi/diff calls that utility with a diff-specific Node/Edge selection (§4, §5) and owns the produced SliceRecord[]. Splitting the utility out of @aburi/diff keeps the graph algorithm testable in isolation and reusable if a future pass needs weakly-connected components over the same edge shape.

The pass is a pure function of (pairedSymbols, baseCallEdges, headCallEdges). Determinism is at least as strict as effect-propagation.md §10 — see §10.

3. Inputs and Preconditions

The pass runs after the diff matcher and delta computation of diff-algorithm.md §3–§5 have finished. Its inputs are:

InputOriginNotes
pairedSymbolsdiff-algorithm.md §3every diff Symbol Record with its status and (for changed / moved+changed) its delta populated
baseCallEdges: CallEdge[]base IR's call-resolution.md §7 outputboth endpoints are workspace Symbol ids; resolved: null calls do not appear
headCallEdges: CallEdge[]head IR's call-resolution.md §7 outputsame shape as base
Optional: effect-propagation resultseffect-propagation.md §5consulted only to identify Symbols whose changed status is driven solely by a propagated: true effect entry (§4.4)

Preconditions the pass MUST assume:

  1. Every pairedSymbols[i] has a fully populated status. unchanged entries have already been dropped per diff-algorithm.md §4.
  2. Every CallEdge.from and CallEdge.to resolves to a Symbol in the corresponding IR's symbols[]. Dangling ids are a resolver bug, not something Slice View tolerates.
  3. Both baseIR and headIR share the same $schema value. diff-algorithm.md §9.1 already rejects mismatches with a fatal error; the pass runs after that check.
  4. effect-propagation.md has already run on both IRs. Its outputs are read by the pass only to answer "is this changed status attributable to a propagated effect?" (§4.4).

Cross-language edges do not exist yet (call-resolution.md §7.3). Slice clustering therefore runs within one language's Symbol universe; the deferred design in multi-language-id.md will specify cross-language slice composition — see §14.13.

4. Node Selection

4.1 The Node set

A Symbol is a Node in the clustering graph iff its diff status is one of:

StatusIncluded as NodeRationale
addedyesnew code the reviewer must inspect
removedyesdisappearing code the reviewer must inspect
changedyessemantic change; delta.apiChanged / logicChanged / syntaxChanged may distinguish severity in the projection but do not gate Node inclusion
moved+changedyesboth a rename and a semantic change
dropped-toggledyesdrop-list or plugin-configuration change; the Symbol crossed the visibility boundary of the IR
unknownyesone document never analysed the Symbol's file (diff-algorithm.md §3.5.1), so whether it changed is an open question — and an open question is most useful read beside the callers that still reference it
moved (pure)nopure moves carry no semantic change (diff-algorithm.md §4 — moved has no fingerprint delta), so surfacing them in the vertical-slice view would be noise. They remain in the flat "Moved (no semantic change)" fold of §7.2
unchangednoalready excluded from pairedSymbols per §3 precondition 1

The identity of a Node is the Symbol id (<language>:<file>#<qname> per ir-schema.md §3), taken from the head side of the pair when both are present, from base when only removed (head is absent), and from head when only added (base is absent). An unknown Symbol exists on one side only by construction, and its id comes from that side.

Rationale for using head-side ids as the primary identity: the reviewer navigates the head codebase after the PR merges. Ids they see in Slice View must be clickable in the head — the version they will git checkout and read.

4.2 Dropped-toggled Symbols and empty calls[]

A dropped-toggled Symbol may have an empty calls[] on the side where it was dropped (drop-list rules typically strip a Symbol's body-derived arrays). Consequently, no CallEdge on the dropped side connects it to any other Node. On the kept side, its calls[] is populated normally and edges flow.

As a result, a dropped-toggled Symbol that was kept in base, dropped in head may end up in a cluster via base-side edges (§5); one that was dropped in base, kept in head may end up in a cluster via head-side edges; and one dropped on both sides (theoretically impossible under diff-algorithm.md §4.1 but defended against for robustness) becomes an isolated singleton. This behavior is intentional — see §14.5.

4.3 Pure moved symbols are excluded even from bridging

A moved Symbol is not a Node and it is not treated as an intermediate connector in §5. Rationale: even though a moved Symbol's file path changed, its semantic surface is unchanged; a reviewer's need to see it clustered with related changes is zero. Excluding it also keeps the "no bridging via unchanged Symbols" rule of §5.2 uniform — the Node set is exactly the set of Symbols with a semantic change.

4.4 Propagated-only-changed callers ARE Nodes

A Symbol whose changed status is driven solely by a propagated: true entry appearing (or disappearing) in its effects[] — its own body is untouched — is still a Node. Rationale: from effect-propagation.md §9 it is a legitimate changed with delta.logicChanged: true, and it participates in the same causal chain the reviewer is trying to see. Its outgoing CallEdge to the downstream callee (whose local effects[] did change) is present in either baseCallEdges or headCallEdges (or both), so §5 naturally clusters it with that callee. No special-case code is needed; the Node-selection rule "any status: changed is a Node" already includes it.

5. Edge Selection

5.1 The Edge set

Edges are drawn from the union of baseCallEdges and headCallEdges, then undirected, then restricted to edges whose both endpoints are in the Node set (§4). Each edge is canonicalised as an ordered pair (u, v) with u < v in ascending Symbol id order:

E = { (u, v)  |  u, v ∈ Nodes,  u < v (ascending Symbol id order),
                 ∃ CallEdge in baseCallEdges ∪ headCallEdges from u to v  OR  from v to u }

The canonical u < v form collapses the multi-edge case (the same pair contributed by both baseCallEdges and headCallEdges, or by multiple call sites in either direction) into one undirected edge — clustering is a set-connectivity question, not a weight question. Self-loops (a CallEdge where from === to, e.g. direct recursion) are excluded by the strict u < v constraint. They contribute no connectivity between distinct Nodes.

5.2 No bridging via non-Node Symbols

An edge is included ONLY when both endpoints are in the Node set. A path NodeA → M → NodeB where M is an unchanged (non-Node) Symbol does NOT connect NodeA and NodeB. See §14.3 for why bridging is rejected.

5.3 Why the union of base and head edges

A PR often breaks a call: RefundController used to call RefundService.refund in the base, and after the PR it calls RefundService.refundV2. The reviewer needs to see the controller and BOTH services in the same slice, because the controller's meaning is understood only through the rename it participated in. Restricting edges to headCallEdges alone would put the removed RefundService.refund in a separate cluster; restricting to baseCallEdges alone would isolate the added RefundService.refundV2. The union covers both directions.

Formally: an edge (u, v) is included whenever u called v in base OR u called v in head. If u and v are both Nodes and were ever connected in either revision, the slice acknowledges that history.

5.4 Unresolved calls contribute nothing

If a call has resolved: null in one of the two IRs (call-resolution declined to identify the callee — see call-resolution.md §4.5 / §4.6 / §4.7), it produces no CallEdge on that side and therefore no candidate edge in this pass. Slice View does NOT read Symbol.calls[] directly; it reads only the resolved CallEdge[]. This preserves the call-resolution.md §2 promise that "enabling LSP MUST NOT change the shape or ordering" of what downstream consumers see — a slice does not silently split or merge based on whether LSP was available.

The drop is silent in the data but not in the output. A reviewer who expects Ctl.route → Svc.op as one slice and gets two singletons cannot tell an architectural fact from a resolver miss, so the projection reports it: §12.6 marks the members that carry unresolved calls, and aburi diff prints the workspace-level census from call-resolution.md §8.1 on stdout. This changes nothing about clustering — the pass still consumes resolved CallEdge[] only, and the slices[] output is byte-identical to what it was before the reporting existed.

5.5 Cross-language: no edges yet

call-resolution.md §7.3 states cross-language edges do not exist. Consequently, if a PR touches both TypeScript and Python files, they cluster into disjoint slices per language. This is documented as expected behaviour, not a failure mode — the composition rule for cross-language slicing is deferred to multi-language-id.md (planned). See §14.13.

6. Clustering: Weakly-Connected Components

6.1 The algorithm

The clustering is weakly-connected components (WCC) over the undirected graph (Nodes, Edges) defined in §4 and §5. Implementation: Union-Find with union-by-rank and path compression — O((V + E) · α(V)) where α is the inverse Ackermann function (effectively linear).

Iteration order (for byte-identical output — see §10):

  1. Sort Nodes in ascending Symbol id order.
  2. Sort Edges in ascending (min(u,v), max(u,v)) order.
  3. Iterate the sorted Edge list, union(u, v) for each.
  4. Group Nodes by their Union-Find root. Each group is one Slice.

Complexity is linear in V + E; on a PR with V ≤ 1000 and E ≤ 5 · V, this is orders of magnitude below the diff pipeline's I/O cost and does not contribute meaningfully to aburi diff wall time.

6.2 Cycles

Directed cycles in the call graph (mutual recursion, method dispatch loops) do NOT need special-case code in Slice View. Any directed cycle becomes an undirected cycle in the WCC graph; every node participating in the cycle lands in the same Slice by virtue of being pairwise reachable. No SCC pre-condensation is required — see §14.2 for the contrast with effect-propagation.md §6.

6.3 Singletons

A Node with no in-Node neighbours in E is its own Slice — a singleton. Singletons are legitimate output, not an error state; the reviewer sees them under §12.3's "Standalone changes" fold. See §9.2 for the failure-mode framing.

7. Cluster Identity

7.1 The formula

Every Slice has a stable string id:

sliceId = "slice:" + <smallest member Symbol id in the Slice, by ascending lexicographic order>

The "smallest member" is called the anchor of the Slice. The Symbol id is used verbatim, not sanitized (it may contain :, /, #, and . per ir-schema.md §3). The Slice id inherits those characters; downstream consumers that need a filename derive one via markdown-projection.md §8's sanitization rules.

Example: a Slice whose members are

  • ts:apps/billing/src/RefundController.ts#RefundController.refund
  • ts:apps/billing/src/RefundService.ts#RefundService.refund
  • ts:apps/billing/src/RefundRepo.ts#RefundRepo.saveRefund

has

sliceId = "slice:ts:apps/billing/src/RefundController.ts#RefundController.refund"

because that id is the lexicographically smallest of the three: the three ids share the ts:apps/billing/src/Refund prefix; the first differing character is C (Controller) < R (Repo) < S (Service), so RefundController.ts#… sorts first.

7.2 Stability under small edits

The anchor changes iff either (a) the anchor Symbol is removed from the Node set or (b) a new Node with a lexicographically smaller id joins the Slice. A PR that adds a downstream repository method to an existing Controller-anchored Slice leaves the anchor untouched (R…Controller… remains smaller than R…Repo…). A PR that adds a Symbol whose id begins with an earlier letter — e.g. ts:apps/billing/src/A_new_helper.ts#helper — shifts the anchor. This is honest: the identity of the Slice is a function of its members, and if the membership shape genuinely changed, the id reflects that.

An anchor may be a private helper rather than a Boundary Symbol (a Controller or route handler). This is deliberate — see §14.6 for why Boundary anchoring is rejected.

7.3 What identity does NOT do

Cluster identity is NOT a stable name across PRs. Two PRs that both touch "the refund flow" but change different subsets of the flow will produce different Slices with different anchors and different sliceIds. Cross-PR stable naming is out of scope, per roadmap.md "Under consideration" ("Automatic naming of Slice View clusters"). This document specifies stability within one run only.

7.4 Enforcement of the derivation

sliceId is derived, not independent: it carries no information that members[0] does not already carry. A SliceRecord whose id does not equal "slice:" + members[0] is not a Slice with an unusual name — it is a malformed record, and any reader that trusts the id reads the wrong Symbol. Three layers keep the derivation honest, in decreasing order of importance:

  1. Producer post-condition. The pass validates every SliceRecord it constructs before returning it: members[] non-empty and strictly ascending, and id equal to "slice:" + members[0]. A violation is a coded error, not a warning — the record never leaves the pass. The check costs O(|members|) per Slice, which disappears inside the O((V + E)·α(V)) clustering itself.

    The derivation clause is true by construction inside the pass, which builds the id from members[0] itself; the two members[] clauses are the ones that can actually fire. That is the point. members[0] is the anchor only because the weakly-connected-components utility (§6) returns each component sorted ascending — a guarantee that lives one layer below the pass and is invisible from it. Should it ever weaken, the pass would otherwise keep emitting well-formed-looking Slices naming the wrong anchor.

  2. Validator keyword. A SliceRecord that arrives from outside the process — a document written by a third-party or older producer — cannot be checked by the schema alone, so a validating consumer registers the derivation as a custom keyword and rejects a wrong anchor at the same boundary where a wrong prefix is rejected. @aburi/diff exports the predicate (sliceRecordViolation) for exactly this, and the repository's own schema-validation suite is currently its only instance: Aburi itself never reads a diff document it did not just produce, so no shipping command runs this layer today. The keyword is deliberately not written into aburi.diff.v1.json. That schema is a frozen v1 artifact and MUST stay expressible in standard JSON Schema 2020-12, which has no cross-property comparison; a non-standard keyword in the file would fail every strict-mode validator that reads it, including the validators outside this repository that the published schema exists for.

    The predicate accepts unvalidated input by contract. A validator receives data that has not been type-checked by definition, so a signature promising a well-formed SliceRecord would crash on precisely the documents this layer exists to reject.

  3. Reader helper. Consumers that need the anchor read members[0], never id. @aburi/diff exports sliceAnchor(record) so "get the anchor" has one implementation that cannot be written wrong, and stripping the slice: prefix never becomes the obvious way to do it. Reading id as a display string or a map key remains correct and is unaffected.

    The helper lives in @aburi/diff because that is where the derivation lives. Consumers that already depend on the diff engine use it; a consumer that deliberately does not — the Markdown projection depends only on @aburi/types, so that rendering a diff never pulls in the engine that produced it — reads members[0] directly rather than acquiring a dependency for one accessor. What the layer guarantees repository-wide is the negative: no code path reconstructs the anchor from id.

7.5 The id namespace

SliceRecord.id is typed SliceId and SliceRecord.members[] is typed SymbolId[]. The two are distinct nominal types (ir-schema.md §3.5): a Symbol id is not accepted where a Slice id is wanted, and neither accepts a bare string. That makes §7.1 mechanically enforceable in a way §7.4 layer 1 cannot be on its own — "slice:" + anchor evaluates to string, so sliceIdFor is the only expression in the codebase that can produce a value SliceRecord.id will accept. Writing the concatenation out by hand at a second site is a compile error, not a second derivation to keep in sync.

The brand is a TypeScript-only construct and is erased at runtime; the emitted JSON is unchanged, and the schema still constrains the id with pattern: "^slice:" alone. Two escapes remain by design: sliceIdFor itself asserts the brand once, and sliceRecordViolation asserts it on the untyped input it exists to inspect (§7.4 layer 2).

What the brands do not solve is the underlying namespace collision — a language plugin claiming the token slice would mint Symbol ids that already look like Slice ids, and no amount of typing changes what those strings are. That is closed separately, by reserving the token: see multi-language-id.md Rule L-11.

An anchor drawn from that namespace is also its own §7.4 violation kind (anchor-in-reserved-namespace). It has to be: { id: "slice:slice:a#b", members: ["slice:a#b"] } satisfies every other clause — right prefix, ascending single-element members[], and id genuinely equal to "slice:" + members[0]. makeSymbolId refuses to build such a Symbol id and invariant #16 rejects one read from disk, but buildDiff is public API and runs neither check, so the producer post-condition covers it too.

8. Ordering

8.1 Between Slices

The slices[] array (§11) is sorted in ascending sliceId order — equivalently, ascending anchor Symbol id order. This is the emission order in the diff JSON and the rendering order in the diff Markdown.

Alternative rejected — sort by member count descending: a "biggest slice first" reading order helps the reviewer prioritize the largest impact area at a glance, but the ordering then flips whenever a member is added or removed. Ascending-anchor order is stable under any membership change that does not touch the anchor, which is a much more common edit than a member-count reorder.

8.2 Within a Slice

The members[] array of each Slice (§11) is sorted in ascending Symbol id order. The order is strictly ascending: a Node set is a set of Symbol ids, so no id repeats inside a Slice (§11.1 states the same constraint as uniqueItems, and §11.2 rules out repeats across Slices). The strict form is what §7.4 checks, because a repeated id would leave members[0] well-defined while signalling that the Node set was built wrong.

DFS order, topological order, and Controller → Repository chain order are legitimate reading orders for the reviewer, but constructing them requires either a "start node" heuristic (which itself needs a determinism argument) or a full DFS whose output depends on tie-break rules over multi-parent nodes. Neither is worth the complexity when ascending id already groups Symbols by file within the same directory — the natural reading order of a filesystem — and is stable under any membership change.

The Markdown projection layer (§12) MAY re-render members in a call-order-informed layout for display (e.g. anchor at the top followed by depth-first children); the members[] array in the JSON stays sorted.

9. Failure Modes

9.1 A single giant cluster

A large PR that refactors a shared utility used across most modules can, in principle, produce one gigantic Slice containing the majority of changed Symbols. This does not happen under the rules of §4 and §5 unless the changed Symbols are directly connected in the call graph — the "no bridging via non-Node Symbols" rule of §5.2 prevents unchanged-utility-through-connections from silently glueing everything together.

If a legitimately giant cluster does form (every changed Controller directly calls the same changed helper, and that helper is one of the changed Symbols), the projection layer (§12) renders it as one Slice without truncation. No configurable maximum-cluster-size knob is introduced — overview.md §2 "Configuration philosophy" makes it explicit that tuning knobs break time-series comparison. The reviewer's tool for coping with a giant cluster is the standard Markdown <details> fold applied per-Slice in §12.

9.2 Many singletons

Small PRs that touch unrelated Symbols produce many singleton Slices. Rendering N one-line Slice headings, each with a single member, is noise. The projection layer collapses singletons into a single "Standalone changes" bucket, rendered as one folded section listing all singletons — see §12.3.

9.3 Disconnected trivial changes

The degenerate case of §9.2 — a PR that adds three unrelated fields to three unrelated classes with no cross-calls at all — produces three singletons and no non-singleton Slice. The Slice View section still emits, but it contains only the "Standalone changes" fold; the reviewer sees the same information they would see in the flat ## ➕ Added section of diff-algorithm.md §7.2. No error, no empty section — see §14.10 for why the flat sections remain.

9.4 Zero Nodes

If the PR has no Node-eligible statuses at all (only pure moved and dropped-toggled on the same side, for instance), the Slice View section is omitted from the Markdown. slices[]: [] in the JSON is still emitted for consumers that expect the field. See §11.2.

10. Determinism Guarantees

The pass matches effect-propagation.md §10's rigor.

  • The Node set is enumerated in ascending Symbol id order.
  • The Edge set is deduplicated (§5.1) and then sorted in ascending (min(u,v), max(u,v)) order.
  • Union-Find union calls happen in that sorted Edge order. The Union-Find root chosen by union-by-rank is an implementation-internal identifier; the sliceId anchor is derived by scanning each cluster's members[] for the smallest Symbol id per §7.1 after clustering terminates, independent of which representative Union-Find happens to have picked as the root.
  • slices[] is emitted in ascending sliceId order.
  • Each Slice's members[] is emitted in ascending Symbol id order.

Idempotence. Running the pass twice on the same (pairedSymbols, baseCallEdges, headCallEdges) reproduces the same slices[] byte-for-byte. This holds because Union-Find on a fixed edge sequence is a pure function, and every subsequent sort has a total order.

Locality. Adding, removing, or modifying a Symbol elsewhere in the workspace whose status is not a Node (e.g. an untouched Symbol, or a pure moved) does not change any Slice's membership or id. Adding a new Node that lands in a different connected component does not perturb existing Slices. This mirrors call-resolution.md §9's locality property.

Input-order insensitivity. Feeding pairedSymbols, baseCallEdges, or headCallEdges in any order — reversed, shuffled — produces the same output. The sort in §6.1 step 1 and step 2 canonicalizes the input.

11. Diff Schema Implications

11.1 The new field

aburi.diff.v1.json gains an optional top-level array:

jsonc
{
  // ... existing fields per diff-algorithm.md §7.1 ...
  "slices": [                                        // optional, non-breaking (ir-schema.md §15.2)
    {
      "id": "slice:<smallest-member-Symbol-id>",     // required
      "members": ["<Symbol id>", "<Symbol id>", ...] // required, ≥ 1, ascending Symbol id order
    }
  ]
}

SliceRecord carries no confidence, no rationale, no derivedBy — a Slice is a derived view over pairedSymbols and CallEdge[], not a first-class fact. Consumers that need per-member confidence or delta information read those from the existing pairedSymbols entries by joining on Symbol id.

The schema constrains id with pattern: "^slice:", which catches a malformed prefix and nothing else. Neither the §7.1 derivation (id === "slice:" + members[0]) nor the §8.2 ascending members[] order is expressible in standard JSON Schema 2020-12 — both compare one property against another. The schema is therefore a necessary but not sufficient check on a SliceRecord; the two remaining invariants are carried by §7.4.

11.2 Emission rules

  • The pass always emits the slices key, even when slices: []. Consumers therefore never need to distinguish "field absent" from "no slices".
  • Members appear in exactly one Slice each. A Symbol id never appears in two SliceRecord.members[] arrays.
  • The union of all members[] equals the Node set defined in §4.

11.3 Non-breaking

Adding an optional top-level array is a non-breaking schema change under ir-schema.md §15.2 (which diff-algorithm.md §10.1 inherits explicitly). Existing consumers of aburi.diff.v1.json that ignore unknown fields continue to work.

No changes are introduced to:

  • The status enum (diff-algorithm.md §10.1 — status growth is a breaking change).
  • The MatchRationale enum (same policy).
  • The existing symbols[] / components / dependencies / summary shapes.

11.4 CI gate (--fail-on) interaction

aburi diff --fail-on gates on the existing status/summary values. No new --fail-on selector is introduced for Slice View — no cluster-count>N, no slice-size>N, no giant-slice. See §14.7 for the rejection.

12. Markdown Projection

12.1 Where it appears

A new section ## 🧵 Slice View is added to out/diff.md, positioned between ## 🔧 Logic changes and ## ➕ Added in the fixed section order of diff-algorithm.md §7.2 / markdown-projection.md §6.1:

## ⚠ API changes
## 🔧 Logic changes
## 🧵 Slice View                    ← NEW
## ➕ Added
## ➖ Removed
## 🔀 Moved + Changed
## 🔀 Moved
## 🧱 Component changes
## 🔗 Dependency changes
## 💧 Dropped changes
## 🎨 Syntax-only changes

Position rationale. Slice View is the reviewer's "understand the feature" section — the vertical view. It slots after the horizontal severity sections (API / Logic) so a reviewer who cares about critical contract changes reads those first, then reads the same changes re-grouped by feature, then continues to Added / Removed for coverage. Slice View is not folded by default: it IS the primary reading path for large PRs.

12.2 Per-Slice rendering

Each Slice becomes a ### subsection under ## 🧵 Slice View:

md
### `slice:ts:apps/billing/src/RefundController.ts#RefundController.refund` (3 members)

- `RefundController.refund`*(changed)*
  **File**: `apps/billing/src/RefundController.ts:42`
  ↳ delta.apiChanged, delta.logicChanged
- `RefundService.refund`*(changed)*
  **File**: `apps/billing/src/RefundService.ts:88`
  ↳ delta.logicChanged
- `RefundRepo.saveRefund`*(added)*
  **File**: `apps/billing/src/RefundRepo.ts:15`
  ↳ effects: db.write on `prisma.refund.create`

---
  • The heading contains the full sliceId and the member count.
  • Each member is a bullet with: Symbol short-form (last-segment qname), the status in italics, file:line, and a follow-up line summarising which delta axes tripped or, for added / removed, the entry's boundary/effect surface.
  • Members appear in ascending Symbol id order per §8.2. A projection variant that arranges members in call-order (anchor first, then callees) is left for a future iteration — the JSON side stays as specified.
  • Slices are separated by a --- thematic break for visual boundary marking.
  • Column choices reuse existing markdown-projection.md §3 conventions: file paths POSIX and backtick-wrapped (§3.3), confidence badges per §3.5, no emoji for the default status.

12.3 Singletons folded

Singleton Slices (§6.3) are collected into a single <details> block appended after the last non-singleton Slice:

md
### Standalone changes

<details>
<summary>4 singleton slices (no in-Node call-graph neighbours)</summary>

- `slice:ts:packages/shared/src/logger.ts#log``log` *(changed, logic)*
- `slice:ts:packages/shared/src/util.ts#formatMoney``formatMoney` *(changed, syntax-only)*
- `slice:ts:apps/billing/src/dto/refund.dto.ts#RefundDto``RefundDto` *(dropped-toggled, to-dropped)*
- `slice:ts:apps/billing/src/routes/health.ts#health``health` *(added)*

</details>

The reviewer sees the count at a glance and can expand for the list. This matches the collapsed-section policy of markdown-projection.md §12.2 ("Keeping dropped entries, folded") — the fact is retained, the reader is not.

Each member in the Slice View rendering IS the same Symbol that appears in the corresponding flat section (## ⚠ API changes, ## 🔧 Logic changes, ## ➕ Added, etc.). To avoid duplicating the full delta detail in both places, the Slice View bullets contain the summary only — status label, file:line, delta-axis summary — and the flat sections retain the full rendering. Anchor links from Slice View to the flat section entries are planned but out of scope for the initial spec; see markdown-projection.md §5.8 for the parallel "future release will consider internal links" note on call rendering.

12.5 Empty Slice View

Per §9.4, if slices[] is empty the entire ## 🧵 Slice View section is omitted from out/diff.md. Emitting a heading with an empty body would be visual noise.

12.6 Unresolved-call markers

§5.4 explains why an unresolved call contributes no edge. The consequence a reviewer actually feels is a Slice that split — or a singleton that looks disconnected but is not. The projection surfaces that directly.

Directly under the section heading, when any member carries at least one resolved: null call:

md
## 🧵 Slice View

> ⚠ 2 of the changed symbols below make 5 calls the resolver could not identify, so a Slice here may be split rather than genuinely disconnected (call-resolution.md §8.1).

And on the individual members — appended to the follow-up line of a §12.2 bullet, and to the label of a §12.3 singleton:

md
- `Ctl.route`*(changed)*
  **File**: `apps/billing/src/RefundController.ts:42`
  ↳ delta.logicChanged · ⚠ 3 unresolved calls
md
- `slice:ts:apps/billing/src/RefundController.ts#RefundController.refund``RefundController.refund` *(added)* · ⚠ 1 unresolved call

Members that resolved cleanly carry no marker, and a diff where nothing is unresolved renders exactly as it did before this section existed — the marker has to stay rare to stay meaningful.

Where the counts come from. diff.symbols[] already embeds the full IR Symbol on each entry, calls[].resolved included, so the projection counts them directly. No field is added to SliceRecord and no new pass runs; this keeps §14.12's "a Slice is a derived view, not a new fact" intact.

Why counting only the members is enough. §5.1 draws an edge only when both endpoints are Nodes, and the edge originates at the caller. Any edge that would have merged two Slices therefore starts at one of the members rendered here — an unresolved call on some unrelated unchanged Symbol could never have bridged them anyway.

13. Verifiable Properties (Test Criteria)

Every implementation of the Slice View pass MUST pass the following. IDs are prefixed SV to avoid collision with existing prefixes (CR, EP, DF, L, S, T, A, C, MP, PR).

13.1 Node and Edge selection

IDInputExpected
SV1Two changed Symbols A and B with a CallEdge {A → B} in either IRExactly one Slice with members = [A, B] (sorted); sliceId = slice:<min(A, B)>
SV2Two changed Symbols A and B with no CallEdge between them (in either IR)Two singleton Slices
SV3Symbols A and B are both changed; a CallEdge runs A → M → B where M is unchangedTwo singleton Slices — no bridging (§5.2)
SV4A moved Symbol (pure move, no fingerprint delta) with a CallEdge to a changed SymbolThe changed Symbol is a singleton; the moved Symbol is not in slices[] at all (§4.3)
SV5A changed Symbol whose only delta is a propagated: true Effect (own body unchanged) with a CallEdge to its downstream callee (also changed)Both cluster into one Slice (§4.4)

13.2 Base-vs-head edge union

IDInputExpected
SV6Controller C (changed) called Service S1 (removed) in base; calls Service S2 (added) in headExactly one Slice containing {C, S1, S2} (§5.3)
SV7Edge exists only in headCallEdges between two NodesTwo Nodes cluster into one Slice
SV8Edge exists only in baseCallEdges between two NodesSame — two Nodes cluster into one Slice

13.3 Cycles and dropped

IDInputExpected
SV9Directed cycle A → B → C → A, all three changedOne Slice containing {A, B, C} (§6.2)
SV10dropped-toggled Symbol whose calls[] on the dropped side is empty, and no edges from the kept side connect it to any other NodeSingleton Slice (§4.2)
SV11dropped-toggled Symbol whose kept-side calls[] connects to another NodeClusters with that Node

13.4 Cluster identity and ordering

IDInputExpected
SV12A Slice with members [X, Y, Z] sorted ascendingsliceId === "slice:X" (§7.1)
SV13Two Slices S1 and S2 with anchors A1 < A2 (lex order)S1 appears before S2 in slices[] and in the Markdown rendering
SV14A Slice's members[]Sorted ascending Symbol id (§8.2)

13.5 Determinism, idempotence, locality

IDInputExpected
SV15Run the pass twice on the same inputByte-identical slices[] (idempotence, §10)
SV16Shuffle the input pairedSymbols, baseCallEdges, and headCallEdges ordersByte-identical slices[] (input-order insensitivity, §10)
SV17Add an unchanged Symbol elsewhere in the workspaceNo sliceId and no members list changes (locality, §10)
SV18Add a new Node landing in a disjoint componentExisting Slices are unchanged; the new Node appears in its own Slice

13.6 Schema and projection

IDInputExpected
SV19Zero-Node PR (only moved and unchanged)slices: [] in JSON; ## 🧵 Slice View section omitted from out/diff.md (§9.4, §12.5)
SV20Some singleton and some multi-member SlicesMulti-member Slices rendered first, then the "Standalone changes" fold (§12.3)
SV21Cross-language PR (TS + Python nodes)Two disjoint singleton-or-multi Slices per language; no cross-language edges attempted (§5.5, §14.13)
SV22Any outputaburi.diff.v1.json validates unchanged against the schema (§11.3)

13.7 Anchor derivation enforcement

IDInputExpected
SV23Any SliceRecord the pass emitsmembers[] is non-empty and strictly ascending, and id === "slice:" + members[0]. The pass validates each record before returning it and fails with a coded error otherwise. The fixture MUST feed the pass in descending id order, or the check passes whether or not §6 still sorts each component (§7.4)
SV24A SliceRecord with a correct ^slice: prefix but an id not derived from members[0], or with members[] out of ascending order, fed to a validator with the derivation keyword registeredRejected the same way a malformed prefix is, even though the standard-JSON-Schema part of the check passes. Untyped input (missing members, members not an array, missing id) yields a violation verdict, never a thrown TypeError (§7.4, §11.1)
SV25A consumer that needs the anchorNo code path reconstructs the anchor by stripping the slice: prefix from id. Consumers that depend on @aburi/diff read it through sliceAnchor, which answers from members[0] even for a record whose id disagrees (§7.4)

13.8 Id namespace

IDInputExpected
SV26SliceRecord.id assigned a value built as `slice:${anchor}` anywhere other than sliceIdForCompile error: the template literal is a string, which SliceId does not accept (§7.5)
SV27A SliceId used where a SymbolId is expected, or vice versaCompile error in both directions; a bare string satisfies neither (§7.5)
SV28Any runThe emitted slices[] bytes and the aburi.diff.v1.json validation verdict are unchanged by the branding — it exists only in TypeScript (§7.5)
SV29{ id: "slice:slice:a#b", members: ["slice:a#b"] }Reported as anchor-in-reserved-namespace, even though the prefix, the ordering and the derivation clauses all pass. An anchor whose token merely starts with the reserved one (slicer:) is accepted (§7.5)

14. Design Decisions

14.1 Why weakly-connected components

The clustering question is "which changed Symbols participate in the same feature-shaped chain". A feature-shaped chain is a connected path in the call graph, regardless of direction — the reviewer of Controller → Service → Repository does not care that the arrows point downward when reading the code from the top-level Boundary, nor that they point upward when reading from the storage side. WCC captures exactly this: two Nodes are in the same Slice iff there is any undirected path between them in the graph induced on the Node set.

Alternative rejected — SCCs via Tarjan's algorithm: strongly-connected components only join nodes that are pairwise reachable through directed cycles. A linear Controller → Service → Repository chain has no cycles, so every Node ends up a singleton SCC — the exact opposite of the vertical-slice intent. SCC is the right algorithm for effect-propagation.md §6 (which needs to prove termination on a monotone lattice over a possibly-cyclic graph) but the wrong algorithm here.

Alternative rejected — Louvain community detection: modularity-optimising community detection produces clusters that are sensitive to iteration order over tie-breaks. Even carefully-implemented deterministic Louvain shifts cluster boundaries when a single edge is added or removed, which violates overview.md §2's non-negotiable "diff stability". Output cluster size also depends on modularity resolution — a tuning knob whose presence contradicts overview.md §2 "Robust defaults + minimal overrides". WCC has neither problem: it is deterministic under a fixed edge iteration order and has no size knob at all.

14.2 Why no SCC pre-condensation is needed (contrast with effect-propagation.md §6)

effect-propagation.md §6 condenses SCCs before its reverse-topological sweep because the sweep needs a DAG traversal to prove O(V + E) termination on a monotone lattice. Slice View has no lattice and no sweep — Union-Find on the undirected edge set is already O((V + E) · α(V)) and terminates trivially. Directed cycles collapse into the same connected component as a natural consequence of undirection (§6.2). Pre-condensation would be dead code.

14.3 Why "no bridging via non-Node Symbols"

Consider two sibling Controllers RefundController and InvoiceController, both changed, that both call an unchanged shared helper sanitizeAmount. A "1-hop bridging via any unchanged Symbol" rule would cluster them together as one Slice. But their features are semantically unrelated — the reviewer reading the Slice would waste effort proving to themselves that the two Controllers don't in fact share behaviour.

Alternative rejected — 1-hop bridging via a Boundary-owned Symbol only: a narrower rule that only bridges through unchanged Symbols owned by a Boundary (a Controller or route handler) would avoid the trivial sanitizeAmount case above. But it introduces a tuning knob in disguise (how do we define "Boundary-owned"? by decorator? by component? by both?) and, worse, its correctness depends on the framework plugin's decorator classification. A NestJS project sees different bridging than a Next.js project on structurally identical code — that's the plugin sensitivity overview.md §2 marks as "diff stability" hostile. Rejected on both consistency and stability grounds.

Alternative rejected — 1-hop bridging through any unchanged Symbol: cheapest to specify, but produces the trivial false-clustering case above with no defense. The reviewer's cost of "prove these two features are unrelated" exceeds the reviewer's cost of "click into two separate Slices that happened to share a helper".

The chosen rule — no bridging at all — is honest: two features that share an unchanged helper are two features. If the helper itself changes, it becomes a Node and legitimately clusters the two features into one Slice, which is now truthful.

14.4 Why include base-only edges (§5.3)

A PR that renames a Service method and updates its Controller call site produces RefundService.refund as removed, RefundService.refundV2 as added, and RefundController.refund as changed. The head graph has an edge RefundController.refund → RefundService.refundV2 only; the base graph has an edge RefundController.refund → RefundService.refund only. Taking the union clusters all three into one Slice — which is what the reviewer actually needs to see. Restricting to head-only edges would isolate the removed old Service; restricting to base-only edges would isolate the added new one. See SV6 for the test.

14.5 Why dropped-toggled Symbols get included with possibly-empty calls[]

diff-algorithm.md §4.1 argues that dropped-toggled is caused by drop-list or plugin configuration changes rather than user code edits. The reviewer needs to see which Symbols crossed the visibility line, and clustering those Symbols with the code that surrounded them (on the side where they were still visible) gives context. A dropped-toggled Symbol whose calls[] is empty on the dropped side may still have live calls on the kept side — so it may cluster with a real Slice via the union rule of §5.3. When both sides yield no in-Node edges, it is a legitimate singleton (§4.2) and appears under §12.3.

14.6 Why the anchor is not a Boundary Symbol

An intuitive alternative: pick the Boundary Symbol (Controller, route handler) as the anchor of each Slice so that the id reads like slice:<Controller-id> — a human-recognisable feature name.

Rejected because:

  1. Framework-plugin dependency: which Symbols count as Boundary is determined by the framework plugin (Decorator.boundary === true in ir-schema.md §6, or an extKind prefix of framework:*). Two runs of aburi diff on the same source, with different framework-plugin versions or with the framework plugin absent, would produce different anchors → different sliceIds → different slices[] → different Markdown byte content. This violates the overview.md §2 non-negotiable "diff stability".
  2. Multiple Boundaries per Slice: a Slice may contain zero, one, or several Boundaries. Zero-Boundary Slices (a repository-only change) need a fallback; multi-Boundary Slices need a tie-break. Both would be additional rules whose determinism arguments would need proving.
  3. Losing determinism-simplicity: "lexicographically smallest member" is a formula anyone can compute by hand from the member list. "Smallest boundary member, or smallest member if none" is not.

The private-helper-anchor cost is real (the id reads less nicely) but bounded (the member list is right there), and it buys plugin-independence for free.

14.7 No --fail-on cluster-count>N or slice-size selectors

overview.md §2 lists "Highly configurable" as an explicitly rejected design axis. Introducing per-cluster-count or per-slice-size CI gates would put reviewers in the position of tuning the gate on every large PR, and re-tuning on every mid-PR clustering shift caused by a rename. The existing gates (changed, removed, dropped-toggled, ...) already operate on stable per-Symbol counts and cover the "did this PR touch too much" question at a lower level.

The --fail-on surface stays exactly as diff-algorithm.md §10.1 declares it.

14.7.1 No gate on the unresolved-call count either

§12.6 reports unresolved calls; it does not gate on them. A --fail-on unresolved-calls>N selector would fail for the same reason as the cluster-size knob above, and worse: the count moves whenever an LSP server happens to be available, whenever a dependency starts or stops being resolvable, and whenever a plugin's normalizeCallee improves. A CI gate wired to that number would fire on runs where nothing about the code changed, which is precisely the time-series instability overview.md §2 rejects.

aburi explain --debug-resolution is a reporting flag, not a knob: it changes what is printed, never what the IR or the diff contains.

14.8 Why not compute Slice View at aburi scan time

Slice View is defined on the changed Symbol set, which does not exist without a base. An "always-on" workspace-level Slice View would either need to invent a synthetic baseline (an empty workspace? the first commit?) or produce artifacts that shift with every scan of an unmodified workspace. Either option violates diff stability or produces meaningless output. Restricting the pass to aburi diff matches the shape of the reviewer's question.

14.9 Why a ## 🧵 Slice View section in out/diff.md, not a separate out/slices.md

markdown-projection.md §2 establishes "1 artifact = 1 file" (workspace.md, per-component.md, diff.md, per-symbol/*.md). A separate out/slices.md would break that pattern for a view that is a re-grouping of the same diff, not a new artifact. Reviewers paste diff.md into a PR comment; splitting Slice View out would either force a second paste or leave it out of the PR comment entirely, defeating the reason for the Markdown projection.

14.10 Slice View is additional, not a replacement

The existing horizontal sections (## ⚠ API changes, ## 🔧 Logic changes, ## ➕ Added, ...) are kept unchanged. The reviewer's two questions — "what class of change is this?" (horizontal, severity-first) and "what feature is this part of?" (vertical, cluster-first) — are both useful, and a single-view answer to both would lose information. Slice View's per-member bullets summarise; the flat sections carry the full detail (§12.4). Removing the flat sections would also break the existing --fail-on gates that count per-status Symbols.

14.11 Why cluster naming is out of scope

Assigning a human-readable label like "Refund flow" to a Slice requires understanding what the Symbols in the Slice mean — a semantic judgment that overview.md §2 rules out for the static analyser ("LLM judgment (verifiability and diff stability disappear)"). roadmap.md explicitly parks this under "Under consideration" as a downstream LLM concern. Slice ids reveal the anchor Symbol id, which is enough for a reviewer to click into the anchor's L2 view and read the code themselves.

14.12 Why SliceRecord carries no confidence or derivedBy

A Slice is a derived view — the connectivity is a consequence of pre-existing facts (CallEdge[] and pairedSymbols), not itself a new fact. Attaching a confidence would raise the immediate question "confidence in what?" — the WCC computation is deterministic given the inputs, so any confidence would just re-express the per-CallEdge confidence which is already available on the underlying edges. Consumers that want that data join SliceRecord.members[] to pairedSymbols and to the CallEdge set.

14.13 Why cross-language slices are deferred

call-resolution.md §7.3 states that cross-language edges do not exist yet; without such edges, the WCC graph is partitioned by language. Introducing a Slice-View-specific cross-language merge rule (e.g. "consider two Symbols connected if their file paths co-occur in a Component's roots[]") would leak component-detection semantics into the Slice pass and produce Slices whose connectivity is not backed by call-graph evidence — a step away from the "static heuristics + symbol graph" ground rule of overview.md §2. The composition is deferred to multi-language-id.md (planned per roadmap.md) which will introduce cross-language edge semantics; Slice View will then automatically produce cross-language clusters via the same WCC rule with no code change.

Released under the Apache License 2.0.