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 48 AI agent targets. Rich native formatters, AGENTS.md targets, and shared Markdown formatters map the same PromptScript source to each platform's instruction and capability contracts. Shared extraction and rendering logic preserves semantic parity where native platforms support equivalent features.

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"]
            GR["GrokFormatter"]
        end

        subgraph "Shared Markdown Targets (40 targets)"
            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 and AGENTS.md target families"]
        end
    end

    subgraph Output
        GHO["copilot-instructions.md"]
        CLO["CLAUDE.md"]
        CUO[".cursor/rules/project.mdc"]
        AGO[".agent/rules/project.md"]
    end

    PRS --> Parser --> Resolver --> Validator --> AST
    AST --> BR
    BR --> SE
    BR --> SR
    SE --> GH & CL & CU & AG & FA & OC & GE & GR & MI
    SR --> GH & CL & CU & AG & FA & OC & GE & GR & 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,
    });
  }
}

Most supported targets use a thin formatter class that provides constructor configuration. The MarkdownInstructionFormatter base class handles standard sections (@identity, @standards, @shortcuts, and others) and outputs 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