Skip to content

Walk one process out of a project - #31

Merged
peopleworks merged 1 commit into
mainfrom
feat/process-slice
Aug 23, 2026
Merged

Walk one process out of a project#31
peopleworks merged 1 commit into
mainfrom
feat/process-slice

Conversation

@peopleworks

Copy link
Copy Markdown
Owner

Phase 1 of #23 — the deterministic half, the one that needs no AI.

What it does

ProcessSlice.From(project, seed, depth) takes an action, a controller method, a controller or an entity by name and walks outward from it. Breadth-first, bounded, syntax-only: a call is matched by name against what the project declares, with no compilation and no symbol table.

Seeded on the XPO fixture's ApproveOrder:

[0] Action          ApproveOrder                                (ApproveOrderController.cs:13)
[1] Controller      ApproveOrderController                      (ApproveOrderController.cs:11)
[1] Method          ApproveOrderController.ApproveAction_Execute (ApproveOrderController.cs:31)
[2] Entity          Customer                                    (Customer.cs:15)
[2] Entity          Order                                       (Order.cs:21)
[3] ValidationRule  Order_TotalNotNegative                      (Order.cs:21)
[3] AppearanceRule  OrderLockedWhenApproved                     (Order.cs:21)
…
controller:ApproveOrderController  --Declares-->  action:…ApproveOrder
action:…ApproveOrder               --Calls-->     method:…ApproveAction_Execute
method:…ApproveAction_Execute      --Touches-->   entity:Order
entity:Order                       --Governs-->   rule:Order.Order_TotalNotNegative

That is the thing the tool could not answer before. It knew every declaration and nothing about the path across them.

The decision this rests on

The scope is computed, not asked for. A model asked what belongs in "the approval process" answers authoritatively, in a form nobody can check, and is wrong in the places that look exactly like the places it is right. A walk answers something reviewable, something that diffs between two extractions, and something whose bad sentences can be fixed later without touching its structure.

What it cannot see, and says so

A call syntax cannot follow is printed, not dropped. The new fixture exists for exactly this shape — a handler calling Recalculate(), one virtual declaration beside the call, two controllers overriding it:

method:TotalsControllerBase.RecalculateAction_Execute
  --Calls--> method:TotalsControllerBase.Recalculate

unresolved: Recalculate
  TotalsControllerBase.Recalculate | InvoiceTotalsController.Recalculate | CreditNoteTotalsController.Recalculate
  "the declaration is virtual; which override runs is decided by the run-time type, not by the source"

And the consequence is asserted, because it is the whole point: that slice contains no entities at all. The bodies doing the arithmetic were never entered, so what they write is not there. A walk that stopped in silence would read as a complete account of a process it never went into.

The depth bound is reported for the same reason. A walk that ran out of things to reach is a whole process; a walk that stopped at its limit is a view of one; rendering them identically is how a document claims completeness it does not have.

Four things the probe found that tests would only have enshrined

I ran it against the fixtures and read the output before writing a single assertion.

  1. The action→controller edge pointed the wrong way — it read ApproveOrder Declares ApproveOrderController, which is false. Phase 2 draws arrows straight off this set, so a backwards edge is an arrow in a diagram the code does not support. Expansions can now declare an incoming edge.
  2. Customer was reached by coincidence. The handler reads order.Customer, and the identifier Customer matched the class Customer. Right here, wrong the first time an entity is called Status. A name written after a dot now counts only when the model declares a relationship of that name pointing at that entity — right for a reason instead of by luck.
  3. An action's extracted body is its handler's body, so walking both gave the action a copy of every edge the handler had: two nodes reporting one call, and a fork in the diagram where the code has none. The action now hands off, and walks its own body only when there is no handler to hand to (an inline lambda).
  4. Preferring the caller's own declaration hid the virtual dispatch it was meant to expose. Found while building the fixture, not while running it — an unqualified Recalculate() resolved to the local declaration and reported nothing. Methods now record virtual/abstract and override, which is what makes the distinction possible at all.

The fixture

WalkthroughSolutionBilling.Module, two entities, three controllers. Its proportions are its point: one action, one handler, and a Recalculate that two controllers override. No existing fixture could reach the unresolved case; every one of them resolves cleanly, which is exactly how a blind spot stays invisible.

Deliberate limits, written down in the source

  • Entity-to-entity relationships are not walked. They are what makes a slice reach the whole application, and EntityGraph already answers that question.
  • A controller reached from one of its own actions does not fan out to its siblings — a walkthrough of "what happens when I press Approve" does not want the other buttons. Asked about directly it is the subject, and contributes its actions and its methods.
  • The reach of a slice is the reach of the extraction. Methods come from controllers, so a calculation living in a plain service class is not walked into.

