Skip to content

fix(schema): preserve YAML formatting when forking a schema - #1607

Merged
clay-good merged 14 commits into
mainfrom
claude/pr-1130-merge-ready-5bd4ef
Aug 11, 2026
Merged

fix(schema): preserve YAML formatting when forking a schema#1607
clay-good merged 14 commits into
mainfrom
claude/pr-1130-merge-ready-5bd4ef

Conversation

@clay-good

@clay-good clay-good commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Status: LGTM — verified merge-ready. Supersedes #1130 (by @linjinze999) with the maintainer-requested validation restored and regression coverage added.

What was missing / motivation

openspec schema fork renamed the forked schema.yaml by round-tripping through parseSchemastringifyYaml. That re-serialization dropped comments and could rewrite block-scalar style (a long literal | folded to >) and reorder keys, so a forked schema no longer matched its source. #1130 (thanks @linjinze999) fixed this by switching to yaml's Document API.

What it does

  • Renames via the yaml Document API (parseDocument + doc.set('name', …) + doc.toString()), so block scalars, comments, and key order in the source schema.yaml survive the fork.
  • Keeps the structural parseSchema validation before the document mutation, so an invalid source (e.g. an artifact missing required fields) is still rejected rather than silently serialized. This addresses the alfred-openspec CHANGES_REQUESTED review on fix(schema): preserve YAML formatting when forking a schema #1130.

Proof it works

test/commands/schema-fork-fidelity.test.ts (new) covers both halves alfred asked for:

  1. Formatting preservation — forks a schema with a banner comment, an inline comment, and a literal instruction: | block; asserts the fork keeps | (not flattened), preserves both comments, updates name:, and is byte-identical to the source except the name line.
  2. Invalid-source rejection — forking a structurally invalid schema fails (non-zero exit, "forked": false, Invalid schema). Verified this test fails without the restored parseSchema line and passes with it.
  3. Executable documentation of the pre-fix object round-trip dropping comments.

Full gate: npm run build + npx vitest run → 2791 passed; the only failures (config-profile, artifact-workflow, adapters.test missing-dep) are pre-existing on main and PR-independent (confirmed by stash/revert compare). Lint + typecheck clean.

Notes / nits

  • No filed issue exists for this bug, so there is intentionally no Closes #N.
  • The original fix(schema): preserve YAML formatting when forking a schema #1130 description states a short A: | flattens to A: xxx yyy; on the pinned yaml 2.9.0 that specific case was already preserved — the real regressions fixed are comment loss and key-order/scalar-style drift. The fix is sound regardless.

Co-authored with the original author.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Schema forks now preserve comments, formatting, block scalars, key order, and other original YAML details while updating only the schema name.
    • Invalid schemas are rejected before replacement, with clear errors and cleanup of incomplete results.
    • Existing destinations remain protected unless overwrite is explicitly requested.
    • Self-forks, including path aliases, are blocked.
    • Copy or installation failures no longer damage the source or existing destination.
    • Temporary fork files are excluded from schema discovery.
    • YAML-ambiguous names remain preserved as strings.
  • Tests

    • Added comprehensive coverage for fidelity, validation, cleanup, atomicity, self-fork protection, and overwrite behavior.

Rename a forked schema via yaml's Document API (parseDocument + doc.set)
instead of round-tripping through parseSchema/stringifyYaml, so block
scalars, comments, and key order in the source schema.yaml survive the
fork. Keep the structural parseSchema validation before the document
mutation so an invalid source is still rejected (addresses PR #1130
review). Adds fork-level regression coverage for both formatting
preservation and invalid-source rejection.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good
clay-good requested a review from a team as a code owner August 7, 2026 21:06
@clay-good
clay-good requested review from TabishB and removed request for a team August 7, 2026 21:06
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The schema fork command validates sources before replacement, rejects self-forks, stages copies atomically, and updates names through YAML’s Document API. Tests cover formatting preservation, cleanup, destination safety, copy failures, installation rollback, and YAML-ambiguous names.

Changes

Schema fork safety and fidelity

Layer / File(s) Summary
Validation and path safety
src/commands/schema.ts, test/commands/schema-fork-fidelity.test.ts
The command validates the source before replacement, resolves paths for self-fork checks, and preserves existing destinations when overwrite checks or source validation fail.
Staged YAML replacement
src/commands/schema.ts, test/commands/schema-fork-fidelity.test.ts
The command copies schemas to a temporary sibling directory, updates the staged YAML document name, installs the destination, and restores backups when installation fails.
Temporary directory discovery filtering
src/core/artifact-graph/resolver.ts, test/commands/schema-fork-fidelity.test.ts
Schema discovery excludes fork staging and backup directories, including symlinked entries with those names.
YAML fidelity and name serialization
src/commands/schema.ts, test/commands/schema-fork-fidelity.test.ts
The fork preserves comments and formatting while changing the top-level name. Tests cover names such as true, false, null, and off.
Failure recovery regression coverage
test/commands/schema-fork-fidelity.test.ts
Tests cover setup, invalid sources, destination preservation, staging cleanup, copy failures, installation rollback, restore failures, and prior comment loss.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SchemaForkCommand
  participant SourceSchema
  participant StagingDirectory
  participant DestinationSchema
  User->>SchemaForkCommand: request schema fork
  SchemaForkCommand->>SourceSchema: parse and validate source
  SourceSchema-->>SchemaForkCommand: validation result
  SchemaForkCommand->>StagingDirectory: copy source schema
  SchemaForkCommand->>StagingDirectory: update YAML document name
  SchemaForkCommand->>DestinationSchema: install staged schema
  DestinationSchema-->>SchemaForkCommand: restore backup on failure
  StagingDirectory-->>SchemaForkCommand: clean up temporary data
Loading

Possibly related PRs

Suggested reviewers: tabishb, alfred-openspec

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: preserving YAML formatting when forking a schema.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pr-1130-merge-ready-5bd4ef

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/schema.ts`:
- Around line 710-712: Move the trusted-source `schema.yaml` read and
`parseSchema` validation before the destination existence/removal logic in the
schema command flow, while keeping Document API mutation after the copy. Update
the invalid-source regression to assert no destination remains, and add a
`--force` case confirming an existing destination is unchanged when validation
fails; run the focused Vitest command specified in the comment.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 321f939e-2a8a-47ea-8e70-7656808c92b7

📥 Commits

Reviewing files that changed from the base of the PR and between e50bd09 and 72423ab.

📒 Files selected for processing (2)
  • src/commands/schema.ts
  • test/commands/schema-fork-fidelity.test.ts

Comment thread src/commands/schema.ts Outdated
If the source schema is structurally invalid, parseSchema throws after
copyDirRecursive has already created the destination directory, leaving
a broken half-schema on disk that made the next fork report "already
exists". Wrap the read/validate/rename in a try/catch that removes the
just-created destination on any failure and rethrows so the original
error still drives the JSON/exit-code reporting. The cleanup can only
ever delete a directory this run created: the no-force existing-dest
path returns before the copy, and the --force path removes the prior
directory first. This also closes a mid-write truncation window for free.

Adds regression coverage: cleanup + retryability on invalid source, the
pre-existing-destination-is-never-touched invariant, and a lock-in that
YAML-ambiguous names (true/false/null/off) round-trip as strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

Follow-up hardening (7424043): an adversarial edge-case pass turned up no data-loss or type-corruption reachable through the Document-API rename (multi-doc YAML makes parse() throw before we get there; ambiguous names like true/null are quoted and reload as strings; BOM/CRLF/trailing-newline are all at parity with the old path). The one worthwhile robustness gap it found: a structurally-invalid source left the freshly-copied destination dir on disk (a pre-existing wart this code sits on), so the next fork reported "already exists". Now the read/validate/rename is wrapped so any failure removes only the directory this run created and rethrows the original error. Added regression coverage for cleanup + retryability, the never-touch-a-pre-existing-dir invariant, and a string-typing lock for YAML-ambiguous names. Full suite: 2796 passed; the 2 failures (config-profile, artifact-workflow) are pre-existing on main and independent (confirmed via stash-compare).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/schema.ts`:
- Around line 708-730: Move source validation in the schema fork flow before the
--force destination removal logic, using the schema at
trustedSourceDir/schema.yaml rather than the copied destination. Ensure parsing
and Document API serialization succeed before replacing an existing destination;
stage the copy if necessary, then atomically remove/replace the destination only
after validation and serialization complete, preserving the original destination
on failure. Update the surrounding logic anchored by parseSchema,
copyDirRecursive, and the destination cleanup catch block.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 05ff4b54-2960-4642-a423-6b0d7f517e78

