Skip to content

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

Closed
linjinze999 wants to merge 3 commits into
Fission-AI:mainfrom
linjinze999:feat/schema_fork_value_format
Closed

fix(schema): preserve YAML formatting when forking a schema#1130
linjinze999 wants to merge 3 commits into
Fission-AI:mainfrom
linjinze999:feat/schema_fork_value_format

Conversation

@linjinze999

@linjinze999 linjinze999 commented May 27, 2026

Copy link
Copy Markdown
Contributor

Use 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

Fix yaml:

  • A: |
    xxx
    yyy
    will be:
  • A: xxx yyy

Summary by CodeRabbit

  • Refactor
    • Enhanced the schema forking process with improved internal handling of schema name updates during schema operations.

Review Change Stack

Use 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
@linjinze999
linjinze999 requested a review from TabishB as a code owner May 27, 2026 07:05
@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR refactors how the schema fork command updates a schema's name in its YAML configuration file. It switches from parsing the YAML into a typed schema model, mutating the model, and re-stringifying it—to parsing the YAML as a document, using targeted field mutation, and converting back to a string.

Changes

YAML Document-Level Editing

Layer / File(s) Summary
YAML document-level field mutation
src/commands/schema.ts
Import parseDocument from the YAML library and replace schema-model–based name field updates in schema fork with document-level doc.set('name', ...) and doc.toString() to enable targeted field edits.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~8 minutes

Poem

🐰 A fork once parsed the whole schema through,
But now we set just one field—clean and true!
Document-level edits, precise and lean,
The name updates faster, the structure stays keen.
Hop, hop, refactor!

🚥 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 'fix(schema): preserve YAML formatting when forking a schema' directly and clearly describes the main change: using YAML's Document API to preserve formatting (block scalars, comments, key order) when forking schemas instead of round-tripping through parseSchema/stringifyYaml.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 and usage tips.

@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.

🧹 Nitpick comments (1)
src/commands/schema.ts (1)

629-631: ⚡ Quick win

Don’t worry about silent corruption: toString() already fails on YAML parse errors

  • yaml (eemeli/yaml) Document#toString() throws when doc.errors.length > 0, so fs.writeFileSync(...) won’t run and the existing try/catch will prevent overwriting an invalid schema.yaml.
  • An explicit doc.errors check is still a useful optional improvement for clearer error messaging and to avoid mutating via doc.set.
Proposed fix
 const doc = parseDocument(schemaContent);
+if (doc.errors.length > 0) {
+  throw new Error(
+    `Failed to parse '${destSchemaPath}': ${doc.errors.map((e) => e.message).join('; ')}`
+  );
+}
 doc.set('name', destinationName);
 fs.writeFileSync(destSchemaPath, doc.toString());
🤖 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 `@src/commands/schema.ts` around lines 629 - 631, The code currently calls
parseDocument(schemaContent) then mutates the Document with doc.set('name',
destinationName) before writing, which can silently mutate an invalid Document;
update the flow in the block that uses parseDocument, Document#toString, and
fs.writeFileSync so you first check doc.errors (after parseDocument) and if any
errors exist throw or surface a clear error message, and only then call
doc.set('name', destinationName) and fs.writeFileSync(destSchemaPath,
doc.toString()); this ensures you don’t mutate an invalid Document and that
toString() won’t be relied on to detect parse problems.
🤖 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.

Nitpick comments:
In `@src/commands/schema.ts`:
- Around line 629-631: The code currently calls parseDocument(schemaContent)
then mutates the Document with doc.set('name', destinationName) before writing,
which can silently mutate an invalid Document; update the flow in the block that
uses parseDocument, Document#toString, and fs.writeFileSync so you first check
doc.errors (after parseDocument) and if any errors exist throw or surface a
clear error message, and only then call doc.set('name', destinationName) and
fs.writeFileSync(destSchemaPath, doc.toString()); this ensures you don’t mutate
an invalid Document and that toString() won’t be relied on to detect parse
problems.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e76ea6c6-73d8-48bc-bf75-41ddf0f74f7e

📥 Commits

Reviewing files that changed from the base of the PR and between e441287 and 2485f8d.

📒 Files selected for processing (1)
  • 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 Document API preserves formatting, but this change also removes the structural parseSchema validation from schema fork: I reproduced an invalid schema missing required artifact fields being serialized by doc.set/toString while the old path rejects it. Please keep schema validation before the document mutation and add a fork-level regression covering both formatting preservation and invalid-source rejection.

@clay-good

Copy link
Copy Markdown
Collaborator

Thanks for this fix, @linjinze999 — the Document-API approach is the right call. I've carried it forward in #1607, which keeps your change and additionally restores the parseSchema structural validation that the alfred-openspec review asked for (so an invalid source schema is still rejected), plus adds a fork-level regression test covering both formatting preservation and invalid-source rejection. Your authorship is preserved via co-author. Closing in favor of #1607.

@clay-good clay-good closed this Aug 7, 2026
timothybrush pushed a commit to timothybrush/OpenSpec that referenced this pull request Aug 12, 2026
…AI#1607)

* fix(schema): preserve YAML formatting when forking a schema

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 Fission-AI#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>

* harden(schema): clean up partial fork when validation fails

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>

* harden(schema): validate fork source up front; never mask fork errors

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>

* harden(schema): reject self-fork and stage fork before replacing destination

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>

* harden(schema): back up destination before installing fork so a failed 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>

* harden(schema): surface unrecoverable fork restore + hide fork temp dirs 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>

* harden(schema): fingerprint fork destination to abort on concurrent edits

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>

* fix(schema): avoid stat-then-read in fork fingerprint (CodeQL js/file-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>

* harden(schema): validate the completed staged fork before any destructive 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>

---------

Co-authored-by: JinzeLin <linjinze999@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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