Skip to content

Compiler Safety & Output Conflict Detection

Date: 2026-03-22 Status: Draft Scope: packages/cli, packages/core, packages/compiler

Problem Statement

During the migration of the "Everything Claude Code" (ECC) project (28 agents, 116 skills, 59 commands) to PromptScript, four compiler safety issues were discovered that block adoption in large, multi-target projects:

  1. Silent overwrites — The default behavior allows overwriting existing user files without explicit opt-in. Three independent review bots flagged this as P1 risk during the ECC migration PR.
  2. Conflicting output paths — Multiple targets (factory, codex, amp) all map to AGENTS.md via DEFAULT_OUTPUT_PATHS. When multiple such targets are enabled simultaneously, the last write wins silently with no warning.
  3. Unrecoverable file/directory conflicts — The Cline formatter writes to .clinerules (a file), but also attempts to create .clinerules/skills/ (a directory). If .clinerules already exists as a plain file, Node.js throws a raw ENOTDIR error with no actionable guidance.
  4. False diffs from generation markersprs diff shows timestamp-only changes as real diffs because it does not strip PromptScript generation markers before comparing. The compile command already has a stripMarkers() function that handles this correctly, but it is not shared.

Goals

  1. Make the compiler safe by default for large projects with many enabled targets
  2. Surface output path collisions before compilation writes any files
  3. Provide actionable, user-friendly error messages for all filesystem edge cases
  4. Eliminate false positives in prs diff output
  5. Maintain backward compatibility with --force as an explicit escape hatch

Non-Goals

  • Automatic conflict resolution (renaming files, merging outputs)
  • Per-target output directory isolation (would require config schema changes)
  • Changes to the formatter output logic itself
  • Supporting multiple targets writing different content to the same path (this is inherently a conflict)

Design

1. Safer default overwrite behavior

Current behavior: output.overwrite in promptscript.yaml is typed as boolean | undefined. When undefined, the compile command falls through to interactive prompting in TTY mode, or silently overwrites in non-interactive mode (CI/CD). This is the dangerous case — a CI pipeline can destroy hand-crafted files without warning.

Proposed behavior:

  • Treat undefined the same as false in non-interactive mode.
  • When overwrite is false (or unset) and a conflict is detected in non-interactive mode, fail with exit code 1 and a clear error listing all conflicting files:
Error: Cannot overwrite existing files (not generated by PromptScript):
  - .clinerules (last modified 2026-03-15)
  - CLAUDE.md (last modified 2026-03-10)

Use --force to overwrite, or set output.overwrite: true in promptscript.yaml.
  • Interactive mode (TTY) retains the current per-file prompt behavior — no change.
  • --force flag continues to bypass all overwrite checks, as it does today.

Files affected:

File Change
packages/core/src/types/config.ts Update JSDoc on overwrite to document the new default semantics
packages/cli/src/commands/compile.ts Change the non-interactive fallback from silent overwrite to error-and-exit

2. Output path conflict detection

Current behavior: DEFAULT_OUTPUT_PATHS maps multiple targets to the same file. Currently:

Output path Targets
AGENTS.md factory, codex, amp, cursor (agents-md version)
.clinerules cline

When a user enables both factory and codex, both formatters generate content for AGENTS.md. The last target processed wins; earlier output is silently discarded. There is no validation, warning, or error.

Additionally, github in full mode generates AGENTS.md as an additional file, creating a potential three-way collision with factory and codex.

Proposed behavior:

Add a pre-compilation validation step that scans all enabled targets and their output paths (primary path from DEFAULT_OUTPUT_PATHS or custom output, plus any additionalFiles the formatter declares). If two or more targets resolve to the same output path:

  • Default mode: Emit a warning to stderr:
    Warning: Targets 'factory' and 'codex' both write to AGENTS.md. Last target processed wins.
    
  • --strict mode: Treat the conflict as an error and exit with code 1:
Error: Output path conflict detected:
  AGENTS.md <- factory, codex

Disable one of the conflicting targets or assign a custom output path.

The compiler must expose a method to resolve output paths for all enabled targets before running formatters. This avoids wasted compilation work when --strict is active.

Files affected:

File Change
packages/cli/src/commands/compile.ts Add detectOutputConflicts() validation step before compilation loop
packages/compiler/src/compiler.ts Expose resolveOutputPaths(config): Map<string, string[]> that maps each output path to the list of targets that write to it
packages/core/src/types/config.ts No schema changes needed; uses existing DEFAULT_OUTPUT_PATHS and per-target output overrides

Algorithm:

function detectOutputConflicts(enabledTargets, config):
  pathMap = new Map<string, string[]>()
  for each target in enabledTargets:
    paths = [resolvedPrimaryPath(target, config)]
    paths.push(...resolvedAdditionalPaths(target, config))
    for each path in paths:
      pathMap.get(path).push(target)
  conflicts = entries in pathMap where value.length > 1
  return conflicts

3. Graceful file/directory conflict handling (Cline ENOTDIR)

Current behavior: The Cline formatter's primary output path is .clinerules (a file). In multifile or full mode, it also generates skill files under .clinerules/skills/promptscript/. If .clinerules already exists as a plain file (from a previous simple-mode compilation or manual creation), fs.mkdir('.clinerules/skills/promptscript', { recursive: true }) throws:

ENOTDIR: not a directory, mkdir '.clinerules/skills/promptscript'

This raw Node.js error is opaque to users and halts the entire compilation, including other targets that would have succeeded.

Proposed behavior:

Wrap file-writing operations in the compile command's output loop with a targeted catch for ENOTDIR (and the related EEXIST when a directory exists but a file write is attempted). On catch:

  1. Emit a user-friendly error for that specific target:
    Error [cline]: Cannot create directory '.clinerules/skills/' because '.clinerules'
    already exists as a file. Remove the file or disable the cline target.
    
  2. Mark that target as failed.
  3. Continue compilation for remaining targets.
  4. At the end, exit with code 1 if any target failed, with a summary of failures.

This pattern (fail-per-target, continue others) is consistent with how build systems handle partial failures and is more useful in CI than aborting on the first error.

Files affected:

File Change
packages/cli/src/commands/compile.ts Add try/catch around per-target file writing with ENOTDIR/EEXIST detection and user-friendly error message

4. Diff command marker stripping

Current behavior: The compile command has a stripMarkers() function (defined at line 88 of compile.ts) that removes PromptScript generation markers (HTML comments and YAML comments containing timestamps) from content before comparing existing vs. new output. This prevents unnecessary overwrites when only the timestamp changed.

The diff command does not use stripMarkers(). As a result, prs diff reports changes even when the only difference is the generation timestamp, producing noisy output that makes it hard to see real changes.

Proposed behavior:

  1. Extract stripMarkers() from compile.ts into a shared utility module.
  2. Import and use it in both compile.ts and diff.ts before content comparison.

The shared utility should be placed in the CLI package since it is specific to CLI output handling (not a core/compiler concern).

Files affected:

File Change
packages/cli/src/utils/markers.ts New file: export stripMarkers(content: string): string
packages/cli/src/commands/compile.ts Remove inline stripMarkers(), import from ../utils/markers.js
packages/cli/src/commands/diff.ts Import stripMarkers from ../utils/markers.js, apply before diff comparison

Testing Strategy

1. Safer default overwrite

  • Unit test: Non-interactive mode with overwrite: undefined and existing non-PromptScript files exits with code 1 and lists conflicting files.
  • Unit test: Non-interactive mode with overwrite: true proceeds without error.
  • Unit test: --force flag bypasses overwrite protection regardless of config.
  • Integration test: Interactive mode still prompts per-file (existing behavior preserved).

2. Output path conflict detection

  • Unit test: Two targets mapping to the same path triggers a warning in default mode.
  • Unit test: Same scenario with --strict triggers an error and exits.
  • Unit test: Custom output override on one target resolves the conflict (no warning).
  • Unit test: github in full mode with factory enabled detects AGENTS.md collision.

3. Graceful file/directory conflict handling

  • Unit test: Mock fs.mkdir throwing ENOTDIR, verify user-friendly error is emitted and other targets continue.
  • Integration test: Create .clinerules as a file, run compile with cline + claude targets, verify claude succeeds and cline reports a clear error.

4. Diff marker stripping

  • Unit test: stripMarkers() removes HTML comment markers (<!-- Generated by PromptScript ... -->).
  • Unit test: stripMarkers() removes YAML comment markers (# Generated by PromptScript ...).
  • Unit test: stripMarkers() preserves all other content unchanged.
  • Integration test: prs diff with only timestamp changes reports no differences.

Migration / Breaking Changes

Breaking: Non-interactive overwrite default

What changes: Projects that rely on prs compile silently overwriting files in CI/CD without --force or output.overwrite: true will now get an error.

Migration path:

  1. Add --force to CI scripts, OR
  2. Add output.overwrite: true to promptscript.yaml

Risk: Low. The marker detection system already identifies PromptScript-generated files and overwrites them freely. Only files not generated by PromptScript (i.e., user-created files) are affected by this change. The scenarios where CI silently destroys user files are exactly the cases we want to catch.

Non-breaking: All other changes

  • Output conflict warnings are informational (no behavior change without --strict).
  • ENOTDIR handling is purely additive error recovery.
  • stripMarkers extraction is an internal refactor with no public API change.

Open Questions

  1. Conflict resolution order: Should we define a deterministic target processing order, or is "last write wins" acceptable as long as we warn? A deterministic order would let users predict which target's output survives.

  2. Per-target overwrite config: Should TargetConfig gain its own overwrite field to allow fine-grained control? For example, a user might want --force behavior for claude but protection for github. This would add config complexity but enables more precise control in large projects.

  3. Cline formatter restructuring: Should the Cline formatter be updated to always use a directory (.clinerules/) instead of sometimes a file and sometimes a directory? This would eliminate the file/directory conflict at the source rather than just handling it gracefully. However, it would be a breaking change for existing Cline users.

  4. Warning fatigue: For the output path conflict warning, should we suppress it when the user has explicitly configured custom output paths that happen to collide? An explicit collision may be intentional (e.g., wanting the union of two formatters' output in one file).