📥 Commits

Reviewing files that changed from the base of the PR and between 72423ab and 7424043.

📒 Files selected for processing (2)
  • src/commands/schema.ts
  • test/commands/schema-fork-fidelity.test.ts

Comment thread src/commands/schema.ts
Second hardening pass on the fork command, from an adversarial review of
the previously-added cleanup.

1. Atomicity: validate the source's schema.yaml up front, immediately
   after assertSchemaTreeCanBeCopied and BEFORE the --force removal of an
   existing destination. Previously the source was validated only after
   the copy, so `fork --force <invalid-source> <existing-valid-dest>`
   destroyed the existing destination and then failed, leaving nothing.
   This matches `schema init`, which already validates before it
   overwrites. Behavior is unchanged for valid sources, and the redundant
   post-copy validation is dropped.

2. Never mask the real error: the failure-cleanup rmSync is now wrapped
   in its own try/catch. fs.rmSync's `force` only suppresses ENOENT, not
   EPERM/EBUSY/ENOTEMPTY (e.g. a locked file on Windows or a concurrent
   process), so a failed cleanup could previously replace the real
   "Invalid schema" diagnostic with a confusing filesystem error. The
   original error is now always rethrown.

Adds regression coverage: --force with an invalid source leaves a valid
destination intact; the pre-existing-destination test now uses a valid
source so it exercises the no-force "already exists" guard directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

Second hardening pass (6498ef5), from an adversarial review of the cleanup I added in 7424043:

  1. Atomic --force. Source validation now runs up front (right after the tree check, before the destructive --force removal) instead of after the copy. Previously fork --force <invalid-source> <existing-valid-dest> would delete the existing destination and then fail, leaving the user with nothing. This mirrors schema init, which already validates before it overwrites; behavior is unchanged for valid sources, and the now-redundant post-copy validation is dropped.
  2. Failures never masked. The cleanup rmSync is wrapped in its own try/catch. Node's force flag only suppresses ENOENT, not EPERM/EBUSY/ENOTEMPTY (a locked file on Windows, or a concurrent session), so a failed cleanup could otherwise replace the real "Invalid schema" error with a confusing filesystem error. The original error is now always rethrown.

New regression coverage: --force + invalid source leaves a valid destination intact (proven to fail without the fix). Full suite: 2797 passed; the 2 failures (config-profile, artifact-workflow) are pre-existing on main and independent (confirmed via stash-compare).

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

schema fork with identical source and destination plus --force deletes its own source before failing, and replacement removes an existing destination before a complete fork is staged. Reject identical paths, stage and validate before replacement, and add regressions proving self-fork and copy failures preserve existing content.

…ination

Two data-loss defects in `schema fork --force` (per alfred-openspec review):

1. Self-fork: forking a schema onto itself removed the destination (which IS
   the source) before the copy, so the copy then read a directory it had just
   deleted — destroying the only copy. Now rejected up front by comparing the
   real (symlink-resolved) source and destination paths before any removal.

2. Non-atomic replacement: an existing destination was removed before the new
   fork was fully copied and name-updated, so a mid-copy failure left the user
   with nothing. The fork is now staged in a temporary sibling directory and
   only swapped into place once complete; any failure while staging leaves both
   the source and the existing destination untouched.

Adds regressions: self-fork is rejected with the source intact; a forced fork
whose copy fails leaves the existing destination byte-identical with no staging
leftovers.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

@alfred-openspec Addressed both data-loss defects in schema fork (commit 87d5da4).

1. Reject identical source/destination (self-fork). Before any removal, the destination is resolved to its real path (fs.realpathSync when it exists, else path.resolve) and compared against the already-realpath'd source. A self-fork (e.g. schema fork X X --force) now throws "Cannot fork schema 'X' onto itself" with a nonzero exit and the source is left completely intact — previously --force deleted the destination, which was the source, then failed the copy.