Not in this PR

Nothing consumes the slice yet. The Mermaid diagram, the Markdown document, xaflogic walkthrough and xaf_walkthrough are phase 2 — where, per the design, the diagram is emitted from this graph and never drawn by a model.

363 tests, zero warnings.

🤖 Generated with Claude Code

https://claude.ai/code/session_01W45tzJFX3NoSrk7svtQeKT

Phase 1 of #23. ProcessSlice.From takes an action, a controller method, a
controller or an entity by name and walks outward: an action to the handler it
runs, a method to the methods it calls and the entities it names, a controller
to the entities it is activated for, an entity to the rules that govern it.
Breadth-first, bounded by depth, syntax-only -- a call is matched by name
against what the project declares, with no compilation and no symbol table.

The scope is computed rather than asked for, which is the governing decision of
the whole feature. A model asked what belongs in "the approval process" answers
authoritatively, in a form nobody can check, and is wrong in the places that
look exactly like the places it is right.

A call that syntax cannot follow is printed rather than dropped. A virtual
declaration is followed to what is written beside the call, and reported with
every override that may replace it. The consequence is stated because it is the
point: the bodies never entered mean entities missing from the slice, so a walk
that stopped there in silence would read as a complete account of a process it
never went into. Methods now record virtual/abstract and override, which is
what makes that distinction possible at all.

The depth bound is reported for the same reason: a walk that ran out of things
to reach is a whole process, a walk that stopped at its limit is a view of one.

Four things the probe found that the tests would only have enshrined:

- The action-to-controller edge pointed the wrong way, saying an action
  declares its controller. Phase 2 draws arrows off this set.
- An entity was touched because a property called Customer happened to match a
  class called Customer. It is now grounded in a declared relationship, so it
  is right for a reason rather than by luck.
- An action's extracted body is its handler's body, so walking both gave the
  action a copy of every edge the handler had.
- Preferring the caller's own declaration hid the virtual dispatch it was
  supposed to expose. Found while building the fixture for it.

New fixture WalkthroughSolution, whose proportions are its point: one action,
one handler, and a Recalculate that two controllers override. No existing
fixture could reach that case.

Nothing consumes the slice yet. The Mermaid diagram, the document, the CLI
command and the MCP tool are phase 2. 363 tests, zero warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W45tzJFX3NoSrk7svtQeKT
Comment on lines +68 to +72
foreach (var action in controller.Actions)
{
if (action.ActionId.Equals(seed, StringComparison.OrdinalIgnoreCase))
return Action(controller, action);
}
Comment on lines +79 to +86
foreach (var method in controller.Methods)
{
if (method.Name.Equals(seed, StringComparison.OrdinalIgnoreCase)
|| $"{controller.ClassName}.{method.Name}".Equals(seed, StringComparison.OrdinalIgnoreCase))
{
return Method(controller, method);
}
}
Comment on lines +197 to +205
foreach (var related in _project.Entities
.SelectMany(entity => entity.Relationships)
.Where(relationship => relationship.PropertyName.Equals(propertyName, StringComparison.Ordinal))
.Select(relationship => relationship.RelatedEntity)
.Distinct(StringComparer.Ordinal))
{
if (Entity(related) is { } entity)
yield return entity;
}
Comment on lines +214 to +218
foreach (var name in controller.ReferencedEntities)
{
if (Entity(name) is { } referenced)
yield return referenced;
}
Comment on lines +245 to +259
else if (candidates.Count > 1)
{
// The shape a virtual call takes in syntax: a base declaration and its overrides all
// carry one name, and which of them runs is decided at run time.
if (seenUnresolved.Add($"{from.Node.Id}|{name}"))
{
unresolved.Add(new UnresolvedCall
{
From = from.Node.Id,
CallName = name,
Candidates = [.. candidates.Select(candidate => candidate.Node.Name)],
Reason = "several declarations carry this name; syntax alone cannot say which one runs",
});
}
}
Comment on lines +268 to +272
foreach (var named in typeNames)
{
if (index.Entity(named) is { } entity)
yield return (entity, SliceEdgeKind.Touches, false);
}
Comment on lines +336 to +343
foreach (var node in code.DescendantNodes())
{
if (node is not SimpleNameSyntax name || memberNodes.Contains(name))
continue;

if (seen.Add(name.Identifier.Text))
types.Add(name.Identifier.Text);
}
@peopleworks
peopleworks merged commit 6c8a308 into main Aug 23, 2026
6 checks passed
@peopleworks
peopleworks deleted the feat/process-slice branch August 23, 2026 04:47
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