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:
standardsExtractorinstance - 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:
- Dynamically iterates over all keys (not hardcoded list)
- Separates concerns: code standards vs. non-code (git, config, docs, diagrams)
- Normalizes names:
errors→error-handlingfor backwards compatibility - 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¶
- Shared extraction: All formatters use
StandardsExtractor - Common base class: Shared utilities prevent drift
- Parity tests:
semantic-parity.spec.tsvalidates identical handling - Golden file tests: Snapshot testing catches regressions
Adding New @standards Keys¶
When you add a custom key like @standards { security: [...] }:
- No code changes needed - extractors handle arbitrary keys
- All formatters automatically include it in output
- 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:
- Extend BaseFormatter:
export class MyFormatter extends BaseFormatter {
format(ast: Program): FormatterResult {
// Use this.standardsExtractor.extract() for @standards
}
}
- Register in FormatterRegistry:
-
Add parity tests: Include in
semantic-parity.spec.ts -
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