2. Stage-then-replace (atomic replacement). The fork is now built in a temporary sibling directory (fs.mkdtempSync under the project schemas dir): copy the tree, then rewrite name via the yaml Document API in the staging dir. Only once that is complete is any existing destination removed (rmSync) and the staging dir renameSyncd into place. Any failure while staging removes only the staging dir and rethrows, so both the source and the existing destination survive untouched. The up-front parseSchema of the source (already on this branch) is retained, so an invalid source is rejected before staging.

New regressions in test/commands/schema-fork-fidelity.test.ts:

  • rejects a self-fork and leaves the source intact — asserts nonzero exit, onto itself error, and source schema.yaml byte-identical.
  • preserves an existing --force destination when the copy fails — forces copyFileSync to throw (via a passthrough node:fs mock, since ESM forbids spying the namespace export), then asserts the pre-existing destination is byte-identical and no .fork-staging- leftovers remain.
  • Retained: invalid-source rejection and the existing --force invalid-source-preserves-destination test.

Full suite: 3867 passed; the only 2 failures (config-profile.test.ts:389, artifact-workflow.test.ts:1226) are pre-existing on main and unrelated to schema fork.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
test/commands/schema-fork-fidelity.test.ts (2)

260-283: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add an alias-path regression test for the self-fork guard.

This test covers only the identical-name case. The implementation comment in src/commands/schema.ts at Lines 691-696 claims that a symlinked destination or a ./.. spelling of the source is also rejected. No test proves that claim.

Add a case that creates a symlink inside openspec/schemas pointing at src-schema, then forks src-schema onto the symlink name with --force. Assert the rejection and assert that the source stays byte-identical. Canonicalize both sides with fs.realpathSync.native() when you compare paths. Guard the symlink creation so the case skips on platforms without symlink support.

