fix(schema): preserve YAML formatting when forking a schema - #1130
fix(schema): preserve YAML formatting when forking a schema#1130linjinze999 wants to merge 3 commits into
Conversation
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
📝 WalkthroughWalkthroughThe PR refactors how the ChangesYAML Document-Level Editing
Estimated code review effort🎯 2 (Simple) | ⏱️ ~8 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/commands/schema.ts (1)
629-631: ⚡ Quick winDon’t worry about silent corruption:
toString()already fails on YAML parse errors
yaml(eemeli/yaml)Document#toString()throws whendoc.errors.length > 0, sofs.writeFileSync(...)won’t run and the existing try/catch will prevent overwriting an invalidschema.yaml.- An explicit
doc.errorscheck is still a useful optional improvement for clearer error messaging and to avoid mutating viadoc.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
📒 Files selected for processing (1)
src/commands/schema.ts
alfred-openspec
left a comment
There was a problem hiding this comment.
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.
|
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 |
…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>
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:
xxx
yyy
will be:
Summary by CodeRabbit