Skip to content
8 min read · 1,691 words

Engines & Capabilities

This battery ships no engines. Not as a roadmap item — as the design. What it ships is a declaration contract, a selection rule with the arbitration semantics written down, and a diagnostic pipeline that knows exactly which of three moments stamped each field.

Your formatter is your business. Whether it shells out, runs in-process, or is a thin wrapper over something you already own is a composition decision, made at the composition site, where a reviewer can see it.

The dev tools battery ships with zero engines. It provides no built-in prettier wrapper, no opinionated eslint runner, and no bundled compiler checkers. DevEngine is pure data: an identifier and three optional arrays of capability declarations.

The registry never invokes engine code to discover what an engine can do. Plain data tells the runtime everything required to plan a pipeline, arbitrate conflicting tools, enforce file-system sandboxes, and stamp diagnostic findings without executing a single line of third-party logic before dispatch.

Plain data declarations

An engine declares its capabilities as plain data arrays. Every engine is validated at construction via implementsDevEngine, which fails fast and names the exact array index when an object violates the contract:

typescript
import type { DevEngine } from '@nhtio/adk/batteries/dev-tools'

export const myEngine: DevEngine = {
  id: 'eslint-engine',
  lints: [
    {
      extensions: ['ts', 'tsx', 'js'],
      fixable: true,
      inPlace: true,
      scope: ['src/**', 'test/**'],
      needs: ['rename'],
      lint: async ({ paths, fix, files, root, access }) => {
        // Returns a WorkspaceDelta. An in-place capability's mutation fields are
        // advisory; only its diagnostics are used.
        return { diagnostics: [] }
      },
    },
  ],
}

Engine IDs must be unique within a pipeline. All three capability methods—format, lint, and check—return a WorkspaceDelta. Standardizing on one return shape allows the runtime to apply workspace mutations and collect diagnostics uniformly, regardless of which capability produced them. For checkers, every mutation field in WorkspaceDelta is optional: a checker returns a delta containing only diagnostics, without being forced to invent empty maps it does not need.

The capability taxonomy

A capability belongs to one of three contracts:

  • FormatCapability — rewrites file contents. Declares extensions, and optionally inPlace, scope, needs, generates, and the format(request) execution method.
  • LintCapability — inspects and optionally mutates files. Carries everything FormatCapability has, plus a required fixable: boolean flag.
  • CheckCapability — read-only diagnostics provider. Declares extensions and check(request). It accepts no scope and no needs: a checker that wants to rewrite disk is a formatter and must declare itself as one.

Extensions are lowercase strings without leading dots ('ts', 'json'). The string '*' matches any extension, while the empty string '' explicitly claims extensionless files (such as Dockerfile or Makefile).

Construction-time validation rules

The registration boundary rejects configurations that TypeScript types cannot adequately prevent:

  • inPlace requires a non-empty scope. An empty array scope: [] authorizes zero file-system access. A capability declaring in-place execution without scope could never execute, and it is rejected at startup.
  • Generators must be fixable. A linter declaring generates: true must also declare fixable: true. A non-fixable generator is contradictory: generates licenses file creation, while fixable: false makes any mutation a contract violation.
  • Strict needs validation. Unknown tokens in needs are rejected immediately. Duplicates are silently deduplicated. If a typo like 'mkdirp' were ignored rather than rejected, the capability would be refused at runtime for a capability it believed it had properly declared.

Capability indices (capabilityIndex) remain tied to their zero-based positions in the engine's original declared array. If a capability is omitted due to a missing platform feature or file-system operation, its slot remains a hole rather than renumbering subsequent siblings. Preserving declared indices ensures stable identity across heterogeneous deployments: a selection stage targeting (engineId, 2) will never silently target capability index 1 because an earlier capability was pruned.

Selection mechanics

Selection determines which capabilities run on which files. The dev tools pipeline evaluates all matching capabilities from all engines—unlike media pipelines where capability groups are alternatives that break on first match. Every matching capability across every registered engine is collected as a candidate.

Workspace Files


┌────────────────────────────────────────┐
│ Extension Filter (Declaration Match)   │
└────────────────────────────────────────┘


┌────────────────────────────────────────┐
│ Generator Pass (generates: true)       │
└────────────────────────────────────────┘

      ▼ (If candidates > 1)
┌────────────────────────────────────────┐
│ Selection Onion (Narrow / Reorder)     │
└────────────────────────────────────────┘


┌────────────────────────────────────────┐
│ Tie-Break & Execution Plan             │
│ • format: 1 winner per extension group │
│ • lint & check: all survivors run      │
│ • generators: all survivors run        │
└────────────────────────────────────────┘

The selection process runs in distinct stages:

  1. Extension filter: Candidates are matched synchronously against target extensions from their static declarations.
  2. Selection onion: Selection middleware executes only when there is more than one candidate. If only one candidate matches, the onion is bypassed completely. Selection is arbitration; middleware cannot suppress a lone candidate. A deployment that wishes to disable a capability removes it from the engine list rather than filtering it at dispatch.
  3. Narrow or reorder only: Selection middleware may reorder or narrow ctx.candidates, but it cannot inject new candidates or duplicate existing ones. Candidates are deduplicated by (engineId, capabilityIndex), keeping the first occurrence. Media pipelines discard all candidates after the first survivor, but dev tools run every surviving lint and check capability. Deduplication prevents a duplicated identity from executing twice and applying duplicate deltas.
  4. Execution arbitration:
    • format selects exactly one winning capability per extension group via post-onion tie-breaking.
    • lint and check execute every surviving candidate. If an engine declares separate .ts and .tsx linter entries, both run when both file types are present.
    • Invocation deduplication: A capability is invoked once per step, passing the union of all matched paths across all groups it won—never once per won group.

Generator planning

Capabilities with generates: true may create files when invoked with an empty path list (paths: []). This capability must be explicitly declared; the runtime never guesses whether an empty-path execution is a no-op formatter or a generative tool.

Generators are resolved in a separate second pass after extension groups. When a workspace is empty, no extension groups exist—there are no extensions to arbitrate and no winning groups to calculate. Forcing generators into extension matching would require synthetic extensions and arbitrate generators against unrelated file formatters.

Every post-onion generator survivor runs, including formatters. The one-per-group restriction for standard formatters prevents multiple tools from fighting over existing file contents, but generators produce distinct outputs. If two generators happen to emit the same path, the collision is resolved safely at runtime: the first delta applies, and the second fails its file-absence check, naming the contested path and both engines involved.

Planning and dispatch are separate phases

Pipeline execution is divided into two discrete phases: plan() and dispatch().

typescript
const { invocations, skipped, scopeExcluded } = await registry.plan(request)

for (const invocation of invocations) {
  // Each dispatch sees its predecessor's applied delta. The runtime owns the loop.
  const stamped = await registry.dispatch(invocation, dispatchContext)
  await applyDelta({ delta: stamped /* ...workspace bookkeeping */ })
}

plan() is asynchronous, executing the selection onion without running any engine logic. The returned DevPlanResult includes planned invocations, skipped candidates, and scopeExcluded paths. Returning explicit skipped and excluded metadata ensures the runtime can surface why an operation was omitted rather than interpreting an empty invocation array as an unhandled error.

dispatch() executes capabilities individually in sequence. The registry is completely stateless and reentrant; all dispatch state rides on the invocation payload, allowing concurrent operations across a shared pipeline without cross-talk. Sequential dispatch ensures each capability observes the workspace delta applied by its predecessor.

Selection is frozen per step; workspace contents are live

Selection answers who runs on what and is frozen before the first capability dispatches. The workspace answers what is in those files and is live.

If a TypeScript formatter creates generated/schema.tsx midway through a step, that new .tsx file is not dispatched to linters during the active step. Re-planning after every delta would make the step's execution gate targets untruthful, pull unexpected linters into steps the caller did not authorize, and introduce infinite loops between mutually generating formatters. New files reside in the live workspace immediately—meaning subsequent write, check, or separate step executions will see and process them normally.

Extensions versus scope

extensions and scope address two independent security and dispatch questions:

  • extensions determines what files a capability is selected to process.
  • scope determines what files an in-place capability has permission to touch.

The in-place execution allowlist is computed as:

$$\text{Allowlist} = \text{Declared Scope} \cap \text{Current Workspace} \cap \text{Selector}$$

This allowlist is intentionally not restricted to the capability's matched extensions. A TypeScript formatter scoped to src/** is selected exclusively for .ts files, but it may legitimately read or update src/config.json, maintain a cache file, or emit sidecar metadata. Constraining disk access strictly to matched extensions breaks real-world developer tools.

The allowlist is recomputed per dispatching step rather than locked at pipeline initialization. Recomputing allows an in-place fixer to access files that were added by earlier steps (such as patch applications or write operations).

The fixable permission boundary

When running lint with { fix: true }, all post-onion candidates are executed. Non-fixable capabilities run with fix: false, while fixable capabilities receive fix: true. Non-fixable linters (such as type-aware checkers) are never suppressed during a fix run; dropping them would silently hide critical diagnostics.

Requesting lint with { fix: false } against an inPlace capability is refused during execution preflight. An in-place capability declares that it operates by mutating disk directly. Calling it while forbidding mutations requests behavior the engine never declared. Engines supporting both modes declare two distinct capability entries.

Diagnostic lifecycle and coordinate safety

Diagnostics progress through three discrete stamping stages:

┌─────────────────────────┐
│ Engine produces         │  RawDiagnostic
│ (No engineId, no scope) │  { path, message, line, column }
└─────────────────────────┘


┌─────────────────────────┐
│ Registry stamps         │  StampedDiagnostic
│ (At dispatch)           │  engineId: string | null
└─────────────────────────┘


┌─────────────────────────┐
│ Runtime stamps          │  Final Diagnostic
│ (Post-delta/re-read)    │  outOfScope: boolean
└─────────────────────────┘
  1. RawDiagnostic (Engine): Engines construct raw diagnostics. Engines cannot stamp engineId because self-reported identifiers cannot be trusted.
  2. engineId Stamping (Registry): The registry attaches engineId at dispatch time based on the capability it invoked. Runtime-generated diagnostics carry engineId: null (such as boundary violations, unmatched extensions, or scope counts). The sole exception is the advisory-delta discrepancy warning, which carries the offending engine's ID so the failure is actionable.
  3. outOfScope Stamping (Runtime): The runtime evaluates and stamps outOfScope only after workspace deltas are applied and in-place changes are authoritatively re-read. Scope is determined by workspace membership. If stamped at dispatch, an engine adding new.ts while reporting a diagnostic on new.ts would have its own newly created file marked out of scope.

Path and coordinate sanitization

  • Path values: Runtime group-level diagnostics use path: null to describe entire unmatched groups or overall scope exclusions. Conversely, an engine returning path: null commits a contract violation: an engine diagnostic without a file location is not actionable.
  • Malformed coordinates: Invalid coordinates (line: 0, negative values, floating-point numbers, or endLine preceding line) are dropped without failing the step. A malformed coordinate is an engine bug, but the underlying text message remains useful.
  • Cascading coordinate drops: When a base coordinate is dropped, its dependent fields fall with it. Dropping an invalid line strips column, endLine, and endColumn. An inverted same-line range (column > endColumn) drops endColumn while preserving the start position. A single step-level warning summarizes dropped coordinates by engine and count.