As per coding guidelines, "Add an alias-path regression test when touching path identity logic" and "When asserting existing filesystem paths as identities, canonicalize both actual and expected paths first using FileSystemUtils.canonicalizeExistingPath() in project code and fs.realpathSync.native() in test-only expectations".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/commands/schema-fork-fidelity.test.ts` around lines 260 - 283, Add a
regression case alongside the existing self-fork test that creates a symlink in
openspec/schemas targeting src-schema, skips when symlink creation is
unsupported, and forks src-schema to the symlink with --force. Assert the
command rejects the alias and verify the source schema remains byte-identical;
compare the symlink and source identities using fs.realpathSync.native().

Source: Coding guidelines


13-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the patched module for the default export.

default: actual bypasses the patched copyFileSync. Return one patched object as both the named exports and default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/commands/schema-fork-fidelity.test.ts` around lines 13 - 25, Update the
node:fs mock in the vi.mock factory so the default export references the same
patched object as the named exports; avoid assigning default to the unmodified
actual module, ensuring default.copyFileSync uses the failCopyFileSync behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/commands/schema.ts`:
- Around line 729-732: Update schema discovery in isSchemaDir and listSchemas to
ignore dot-prefixed entries, including temporary .fork-staging-* directories, so
schema validate --all cannot treat staging contents as schemas. Preserve
discovery of non-hidden schema directories and the existing staging copy flow.

---

Nitpick comments:
In `@test/commands/schema-fork-fidelity.test.ts`:
- Around line 260-283: Add a regression case alongside the existing self-fork
test that creates a symlink in openspec/schemas targeting src-schema, skips when
symlink creation is unsupported, and forks src-schema to the symlink with
--force. Assert the command rejects the alias and verify the source schema
remains byte-identical; compare the symlink and source identities using
fs.realpathSync.native().
- Around line 13-25: Update the node:fs mock in the vi.mock factory so the
default export references the same patched object as the named exports; avoid
assigning default to the unmodified actual module, ensuring default.copyFileSync
uses the failCopyFileSync behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 15e2e270-c388-4635-9f41-d0f9228dffcf

📥 Commits

Reviewing files that changed from the base of the PR and between 7424043 and 87d5da4.

📒 Files selected for processing (2)
  • src/commands/schema.ts
  • test/commands/schema-fork-fidelity.test.ts

Comment thread src/commands/schema.ts

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The staging change fixes self-forks and copy or validation failures, but the final swap still deletes the existing destination before renameSync. A forced final-rename failure on 87d5da4 leaves the destination schema missing, so please move the destination to a sibling backup, install staging, restore on failure, and add a final-move regression before approval.

…d final move restores

The stage-then-swap still removed the destination and then renamed staging into
place; if that final rename failed (e.g. a Windows lock) the destination was gone
with no restore. Now, when a destination exists, `fork --force` moves it to a
sibling backup, installs the staged fork, and only then discards the backup. If
the install rename throws, the backup is moved back so the original destination
is never lost. Non-existing destinations keep the simple staging rename.

Adds a regression: forcing the final staging->destination move to fail leaves the
pre-existing destination byte-identical with no staging/backup leftovers.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

@alfred-openspec Addressed the final-move edge case (commit 2c20d4c).

Backup/restore swap. When a destination already exists, fork --force no longer does rmSync(dest) then renameSync(staging→dest). Instead:

  1. renameSync(destinationDir, backupDir) — move the existing destination aside to a unique sibling (${destinationDir}.fork-backup-<pid>-<ts>).
  2. renameSync(stagingDir, destinationDir) — install the staged fork.
  3. on success: rmSync(backupDir, { recursive: true, force: true }).
  4. if the install rename throws: renameSync(backupDir, destinationDir) (guarded) to restore the original, then rethrow — so the destination is never left missing.

When the destination does not exist, the simple renameSync(staging→dest) is kept (no backup needed). The outer catch still cleans up the staging dir on any failure.

New regression in test/commands/schema-fork-fidelity.test.tsrestores the destination when the final install move fails: a passthrough node:fs mock throws only on the staging→destination rename; the test asserts the pre-existing destination is byte-identical afterward and no .fork-staging-/.fork-backup- leftovers remain.

Full suite: 3868 passed; only the 2 pre-existing unrelated failures remain (config-profile.test.ts:389, artifact-workflow.test.ts:1226).

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 11, 2026

Copy link
Copy Markdown

Deploying openspec-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: cc4fd4b
Status: ✅  Deploy successful!
Preview URL: https://e76c1df2.openspec-docs.pages.dev
Branch Preview URL: https://claude-pr-1130-merge-ready-5.openspec-docs.pages.dev

View logs

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backup now restores the common final-move failure, but recovery remains unsafe in two exact cases: a failed restore is swallowed with no backup path reported, and staging plus backup directories are accepted by schema discovery, so concurrent list or validate can consume in-progress artifacts. Please exclude owned temporary directories, surface restore failure and its recovery path, and add regressions before approval.

…irs from discovery

Two more edge cases from alfred's review:

1. A failed backup->destination restore was silently swallowed, so if the final
   install AND the restore both failed the user lost the destination with no clue
   the backup existed. Now that case throws an error naming the backup directory
   and how to move it back, with the original install error attached as cause.

2. The transient `.fork-staging-*` / `<name>.fork-backup-*` directories live
   inside the schemas dir, so a concurrent scan could surface them as real
   schemas. isSchemaDir (the single discovery chokepoint) now excludes them;
   real schema names are kebab-case (no dots) so this can never hide a schema.

Adds regressions: an unrecoverable restore surfaces the backup path (and the
rescued content is really there); fork temp dirs are excluded from listSchemas
and listSchemasWithInfo.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

@alfred-openspec Addressed both remaining issues (commit b6c2206, rebased onto the latest main merge).

1. Unrecoverable restore is no longer swallowed (src/commands/schema.ts). If the final staging→destination install fails, the previous destination is moved back from its backup. Previously a failed restore was caught-and-ignored, so a double failure lost the destination silently. Now that inner catch throws an error that names the backup directory and how to recover it — Your previous schema is preserved at <backupDir>; move it back to <destinationDir> to restore — with the original install error attached as cause.

2. Fork temp dirs are hidden from schema discovery (src/core/artifact-graph/resolver.ts). The transient .fork-staging-* and <name>.fork-backup-* directories live inside the schemas dir, so a concurrent schema list/validate scan could surface them as real schemas. Added isOwnedForkTempDir() and excluded those names in isSchemaDir — the single chokepoint that listSchemas, listSchemasWithInfo, and the validate scan all route through. Real schema names are kebab-case (no dots), so this filter can never hide a legitimate schema.

New regressions in test/commands/schema-fork-fidelity.test.ts:

  • surfaces the backup location when a failed install cannot be restored — forces both the install and the restore rename to throw; asserts the error output names a .fork-backup- dir with preserved at/could not restore, and that the rescued content is genuinely present in that backup dir on disk.
  • excludes fork staging/backup temp dirs from schema discovery — creates .fork-staging-* and *.fork-backup-* dirs (each with a valid schema.yaml) and asserts neither listSchemas nor listSchemasWithInfo returns them, while the real schema still appears.

Full suite: 3909 passed; only the 2 pre-existing unrelated failures remain (config-profile.test.ts:389, artifact-workflow.test.ts:1226).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/commands/schema-fork-fidelity.test.ts`:
- Around line 422-441: Update the test around the backup directory lookup to
assert that output contains the actual backupDir value after it is discovered.
Keep the existing generic backup-path assertions and recovery-content validation
unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e1a6f1e-4cf0-4029-8dc2-031ad52837c3

