feat(prefs): self-aligning template upgrades — sf keeps its own files synced
Companion to the earlier schema-versioning framework. Where that handles
data-shape evolution via forward migrations, this handles file-template
evolution via silent self-rewrite. The user shouldn't have to know:
- ensurePreferences() now stamps `last_synced_with_sf: <semver>` in the
frontmatter when seeding a new project's PREFERENCES.md, recording the
sf version that wrote the template.
- New module preferences-template-upgrade.ts:
- detectTemplateDrift(prefs) — pure check, returns
{ fromVersion, toVersion, needsUpgrade }.
- upgradePreferencesFileIfDrifted(path, prefs) — silently re-renders
the file's frontmatter when fromVersion ≠ toVersion. Body (anything
after the closing `---`) is preserved verbatim, so user notes stay.
- Wired into loadPreferencesFile() — every read self-aligns. No human
warnings, no opt-in flow; sf keeps its own house in order.
- last_synced_with_sf added to SFPreferences + KNOWN_PREFERENCE_KEYS so
it round-trips through validatePreferences without "unknown key"
warnings.
Failure modes are non-fatal: missing file, malformed frontmatter, or
read-only filesystem all leave the file alone and return the in-memory
prefs unchanged. SF_VERSION env var (set by loader.ts) is the source of
truth for "current sf"; "0.0.0" sentinel skips upgrade so atypical entry
points don't stamp incorrect values.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4912f6ea80
commit
2afe2ac6f1
4 changed files with 126 additions and 1 deletions
|
|
@ -351,8 +351,15 @@ export function ensurePreferences(basePath: string): boolean {
|
|||
// fall through to bare template
|
||||
}
|
||||
|
||||
// Stamp the sf version that wrote this template. Drift detection in
|
||||
// checkPreferencesDrift uses this to flag stale templates after major
|
||||
// sf updates. SF_VERSION is set by loader.ts; fall back to "0.0.0" if
|
||||
// the env var is missing (atypical entry point).
|
||||
const sfVersion = process.env.SF_VERSION || "0.0.0";
|
||||
|
||||
const template = `---
|
||||
version: 1
|
||||
last_synced_with_sf: ${yamlSafeString(sfVersion)}
|
||||
${verifySection}always_use_skills: []
|
||||
prefer_skills: []
|
||||
avoid_skills: []
|
||||
|
|
|
|||
104
src/resources/extensions/sf/preferences-template-upgrade.ts
Normal file
104
src/resources/extensions/sf/preferences-template-upgrade.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/**
|
||||
* Self-aligning template upgrades for SF config files.
|
||||
*
|
||||
* Goal: when sf evolves, projects' .sf/PREFERENCES.md (and friends) get
|
||||
* brought into alignment WITHOUT human intervention. The file's frontmatter
|
||||
* stamps `last_synced_with_sf: <semver>`; on every load, sf compares to its
|
||||
* own version and silently re-writes the frontmatter to match. The body
|
||||
* (Markdown after the frontmatter) is preserved verbatim — users can keep
|
||||
* notes there and sf won't clobber them.
|
||||
*
|
||||
* What this is NOT: a human-facing warning system. The end-user shouldn't
|
||||
* have to read drift advisories; sf keeps its own house in order.
|
||||
*/
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js";
|
||||
import type { SFPreferences } from "./preferences-types.js";
|
||||
|
||||
export interface TemplateDrift {
|
||||
/** SF version recorded in the file (or "0.0.0" if missing). */
|
||||
fromVersion: string;
|
||||
/** SF version currently running. */
|
||||
toVersion: string;
|
||||
/** True iff fromVersion ≠ toVersion AND both are known. */
|
||||
needsUpgrade: boolean;
|
||||
}
|
||||
|
||||
export function detectTemplateDrift(prefs: SFPreferences): TemplateDrift {
|
||||
const toVersion = process.env.SF_VERSION || "0.0.0";
|
||||
const fromVersion = prefs.last_synced_with_sf || "0.0.0";
|
||||
|
||||
// Skip when either side is unknown — better to no-op than re-write with
|
||||
// "0.0.0" stamp on a file that was hand-edited or generated outside the
|
||||
// normal sf load path.
|
||||
if (toVersion === "0.0.0" || fromVersion === "0.0.0") {
|
||||
return { fromVersion, toVersion, needsUpgrade: false };
|
||||
}
|
||||
|
||||
return {
|
||||
fromVersion,
|
||||
toVersion,
|
||||
needsUpgrade: fromVersion !== toVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* If the file at `path` is drifted from current sf, re-render its
|
||||
* frontmatter with a fresh `last_synced_with_sf` stamp (and any other
|
||||
* fields that may have been migrated by validatePreferences). Body is
|
||||
* preserved verbatim — only the leading `--- ... ---` block is rewritten.
|
||||
*
|
||||
* Returns the (possibly stamp-updated) prefs object so callers can use the
|
||||
* canonical view without re-reading the file.
|
||||
*
|
||||
* Failure modes are non-fatal: if the file disappeared, frontmatter can't
|
||||
* be located, or write fails, the original prefs are returned unchanged.
|
||||
* sf load paths must continue working even if disk-level upgrade is blocked.
|
||||
*/
|
||||
export function upgradePreferencesFileIfDrifted(
|
||||
path: string,
|
||||
prefs: SFPreferences,
|
||||
): SFPreferences {
|
||||
const drift = detectTemplateDrift(prefs);
|
||||
if (!drift.needsUpgrade) return prefs;
|
||||
|
||||
const upgraded: SFPreferences = {
|
||||
...prefs,
|
||||
last_synced_with_sf: drift.toVersion,
|
||||
};
|
||||
|
||||
if (!existsSync(path)) return upgraded;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(path, "utf-8");
|
||||
} catch {
|
||||
return upgraded;
|
||||
}
|
||||
|
||||
const startMarker = content.startsWith("---\r\n") ? "---\r\n" : "---\n";
|
||||
if (!content.startsWith(startMarker)) {
|
||||
// File doesn't have the canonical frontmatter shape — leave it alone.
|
||||
return upgraded;
|
||||
}
|
||||
const endIdx = content.indexOf("\n---", startMarker.length);
|
||||
if (endIdx === -1) return upgraded;
|
||||
|
||||
// Body is everything after the frontmatter's closing `---` line.
|
||||
const bodyStart = content.indexOf("\n", endIdx + 1);
|
||||
const body = bodyStart === -1 ? "" : content.slice(bodyStart);
|
||||
|
||||
const newFrontmatter = `---\n${serializePreferencesToFrontmatter(
|
||||
upgraded as unknown as Record<string, unknown>,
|
||||
)}---`;
|
||||
|
||||
try {
|
||||
writeFileSync(path, newFrontmatter + body, "utf-8");
|
||||
} catch {
|
||||
// Read-only filesystem, permission error, etc. — not fatal.
|
||||
return upgraded;
|
||||
}
|
||||
|
||||
return upgraded;
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ export const MODE_DEFAULTS: Record<WorkflowMode, Partial<SFPreferences>> = {
|
|||
/** All recognized top-level keys in SFPreferences. Used to detect typos / stale config. */
|
||||
export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
||||
"version",
|
||||
"last_synced_with_sf",
|
||||
"mode",
|
||||
"always_use_skills",
|
||||
"prefer_skills",
|
||||
|
|
@ -347,6 +348,13 @@ export interface CodebaseMapPreferences {
|
|||
|
||||
export interface SFPreferences {
|
||||
version?: number;
|
||||
/**
|
||||
* SF version (semver) that last wrote this file via the init template.
|
||||
* Stamped automatically by ensurePreferences(); used by drift detection
|
||||
* to suggest a template refresh when sf has evolved meaningfully since
|
||||
* the file was generated. Not user-set.
|
||||
*/
|
||||
last_synced_with_sf?: string;
|
||||
mode?: WorkflowMode;
|
||||
always_use_skills?: string[];
|
||||
prefer_skills?: string[];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type SkillResolution,
|
||||
type WorkflowMode,
|
||||
} from "./preferences-types.js";
|
||||
import { upgradePreferencesFileIfDrifted } from "./preferences-template-upgrade.js";
|
||||
import { validatePreferences } from "./preferences-validation.js";
|
||||
import type {
|
||||
PostUnitHookConfig,
|
||||
|
|
@ -261,12 +262,17 @@ function loadPreferencesFile(
|
|||
if (!preferences) return null;
|
||||
|
||||
const validation = validatePreferences(preferences);
|
||||
// Self-align: if the file's recorded sf version drifted from current,
|
||||
// silently re-render the frontmatter so subsequent reads match. No
|
||||
// human-facing warning — sf keeps its own files in sync. Body content
|
||||
// (anything after the frontmatter) is preserved verbatim.
|
||||
const aligned = upgradePreferencesFileIfDrifted(path, validation.preferences);
|
||||
const allWarnings = [...validation.warnings, ...validation.errors];
|
||||
|
||||
return {
|
||||
path,
|
||||
scope,
|
||||
preferences: validation.preferences,
|
||||
preferences: aligned,
|
||||
...(allWarnings.length > 0 ? { warnings: allWarnings } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue