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.
- 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.
- 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.
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.
- 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.
Summary
src/engine/engine.tsis 222 lines and currently holds three distinct responsibilities: orchestration, traversal/matching, and action resolution. The traversal logic (findFirstMatchTraced,matchPackRulesTraced) belongs insrc/matcher/, and the action-resolution + fallback-event-synthesis logic belongs insrc/resolver/. Extracting these leavesengine.tsas 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.tsat lines 46–142 and thesrc/matcher/,src/resolver/modules. All are still valid.findFirstMatchTraced(lines 99–110) andmatchPackRulesTraced(lines 112–141) iterate commands → packs → rules and dispatch tomatchesMatcher. This is the matcher's job, not the engine's.matchPackRulesTracedcallsresolveAction, thendetectFallback(defined as a free function at lines 153–166), and synthesizes therule-matchedandfallback-triggeredDomainEvents inline. The resolver module already ownsresolveAction; the rest of the glue belongs there too.processMatchandmatchAndResolvemix 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.Proposed Change
New:
src/matcher/runner.ts(or extendmatchers.ts)commands, thenpacks, thenpack.rules.rule.phase !== 'before-tool'(preserves the existing// TODO(after-tool)comment at the call site).matchesMatcher(rule.match, matchCtx, predicateRegistry).(matchCtx, rule, cmd)triple, ornullif nothing matched.New:
src/resolver/rule-resolver.ts(or extendaction-resolver.ts)matchedValue = matchCtx.command || matchCtx.filePath || ''.replacementoffrule.defaultActionwhen present.resolveAction().detectFallbackcomparison andrule-matched/fallback-triggeredevent synthesis.src/engine/engine.tsso the engine no longer definesdetectFallback.Slim
src/engine/engine.tsto ~60 linesprocessMatch/matchAndResolvebecome:matchPackRulesTraced,findFirstMatchTraced, and the freedetectFallbackfunction are deleted fromengine.ts.Behavior Contract
Identical to today:
commands×packs×rules.rule-matchedevent for every fired rule; samefallback-triggeredevent whenresolveActionchanges the action type.processMatch({ action, events })andmatchAndResolve(): GuardrailAction | nullsignatures.predicateRegistryexposure for adapter-side registration.Out of Scope
// TODO(after-tool)comment).resolveAction()'s fallback chain.Alternatives Considered
engine.tslength but keep the layering muddled; the module split makes the matcher/resolver boundaries explicit and matches the existing directory structure.matchPackRulesTracedpublic 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.processMatchto a free function only. The class instance is still useful for the per-enginePredicateRegistry+StatsTracker; keep the class, slim the methods.