📥 Commits

Reviewing files that changed from the base of the PR and between 2c20d4c and b6c2206.

📒 Files selected for processing (3)
  • src/commands/schema.ts
  • src/core/artifact-graph/resolver.ts
  • test/commands/schema-fork-fidelity.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands/schema.ts

Comment on lines +422 to +441
const output = consoleLogSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(process.exitCode).toBeTruthy();
expect(output).toContain('"forked": false');
// The destination loss is surfaced, not silent: the error names a
// `.fork-backup-` directory and how to restore it.
expect(output).toMatch(/\.fork-backup-/);
expect(output).toMatch(/preserved at/i);
expect(output).toMatch(/could not restore/i);

// The original content really is still on disk in the backup dir the error
// points at (recovery is genuinely possible).
const backupDir = fs
.readdirSync(path.join(tempDir, 'openspec', 'schemas'))
.find((entry) => entry.includes('.fork-backup-'));
expect(backupDir).toBeTruthy();
const rescued = fs.readFileSync(
path.join(tempDir, 'openspec', 'schemas', backupDir!, 'schema.yaml'),
'utf-8'
);
expect(rescued).toBe('name: keep-me\nversion: 3\n');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the actual backup directory in the error output.

Lines 427-429 accept any .fork-backup- text. The test can pass if the command reports a wrong backup location while preserving the real backup directory on disk. After backupDir is found, assert that output contains backupDir.

Proposed test assertion
     expect(backupDir).toBeTruthy();
