What it reifies
@frenchexdev/requirements-sync owns the plan → preview → apply rhythm for any generated artefact in the family. A scaffolder produces a WritePlan; a wizard produces a SyncPlan for the spec it just authored; a refactor produces a plan for the @Satisfies edges that need updating. None of those producers writes to disk directly. They hand the plan to this package, which compares it against the current disk state, renders a coloured unified diff, and applies the changes the user confirms.
The package is the realisation of the req-spec-is-source-of-truth Requirement declared in Part 03. The Requirement's rationale cites Terraform and Kubernetes as the industry-standard precedents for the pattern: "compute a SyncPlan comparing the in-memory spec against on-disk files, render a colored diff, and apply only the changes the user confirms". The implementation lives here.
The package.json description: "Sync core — diff a Spec's proposed generated files against disk (SyncPlan), render colored unified diffs, apply in all / per-file / dry-run modes."
The public surface
src/index.ts is one line:
// @frenchexdev/requirements-sync — Tier-2: SyncPlan diff + apply over Spec → disk.
export * from './sync-core';// @frenchexdev/requirements-sync — Tier-2: SyncPlan diff + apply over Spec → disk.
export * from './sync-core';The module exports the plan vocabulary, the diff layer, and the apply layer:
import type { FileSystem } from '@frenchexdev/requirements-core/ports';
export interface ProposedFile {
path: string;
content: string;
}
export type EntryStatus = 'create' | 'unchanged' | 'modified';
export interface SyncPlanEntry {
path: string;
status: EntryStatus;
current?: string;
proposed: string;
diff?: string;
}
export interface SyncPlan {
entries: readonly SyncPlanEntry[];
hasChanges: boolean;
}
export declare function diffSpecToDisk(
fs: FileSystem,
proposed: readonly ProposedFile[],
): Promise<SyncPlan>;
export declare function renderColoredDiff(
plan: SyncPlan,
): string;
export interface ApplyOptions {
mode: 'all' | 'per-file' | 'dry-run';
confirm?: (entry: SyncPlanEntry) => Promise<boolean>;
}
export declare function applySyncPlan(
fs: FileSystem,
plan: SyncPlan,
options: ApplyOptions,
): Promise<{ written: string[]; skipped: string[] }>;import type { FileSystem } from '@frenchexdev/requirements-core/ports';
export interface ProposedFile {
path: string;
content: string;
}
export type EntryStatus = 'create' | 'unchanged' | 'modified';
export interface SyncPlanEntry {
path: string;
status: EntryStatus;
current?: string;
proposed: string;
diff?: string;
}
export interface SyncPlan {
entries: readonly SyncPlanEntry[];
hasChanges: boolean;
}
export declare function diffSpecToDisk(
fs: FileSystem,
proposed: readonly ProposedFile[],
): Promise<SyncPlan>;
export declare function renderColoredDiff(
plan: SyncPlan,
): string;
export interface ApplyOptions {
mode: 'all' | 'per-file' | 'dry-run';
confirm?: (entry: SyncPlanEntry) => Promise<boolean>;
}
export declare function applySyncPlan(
fs: FileSystem,
plan: SyncPlan,
options: ApplyOptions,
): Promise<{ written: string[]; skipped: string[] }>;Three layers, each pure except for the FileSystem port. diffSpecToDisk reads the current disk state through fs, computes the entry status for each proposed file, and uses the diff library (the only runtime dependency outside the kernel) to compute the unified diff string. renderColoredDiff is a pure formatter — diff + picocolors produce the terminal-coloured string. applySyncPlan is the only function that writes; its mode parameter controls the granularity of confirmation.
Where it sits
Tier 2, sibling of scaffolders and wizards. Depends on requirements-requirements, the kernel, diff ^7.0.0, and picocolors ^1.1.1. Does not depend on any Tier-1 analyzer — the sync package is content-agnostic, it only knows about { path, content } pairs. The lack of a Tier-1 dependency is what lets every Tier-2 consumer use the same sync primitives: the scaffolder hands a WritePlan (which is convertible to ProposedFile[]), the wizard hands a different ProposedFile[], the refactor hands a third — all three go through the same diff and apply.
Three things the package must not do:
- Compute the proposed content. That is the producer's job. The sync package only diffs and applies what it is given.
- Mutate the proposed content. A consumer who wants to transform the content (say, run prettier on it before writing) does that before calling
diffSpecToDisk; the package treats the proposed strings as opaque. - Embed prompt logic. The
confirmcallback inApplyOptionsis a port. The consumer wires@clack/prompts.confirm, or an automated() => Promise.resolve(true), or an IDE "approve this hunk?" button. The sync package does not know which.
A concrete call-site
The CLI's feature sync subcommand wires the package end-to-end:
import {
diffSpecToDisk,
renderColoredDiff,
applySyncPlan,
type SyncPlan,
type ProposedFile,
} from '@frenchexdev/requirements-sync';
import { generateScaffoldForSpec } from '@frenchexdev/requirements-scaffolders';
import { confirm } from '@clack/prompts';
import { fs } from '@frenchexdev/requirements-core/ports';
const writePlan = await generateScaffoldForSpec(/* … */);
const proposed: ProposedFile[] = writePlan.sources.map(s => ({
path: s.path,
content: s.content,
}));
const plan: SyncPlan = await diffSpecToDisk(fs, proposed);
if (!plan.hasChanges) {
console.log('No changes.');
process.exit(0);
}
console.log(renderColoredDiff(plan));
if (options.dryRun) {
process.exit(0);
}
await applySyncPlan(fs, plan, {
mode: options.reviewPerFile ? 'per-file' : 'all',
confirm: async entry => {
const answer = await confirm({ message: `Apply ${entry.path}?` });
return answer === true;
},
});import {
diffSpecToDisk,
renderColoredDiff,
applySyncPlan,
type SyncPlan,
type ProposedFile,
} from '@frenchexdev/requirements-sync';
import { generateScaffoldForSpec } from '@frenchexdev/requirements-scaffolders';
import { confirm } from '@clack/prompts';
import { fs } from '@frenchexdev/requirements-core/ports';
const writePlan = await generateScaffoldForSpec(/* … */);
const proposed: ProposedFile[] = writePlan.sources.map(s => ({
path: s.path,
content: s.content,
}));
const plan: SyncPlan = await diffSpecToDisk(fs, proposed);
if (!plan.hasChanges) {
console.log('No changes.');
process.exit(0);
}
console.log(renderColoredDiff(plan));
if (options.dryRun) {
process.exit(0);
}
await applySyncPlan(fs, plan, {
mode: options.reviewPerFile ? 'per-file' : 'all',
confirm: async entry => {
const answer = await confirm({ message: `Apply ${entry.path}?` });
return answer === true;
},
});The call sequence is the canonical terraform plan → terraform show → terraform apply shape, with diffSpecToDisk playing plan, renderColoredDiff playing show, and applySyncPlan playing apply. The user reading the output knows exactly what they are committing to; the --dry-run flag short-circuits the apply for inspection-only workflows.
Why it is its own package
Two arguments.
First, the diff/apply rhythm is the family's universal write-side. Every Tier-2 use case that produces files — scaffold, wizard, refactor, future codemods — goes through this package. Centralising the rhythm means the user sees the same coloured diff format whatever the producer; the same per-file confirmation gesture; the same dry-run semantics; the same exit-code contract. Centralising also means the rhythm has one set of tests, one set of bug fixes, one published version. Pre-split, the diff logic lived in requirements-lib/src/sync-core.ts and the apply logic was duplicated by each use case — every Tier-2 consumer wrote its own variant of "if --dry-run then print else write", with subtle off-by-one differences in colouring and prompt timing.
Second, the package is small enough to harden completely. The whole module is roughly 300 lines of code plus its tests. With per-file coverage thresholds at 98%+ (feedback_vitest_always_coverage.md) and npx requirements compliance --strict against its own Features as the dog-fooding gate, the maintainer can claim with confidence that "the apply step never writes a file the user did not approve". That claim is the package's product. If the claim ever weakens, the package's reason for existing weakens with it; isolation in its own package keeps the claim auditable.
The package is also the natural home for the audit log. A future capability — write an .audit/sync.log entry every time applySyncPlan writes a file — fits cleanly here, without touching the kernel or the analyzers. The audit hook is the req-audit-trail-hooks Requirement; its implementation will arrive as a new function in this package once the wider audit story across the family stabilises.
The next page covers the third Tier-2 package — requirements-wizards — which is the producer of the SyncPlans this package consumes for the feature new and requirement new flows.