Skip to content

Formatter Architecture

This guide explains the internal architecture of PromptScript formatters, focusing on how they maintain parity when processing .prs files into target-specific outputs.

Overview

PromptScript compiles to 37 AI agent targets (GitHub Copilot, Claude Code, Cursor, Windsurf, Cline, and more). Each formatter must produce semantically equivalent output from the same input. To ensure this parity, formatters share common extraction and rendering logic.

flowchart TB
    subgraph Input
        PRS[".prs file"]
    end

    subgraph "Compiler Pipeline"
        Parser["Parser"]
        Resolver["Resolver"]
        Validator["Validator"]
    end

    subgraph "Formatters Package"
        AST["Resolved AST"]

        subgraph "Shared Components"
            SE["StandardsExtractor"]
            BR["BaseFormatter"]
            SR["SectionRegistry"]
        end

        subgraph "Target Formatters"
            GH["GitHubFormatter"]
            CL["ClaudeFormatter"]
            CU["CursorFormatter"]
            AG["AntigravityFormatter"]
            FA["FactoryFormatter"]
            OC["OpenCodeFormatter"]
            GE["GeminiFormatter"]
        end

        subgraph "MarkdownInstructionFormatter (31 agents)"
            MI["MarkdownInstructionFormatter"]
            T1["Tier 1: Windsurf, Cline, Roo Code, Codex, Continue"]
            T2["Tier 2: Augment, Goose, Kilo Code, Amp, Trae, Junie, Kiro CLI"]
            T3["Tier 3: Cortex, Crush, Command Code, Kode, + 15 more"]
        end
    end

    subgraph Output
        GHO["copilot-instructions.md"]
        CLO["CLAUDE.md"]
        CUO[".cursorrules"]
        AGO["antigravity.md"]
    end

    PRS --> Parser --> Resolver --> Validator --> AST
    AST --> BR
    BR --> SE
    BR --> SR
    SE --> GH & CL & CU & AG & FA & OC & GE & MI
    SR --> GH & CL & CU & AG & FA & OC & GE & MI
    MI --> T1 & T2 & T3
    GH --> GHO
    CL --> CLO
    CU --> CUO
    AG --> AGO

Core Components

BaseFormatter

All formatters extend BaseFormatter, which provides:

  • Shared utilities: dedent(), wrapText(), markdown helpers
  • Shared extractors: standardsExtractor instance
  • Common interface: format(ast): FormatterResult
// Simplified structure
abstract class BaseFormatter {
  protected readonly standardsExtractor = new StandardsExtractor();

  abstract format(ast: Program): FormatterResult;

  protected dedent(text: string): string {
    /* ... */
  }
  protected wrapText(text: string, width: number): string {
    /* ... */
  }
}

StandardsExtractor

The StandardsExtractor ensures all formatters handle @standards blocks identically. It:

  1. Dynamically iterates over all keys (not hardcoded list)
  2. Separates concerns: code standards vs. non-code (git, config, docs, diagrams)
  3. Normalizes names: errorserror-handling for backwards compatibility
  4. Supports multiple formats: arrays, objects, strings
// Internal extraction result
interface ExtractedStandards {
  codeStandards: Map<string, StandardsEntry>; // typescript, security, etc.
  git?: GitStandards;
  config?: ConfigStandards;
  documentation?: DocumentationStandards;
  diagrams?: DiagramStandards;
}

Extraction Flow

flowchart LR
    subgraph "@standards block"
        TS["typescript: [...]"]
        SEC["security: [...]"]
        GIT["git: {...}"]
        CUSTOM["custom-key: [...]"]
    end

    subgraph "StandardsExtractor"
        ITER["Iterate all keys"]
        CHECK["Is non-code key?"]
        EXTRACT["Extract entry"]
        NORM["Normalize name"]
    end

    subgraph "Result"
        CS["codeStandards Map"]
        NC["Non-code standards"]
    end

    TS & SEC & CUSTOM --> ITER
    GIT --> CHECK
    ITER --> CHECK
    CHECK -->|No| EXTRACT --> NORM --> CS
    CHECK -->|Yes| NC

Supported Formats

The extractor handles multiple input formats for flexibility:

# Array format (recommended)
@standards {
  typescript: ["Use strict mode", "No any type"]
  security: ["Validate inputs", "Escape output"]
}

# Object format (for boolean flags)
@standards {
  typescript: {
    strictMode: true
    noAny: true
    exports: "named only"
  }
}

# String format (single rule)
@standards {
  typescript: "Always use strict mode"
}

# Legacy format (backwards compatible)
@standards {
  code: {
    style: ["Consistent formatting"]
    patterns: ["Composition over inheritance"]
  }
}

SectionRegistry

Tracks which sections each formatter supports, enabling:

  • Parity validation: Ensure all formatters handle the same blocks
  • Feature coverage: Track capabilities across targets
  • Documentation: Auto-generate feature matrices

Formatter Parity

How Parity is Maintained

  1. Shared extraction: All formatters use StandardsExtractor
  2. Common base class: Shared utilities prevent drift
  3. Parity tests: semantic-parity.spec.ts validates identical handling
  4. Golden file tests: Snapshot testing catches regressions

Adding New @standards Keys

When you add a custom key like @standards { security: [...] }:

  1. No code changes needed - extractors handle arbitrary keys
  2. All formatters automatically include it in output
  3. Parity tests verify consistent handling

Non-Code Keys

These keys are handled specially (not included in code standards section):

Key Purpose Extracted As
git Commit conventions GitStandards
config Tool configuration ConfigStandards
documentation Doc standards DocumentationStandards
diagrams Diagram preferences DiagramStandards

Testing Architecture

Test Layers

┌─────────────────────────────────────────┐
│         semantic-parity.spec.ts         │  Cross-formatter parity
├─────────────────────────────────────────┤
│         golden-files.spec.ts            │  Snapshot regression
├─────────────────────────────────────────┤
│    claude.spec.ts, github.spec.ts, ...  │  Per-formatter unit tests
├─────────────────────────────────────────┤
│         extractors.spec.ts              │  Extraction unit tests
└─────────────────────────────────────────┘

Parity Matrix

The parity matrix (parity-matrix.spec.ts) validates that:

  • All formatters handle the same block types
  • Output structure is semantically equivalent
  • Edge cases are handled consistently

Adding a New Formatter

Most new AI agents follow a standard markdown-based instruction format. For these agents, adding support requires only a simple constructor call using MarkdownInstructionFormatter -- no new class needed.

Simple Case: MarkdownInstructionFormatter (most agents)

If the new agent reads markdown instructions from a file or directory:

export class MyAgentFormatter extends MarkdownInstructionFormatter {
  constructor() {
    super({
      name: 'my-agent',
      outputPath: '.myagent/rules/project.md',
      description: 'My Agent rules (Markdown)',
      defaultConvention: 'markdown',
      mainFileHeader: '# Project Rules',
      dotDir: '.myagent',
      skillFileName: 'SKILL.md',
      hasAgents: false,
      hasCommands: false,
      hasSkills: true,
    });
  }
}

This is how 31 of the 38 supported agents are implemented. Each formatter is a thin subclass that only provides constructor configuration. The MarkdownInstructionFormatter base class handles all standard sections (@identity, @standards, @shortcuts, etc.) and outputs well-structured markdown to the configured path.

Advanced Case: Custom Formatter

For agents with unique output formats (e.g., TOML commands, frontmatter metadata, multiple output files), extend BaseFormatter directly:

  1. Extend BaseFormatter:
export class MyFormatter extends BaseFormatter {
  format(ast: Program): FormatterResult {
    // Use this.standardsExtractor.extract() for @standards
  }
}
  1. Register in FormatterRegistry:
registry.register('my-target', new MyFormatter());
  1. Add parity tests: Include in semantic-parity.spec.ts

  2. Add golden files: Create expected output snapshots

Key Design Decisions

Why Dynamic Key Iteration?

Previously, formatters had hardcoded keys (typescript, naming, errors, testing). This caused:

  • Parity issues: GitHub iterated dynamically, others didn't
  • Limited extensibility: Users couldn't add custom keys
  • Maintenance burden: Each new key required code changes

The StandardsExtractor solves this by iterating over all keys dynamically.

Why Separate Non-Code Keys?

Keys like git and diagrams have structured output requirements different from code standards. Separating them allows:

  • Type-safe extraction: Each has its own interface
  • Specialized rendering: Different output format per formatter
  • Clear semantics: Users know what to expect

Why Not Export StandardsExtractor?

The extractor is an internal implementation detail. Keeping it internal allows:

  • API stability: Can refactor without breaking changes
  • Simpler public API: Users don't need to understand internals
  • Flexibility: Can change extraction strategy transparently