+    expect(output).toContain(backupDir!);
     const rescued = fs.readFileSync(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const output = consoleLogSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(process.exitCode).toBeTruthy();
expect(output).toContain('"forked": false');
// The destination loss is surfaced, not silent: the error names a
// `.fork-backup-` directory and how to restore it.
expect(output).toMatch(/\.fork-backup-/);
expect(output).toMatch(/preserved at/i);
expect(output).toMatch(/could not restore/i);
// The original content really is still on disk in the backup dir the error
// points at (recovery is genuinely possible).
const backupDir = fs
.readdirSync(path.join(tempDir, 'openspec', 'schemas'))
.find((entry) => entry.includes('.fork-backup-'));
expect(backupDir).toBeTruthy();
const rescued = fs.readFileSync(
path.join(tempDir, 'openspec', 'schemas', backupDir!, 'schema.yaml'),
'utf-8'
);
expect(rescued).toBe('name: keep-me\nversion: 3\n');
const output = consoleLogSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(process.exitCode).toBeTruthy();
expect(output).toContain('"forked": false');
// The destination loss is surfaced, not silent: the error names a
// `.fork-backup-` directory and how to restore it.
expect(output).toMatch(/\.fork-backup-/);
expect(output).toMatch(/preserved at/i);
expect(output).toMatch(/could not restore/i);
// The original content really is still on disk in the backup dir the error
// points at (recovery is genuinely possible).
const backupDir = fs
.readdirSync(path.join(tempDir, 'openspec', 'schemas'))
.find((entry) => entry.includes('.fork-backup-'));
expect(backupDir).toBeTruthy();
expect(output).toContain(backupDir!);
const rescued = fs.readFileSync(
path.join(tempDir, 'openspec', 'schemas', backupDir!, 'schema.yaml'),
'utf-8'
);
expect(rescued).toBe('name: keep-me\nversion: 3\n');
🧰 Tools
🪛 ast-grep (0.45.1)

[warning] 436-439: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.readFileSync(
path.join(tempDir, 'openspec', 'schemas', backupDir!, 'schema.yaml'),
'utf-8'
)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/commands/schema-fork-fidelity.test.ts` around lines 422 - 441, Update
the test around the backup directory lookup to assert that output contains the
actual backupDir value after it is discovered. Keep the existing generic
backup-path assertions and recovery-content validation unchanged.

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The restore reporting and temp-directory filtering are fixed, but the destructive swap still trusts destination state captured before staging. On exact head a8fd05a, I changed destination schema.yaml during copy; --force completed and deleted the newer content. Please fingerprint the authorized destination, revalidate before moving it aside and before deleting its backup, abort while preserving concurrent changes, and add a regression for this race.

…dits

A concurrent process could edit an existing fork destination between the moment
--force authorized the overwrite and the moment the destructive swap ran, and
those edits were silently destroyed (reproduced by alfred: mutate destination
schema.yaml during copy; --force completed and deleted the newer content).

Now, when overwriting an existing destination, the fork:
- fingerprints the authorized destination (SHA-256 over every file's relative
  path and bytes) BEFORE staging;
- re-fingerprints and compares immediately before moving the destination aside;
  on mismatch it ABORTS without touching the destination, preserving the
  concurrent changes and telling the user to re-run;
- re-fingerprints the backup before discarding it on the success path; if it
  changed during the install window it is kept, not deleted, and its location is
  surfaced.

All prior guarantees remain: self-fork rejection, stage-then-swap, backup/restore
on failed install with the backup path surfaced, and the temp-dir discovery
filter.

Adds regressions: a destination edited concurrently during staging aborts the
fork and preserves the edit; a backup modified during the install window is kept
and its location surfaced.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

@alfred-openspec Fixed the concurrent-edit race you reproduced (commit c59d025).

Root cause. The destructive swap trusted destination state captured before staging. A process editing the destination between the --force authorization and the actual move/delete had its edits silently overwritten.

Fix (src/commands/schema.ts). Added fingerprintDir(dir) — a SHA-256 over every file's relative path AND its bytes (walked in sorted order), so it detects any content/size/structure change, not just existence. Then:

  • Capture: right after confirming the existing destination and the --force authorization, before staging, authorizedDestinationFingerprint = fingerprintDir(destinationDir).
  • Revalidate before the destructive move: immediately before renameSync(destinationDir, backupDir), re-fingerprint the destination. If it differs (or the dir vanished), abort — clean up staging, do not touch the destination, and throw Schema '<name>' ... changed on disk while the fork was being prepared. Aborted to preserve those concurrent changes; nothing was overwritten.
  • Revalidate before deleting the backup: on the success path, before rmSync(backupDir), re-fingerprint the backup against the captured value. On mismatch the backup is kept (not deleted) and its location surfaced via a warning.

All prior guarantees remain intact: self-fork rejection, stage-then-swap, backup/restore on failed install with the backup path surfaced, and the .fork-staging-/.fork-backup- discovery filter.

New regressions in test/commands/schema-fork-fidelity.test.ts (via the passthrough node:fs mock):

  • aborts and preserves a destination edited concurrently during staging — the mock writes new content to the destination schema.yaml during the staging copy; asserts the fork aborts with a "changed on disk"/aborted error, the destination still holds the concurrent content (not the fork's copy), and no staging/backup leftovers remain.
  • keeps the backup when it is modified during the install window — the mock writes into the backup dir right after the move-aside; asserts the fork succeeds, the changed backup is NOT deleted, and its .fork-backup- location is surfaced with the modified content intact.

Full suite: 3963 passed; only the 2 pre-existing unrelated failures remain (config-profile.test.ts:389, artifact-workflow.test.ts:1226). Rebased onto the latest main merge before pushing.

Comment thread src/commands/schema.ts Fixed
clay-good and others added 2 commits August 11, 2026 17:53
…-system-race)

fingerprintDir called fs.lstatSync then fs.readFileSync on the same path,
which CodeQL flags as a file-system race (the file may change between the
check and the read). Use the Dirent type already returned by readdirSync
({ withFileTypes: true }) instead of a separate lstat, and read files
directly, deriving the size from the bytes read. Behavior is unchanged
(13/13 fork-fidelity tests, incl. the concurrent-edit race regressions,
still pass); one fewer syscall per entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

Fixed the CodeQL js/file-system-race alert at schema.ts:352: fingerprintDir no longer does lstatSync-then-readFileSync on the same path. It now uses the Dirent type from readdirSync({ withFileTypes: true }) and reads files directly (size derived from the bytes read) — no check/use gap, one fewer syscall per entry. Behavior unchanged: 13/13 fork-fidelity tests (including the concurrent-edit race regressions) still pass; full suite 3964 pass / 2 known pre-existing failures.

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The destination fingerprinting fixes the reported race, but the staged copy still relies on source validation performed before copying. On ce9039e I changed source schema.yaml to a structurally invalid schema inside copyFileSync; --force succeeded, installed the invalid fork, and deleted the valid destination. Please validate the completed staged schema after the Document edit and before any destination displacement, with a regression preserving the destination when the source changes during staging.

…tive step

The up-front parseSchema only checks the SOURCE, but copyDirRecursive reads
source files that can change mid-copy, so the staged result can be invalid even
though the source was valid at the pre-check (reproduced by alfred: mutate source
schema.yaml to invalid inside copyFileSync; --force installed the invalid fork
and deleted the valid destination).

Now, after copying and the Document-API name edit, the fork validates the
COMPLETED staged schema.yaml (the exact bytes about to be installed) with
parseSchema BEFORE any destination displacement. On failure it aborts, cleans up
staging, and rethrows a clear error ("the staged fork of '<source>' is not a
valid schema ...; aborted, '<dest>' was not modified") chaining the parse error.
The up-front source parseSchema stays as a fail-fast; this is the authoritative
gate. Order before the swap: validate staged -> fingerprint-revalidate dest ->
rename dest->backup -> rename staging->dest -> revalidate+rm backup.

Adds a regression: a source that becomes structurally invalid during staging
aborts the fork and leaves the valid destination byte-identical, no leftovers.

Co-Authored-By: JinzeLin <linjinze999@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@clay-good

Copy link
Copy Markdown
Collaborator Author

@alfred-openspec Fixed the mid-copy source-invalidation race (commit 2d10a1c).

Root cause. The up-front parseSchema validates the SOURCE, but copyDirRecursive then reads source files that can change during the copy — so the staged fork can be invalid even though the source was valid at the pre-check. The old code trusted the pre-check and would install the invalid staged fork, deleting a valid destination.

Fix (src/commands/schema.ts, fork action). Added an authoritative post-stage gate: after copyDirRecursive(...) AND the doc.set('name', ...) write to the staged schema.yaml, and BEFORE any destination displacement, it re-runs parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8')) — validating the exact bytes about to be installed. On failure it aborts, the outer catch cleans up staging, and it rethrows:

The staged fork of '' is not a valid schema (the source may have changed during copy); aborted, '' was not modified.

with the parse error chained as cause. The up-front source parseSchema is kept as a fail-fast. Order before the swap is now: (a) validate staged schema, (b) fingerprint-revalidate destination unchanged, (c) rename dest->backup, (d) rename staging->dest, (e) revalidate + rm backup. Both guards (staged-invalid and destination-changed) abort before any destructive step. The CodeQL-safe Dirent-based fingerprintDir is untouched.

New regression in test/commands/schema-fork-fidelity.test.tsaborts and preserves the destination when the source becomes invalid during staging: the passthrough node:fs mock overwrites the staged schema.yaml with structurally-invalid (but YAML-valid) content right after it is copied into staging; asserts the fork aborts with the "not a valid schema"/aborted error, the pre-existing valid destination is byte-identical, and no staging/backup leftovers remain.

Full suite: 3965 passed; only the 2 pre-existing unrelated failures remain (config-profile.test.ts:389, artifact-workflow.test.ts:1226).

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completed staging tree is now validated after the Document edit and before any destination move, so a source changed mid-copy cannot replace a valid destination. Exact-head build, lint, 63 focused recovery/discovery tests, and the full Linux/macOS/Windows/security matrix pass. Approved.

@clay-good
clay-good added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 8127c7b Aug 11, 2026
19 checks passed
@clay-good
clay-good deleted the claude/pr-1130-merge-ready-5bd4ef branch August 11, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants