# 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 markers** — `prs 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).
