Skip to content

refactor(engine): extract traversal to matcher and resolution+event-synthesis to resolver #9

Description

@nimser

Summary

src/engine/engine.ts is 222 lines and currently holds three distinct responsibilities: orchestration, traversal/matching, and action resolution. The traversal logic (findFirstMatchTraced, matchPackRulesTraced) belongs in src/matcher/, and the action-resolution + fallback-event-synthesis logic belongs in src/resolver/. Extracting these leaves engine.ts as a thin orchestrator (extract targets → validate → call matcher → call resolver → record stats) of roughly ~60 lines.

Findings (verified against current code)

All findings below were checked against src/engine/engine.ts at lines 46–142 and the src/matcher/, src/resolver/ modules. All are still valid.

  1. Traversal/matching logic lives in the engine. findFirstMatchTraced (lines 99–110) and matchPackRulesTraced (lines 112–141) iterate commands → packs → rules and dispatch to matchesMatcher. This is the matcher's job, not the engine's.
  2. Action resolution + fallback wiring + event synthesis live in the engine. matchPackRulesTraced calls resolveAction, then detectFallback (defined as a free function at lines 153–166), and synthesizes the rule-matched and fallback-triggered DomainEvents inline. The resolver module already owns resolveAction; the rest of the glue belongs there too.
  3. processMatch and matchAndResolve mix orchestration with business logic. The class method (lines 35–50) and the module-level functions (lines 187–205) delegate straight into the private traversal methods, so the engine is doing both scheduling and work.
  4. No behavior or test changes required. All 199 tests pass; this is a pure refactor. Tests stay green and untouched.

Proposed Change

New: src/matcher/runner.ts (or extend matchers.ts)

export function findFirstMatchAcrossPacks(
  packs: RulePack[],
  ctx: ToolCallContext,
  commands: string[],
  predicateRegistry: PredicateRegistry
): { matchCtx: ToolCallContext; rule: BeforeToolRule; cmd: string } | null
  • Iterates commands, then packs, then pack.rules.
  • Filters out rule.phase !== 'before-tool' (preserves the existing // TODO(after-tool) comment at the call site).
  • Calls matchesMatcher(rule.match, matchCtx, predicateRegistry).
  • Returns the first (matchCtx, rule, cmd) triple, or null if nothing matched.

New: src/resolver/rule-resolver.ts (or extend action-resolver.ts)

export function resolveRuleAction(
  rule: BeforeToolRule,
  matchCtx: ToolCallContext,
  capabilities: HarnessCapabilities
): { action: GuardrailAction; events: DomainEvent[] }
  • Computes matchedValue = matchCtx.command || matchCtx.filePath || ''.
  • Pulls replacement off rule.defaultAction when present.
  • Calls the existing resolveAction().
  • Owns the detectFallback comparison and rule-matched / fallback-triggered event synthesis.
  • Relocated from src/engine/engine.ts so the engine no longer defines detectFallback.

Slim src/engine/engine.ts to ~60 lines

processMatch / matchAndResolve become:

processMatch(ctx, packs, capabilities): MatchResult {
  const { command, filePath } = extractTargets(ctx)
  if (isMissingRequiredFields(ctx, command, filePath)) {
    return this.handleMissingTargetsTraced(ctx)
  }
  const commands = command ? splitCommands(command) : ['']
  const hit = findFirstMatchAcrossPacks(packs, ctx, commands, this.predicateRegistry)
  const result: MatchResult = hit
    ? resolveRuleAction(hit.rule, hit.matchCtx, capabilities)
    : { action: null, events: [] }
  this.statsTracker.record(result.action)
  return result
}

matchPackRulesTraced, findFirstMatchTraced, and the free detectFallback function are deleted from engine.ts.

Behavior Contract

Identical to today:

  • Same first-match-wins semantics across commands × packs × rules.
  • Same rule-matched event for every fired rule; same fallback-triggered event when resolveAction changes the action type.
  • Same stats accounting.
  • Same processMatch({ action, events }) and matchAndResolve(): GuardrailAction | null signatures.
  • Same predicateRegistry exposure for adapter-side registration.

Out of Scope

  • After-tool phase (still reserved per the existing // TODO(after-tool) comment).
  • Changing resolveAction()'s fallback chain.
  • New tests — existing 199 tests must remain green without modification.

Alternatives Considered

  • Inline extraction (no module split). Pulling the two methods into free functions in the same file would reduce engine.ts length but keep the layering muddled; the module split makes the matcher/resolver boundaries explicit and matches the existing directory structure.
  • Make matchPackRulesTraced public on the engine. Exposes the wrong surface (adapters would have to know to call two methods); the helper-function approach keeps the engine's public API stable.
  • Convert processMatch to a free function only. The class instance is still useful for the per-engine PredicateRegistry + StatsTracker; keep the class, slim the methods.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions