refactor(shell-projection): remove deprecated wrappers + finalize ADRs (Phase 4, #3468) (#3484)

* refactor(shell-projection): remove deprecated wrappers + finalize ADRs (Phase 4, #3468)

Final phase of the shell-command-projection expansion. Removes the legacy
core.cjs wrappers (`atomicWriteFileSync`, `safeReadFile`, `normalizeMd`)
now that every call site lives behind the seam, plus three Phase-3
stragglers (`graphify.cjs`, `template.cjs`, dead import in
`profile-pipeline.cjs`).

Documentation:
- ADR-0009: addendum noting Phase 1–4 scope expansion (subprocess +
  file I/O ownership), supersession of "does not execute" constraint,
  and resolution of open Q4.
- ADR-0010: status changed to Superseded by ADR-0009 with explanation.
- CONTEXT.md "Shell Command Projection Module" entry already current
  from Phase 1 — no edit needed.

Tests:
- `tests/atomic-write.test.cjs` deleted — wrapper it tested is gone;
  `atomic-write-coverage.test.cjs` (Phase 3) covers platformWriteSync.
- `tests/core.test.cjs::safeReadFile` + `::normalizeMd` describes
  deleted — wrappers are gone.
- `tests/concurrency-safety.test.cjs` normalizeMd suite (behavioral /
  perf / snapshot) repointed via 2-line shim at the seam's
  `normalizeContent` — full regression coverage preserved.

Test result: 9059/9041/18 — exact pre-Phase-4 baseline. All 18
failures are pre-existing path-with-spaces local-env issues.

Closes #3468

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(shell-projection): migrate remaining raw fs.writeFileSync sites (Phase 4, #3468)

Sweeps the 7 raw fs.writeFileSync call sites that bypassed the seam through Phase 3,
folding them into platformWriteSync. Net -14 lines: deletes the local writeFileAtomicSync
helper in installer-migrations.cjs and collapses surface.cjs's manual tmp+rename into a
single seam call.

Sites migrated:
- drift.cjs (1) — frontmatter write
- learnings.cjs (1) — learning record JSON write
- install-profiles.cjs (1) — profile marker write (collapsed redundant mkdir)
- gsd2-import.cjs (1) — imported file write (collapsed redundant mkdir)
- surface.cjs (1) — surface state write (replaced manual tmp+rename block)
- installer-migrations.cjs (3) — journal init/finalize + rewrite-json action;
  deleted private writeFileAtomicSync helper and its three call sites

Two sites intentionally retained outside the seam:
- planning-workspace.cjs:241 — workspace lock (wx-flag atomic-create; previously excluded by Phase 3)
- installer-migrations.cjs:220 — install migration lock (fd write into wx-opened handle)
- writeInstallState (installer-migrations.cjs) — strict atomic contract for install state;
  the seam's fallback-to-direct-write on rename failure would silently violate the
  invariant that install state must never be left half-written. Inline tmp+rename with
  rethrow keeps the original guarantee.

Tests: 9059 / 9041 / 18 — exactly the pre-Phase-4 baseline; 18 failures are the
pre-existing path-with-spaces local-env issues, identical files as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(installer-migrations): use strict atomic write for rollback install-state restore

The rollback path was restoring INSTALL_STATE via platformWriteSync, which falls
back to a direct write on rename failure and would silently violate the
half-written invariant that the install-state contract guarantees elsewhere.

Extracts the strict tmp+rename logic from writeInstallState into a shared
atomicWriteInstallState(configDir, content) helper and routes both
writeInstallState and rollbackAppliedMigrationResult through it. Preserves the
existing null-handling (rmSync when previousInstallStateBytes === null) and
existing failure-collection (failures.push on caught errors).

Byte-faithful restore: previousInstallStateBytes is written as-is (no JSON
parse round-trip), preserving the exact prior file contents on restore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-13 20:46:02 -04:00
committed by GitHub
parent 6ea25ec8c7
commit 1e091d2bcb
16 changed files with 77 additions and 413 deletions

View File

@@ -278,14 +278,6 @@ function error(message, reason = ERROR_REASON.UNKNOWN) {
// ─── File & Config utilities ──────────────────────────────────────────────────
function safeReadFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
/**
* Canonical config defaults. Single source of truth — imported by config.cjs and verify.cjs.
*/
@@ -610,118 +602,6 @@ function isGitIgnored(cwd, targetPath) {
return ignored;
}
// ─── Markdown normalization ─────────────────────────────────────────────────
/**
* Normalize markdown to fix common markdownlint violations.
* Applied at write points so GSD-generated .planning/ files are IDE-friendly.
*
* Rules enforced:
* MD022 — Blank lines around headings
* MD031 — Blank lines around fenced code blocks
* MD032 — Blank lines around lists
* MD012 — No multiple consecutive blank lines (collapsed to 2 max)
* MD047 — Files end with a single newline
*/
function normalizeMd(content) {
if (!content || typeof content !== 'string') return content;
// Normalize line endings to LF for consistent processing
let text = content.replace(/\r\n/g, '\n');
const lines = text.split('\n');
const result = [];
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
const fenceRegex = /^```/;
const insideFence = new Array(lines.length);
let fenceOpen = false;
for (let i = 0; i < lines.length; i++) {
if (fenceRegex.test(lines[i].trimEnd())) {
if (fenceOpen) {
// This is a closing fence — mark as NOT inside (it's the boundary)
insideFence[i] = false;
fenceOpen = false;
} else {
// This is an opening fence
insideFence[i] = false;
fenceOpen = true;
}
} else {
insideFence[i] = fenceOpen;
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const prev = i > 0 ? lines[i - 1] : '';
const prevTrimmed = prev.trimEnd();
const trimmed = line.trimEnd();
const isFenceLine = fenceRegex.test(trimmed);
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
result.push('');
}
// MD031: Blank line before fenced code blocks (opening fences only)
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
// Only add blank before opening fences (not closing ones)
if (i === 0 || !insideFence[i - 1]) {
result.push('');
}
}
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
prevTrimmed !== '---') {
result.push('');
}
result.push(line);
// MD022: Blank line after headings
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '') {
result.push('');
}
}
// MD031: Blank line after closing fenced code blocks
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '') {
result.push('');
}
}
// MD032: Blank line after last list item in a block
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
const next = lines[i + 1];
if (next !== undefined && next.trimEnd() !== '' &&
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
!/^\s/.test(next)) {
// Only add blank line if next line is not a continuation/indented line
result.push('');
}
}
}
text = result.join('\n');
// MD012: Collapse 3+ consecutive blank lines to 2
text = text.replace(/\n{3,}/g, '\n\n');
// MD047: Ensure file ends with exactly one newline
text = text.replace(/\n*$/, '\n');
return text;
}
// Default timeout for worktree-related git subprocess calls (matches worktree-safety.cjs).
// Prevents `git worktree list --porcelain` and similar calls from blocking the parent
// ─── Common path helpers ──────────────────────────────────────────────────────
/**
@@ -1884,38 +1764,6 @@ function readSubdirectories(dirPath, sort = false) {
}
}
// ─── Atomic file writes ───────────────────────────────────────────────────────
/**
* Write a file atomically using write-to-temp-then-rename.
*
* On POSIX systems, `fs.renameSync` is atomic when the source and destination
* are on the same filesystem. This prevents a process killed mid-write from
* leaving a truncated file that is unparseable on next read.
*
* The temp file is placed alongside the target so it is guaranteed to be on
* the same filesystem (required for rename atomicity). The PID is embedded in
* the temp file name so concurrent writers use distinct paths.
*
* If `renameSync` fails (e.g. cross-device move), the function falls back to a
* direct `writeFileSync` so callers always get a best-effort write.
*
* @param {string} filePath Absolute path to write.
* @param {string|Buffer} content File content.
* @param {string} [encoding='utf-8'] Encoding passed to writeFileSync.
*/
function atomicWriteFileSync(filePath, content, encoding = 'utf-8') {
const tmpPath = filePath + '.tmp.' + process.pid;
try {
fs.writeFileSync(tmpPath, content, encoding);
fs.renameSync(tmpPath, filePath);
} catch (renameErr) {
// Clean up the temp file if rename failed, then fall back to direct write.
try { fs.unlinkSync(tmpPath); } catch { /* already gone or never created */ }
fs.writeFileSync(filePath, content, encoding);
}
}
/**
* Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
* @param {Date} date
@@ -1948,10 +1796,8 @@ module.exports = {
ERROR_REASON,
setJsonErrorMode,
getJsonErrorMode,
safeReadFile,
loadConfig,
isGitIgnored,
normalizeMd,
escapeRegex,
normalizePhaseName,
comparePhaseNum,
@@ -1999,7 +1845,6 @@ module.exports = {
readSubdirectories,
getAgentsDir,
checkAgentsInstalled,
atomicWriteFileSync,
timeAgo,
pruneOrphanedWorktrees,
inspectWorktreeHealth,

View File

@@ -31,6 +31,7 @@
'use strict';
const fs = require('node:fs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -360,7 +361,7 @@ function writeMappedCommit(filePath, commitSha, isoDate) {
const { data, body } = parseFrontmatter(content);
data.last_mapped_commit = commitSha;
if (isoDate) data.last_mapped_at = isoDate;
fs.writeFileSync(filePath, serializeFrontmatter(data, body));
platformWriteSync(filePath, serializeFrontmatter(data, body));
}
// ─── Exports ─────────────────────────────────────────────────────────────────

View File

@@ -2,8 +2,7 @@
const fs = require('fs');
const path = require('path');
const { execTool, execGit } = require('./shell-command-projection.cjs');
const { atomicWriteFileSync } = require('./core.cjs');
const { execTool, execGit, platformWriteSync } = require('./shell-command-projection.cjs');
// ─── Config Gate ─────────────────────────────────────────────────────────────
@@ -523,7 +522,7 @@ function graphifyBuild(cwd) {
/**
* Write a diff snapshot after successful build (D-06).
* Reads graph.json from .planning/graphs/ and writes .last-build-snapshot.json
* using atomicWriteFileSync for crash safety.
* using platformWriteSync for crash safety.
*
* @param {string} cwd - Working directory
* @returns {object}
@@ -541,7 +540,7 @@ function writeSnapshot(cwd) {
};
const snapshotPath = path.join(cwd, '.planning', 'graphs', '.last-build-snapshot.json');
atomicWriteFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
platformWriteSync(snapshotPath, JSON.stringify(snapshot, null, 2));
return {
saved: true,
timestamp: snapshot.timestamp,

View File

@@ -19,6 +19,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { platformWriteSync } = require('./shell-command-projection.cjs');
// ─── Utilities ──────────────────────────────────────────────────────────────
@@ -434,8 +435,7 @@ function buildPreview(gsd2Data, artifacts) {
function writePlanningDir(artifacts, planningRoot) {
for (const [rel, content] of artifacts) {
const absPath = path.join(planningRoot, rel);
fs.mkdirSync(path.dirname(absPath), { recursive: true });
fs.writeFileSync(absPath, content, 'utf8');
platformWriteSync(absPath, content);
}
}

View File

@@ -41,6 +41,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { platformWriteSync } = require('./shell-command-projection.cjs');
// ---------------------------------------------------------------------------
// Profile definitions
@@ -404,8 +405,7 @@ function readActiveProfile(runtimeConfigDir) {
* @param {string} profileName e.g. 'core', 'standard', 'full'
*/
function writeActiveProfile(runtimeConfigDir, profileName) {
fs.mkdirSync(runtimeConfigDir, { recursive: true });
fs.writeFileSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n', 'utf8');
platformWriteSync(path.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n');
}
// ---------------------------------------------------------------------------

View File

@@ -7,6 +7,7 @@ const {
validateInstallerMigrationActions,
validateInstallerMigrationRecord,
} = require('./installer-migration-authoring.cjs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
const MANIFEST_NAME = 'gsd-file-manifest.json';
const INSTALL_STATE_NAME = 'gsd-install-state.json';
@@ -71,9 +72,24 @@ function readInstallState(configDir) {
};
}
function writeInstallState(configDir, state) {
// Strict atomic write for the install state: must never be left half-written.
// Bypasses the seam because platformWriteSync falls back to a direct write on
// rename failure, which would silently violate this invariant.
function atomicWriteInstallState(configDir, content) {
fs.mkdirSync(configDir, { recursive: true });
writeFileAtomicSync(path.join(configDir, INSTALL_STATE_NAME), JSON.stringify(state, null, 2) + '\n');
const filePath = path.join(configDir, INSTALL_STATE_NAME);
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(tmpPath, content, 'utf8');
fs.renameSync(tmpPath, filePath);
} catch (error) {
try { fs.rmSync(tmpPath, { force: true }); } catch { /* best-effort */ }
throw error;
}
}
function writeInstallState(configDir, state) {
atomicWriteInstallState(configDir, JSON.stringify(state, null, 2) + '\n');
return state;
}
@@ -263,20 +279,6 @@ function isStructurallyEmpty(value) {
return typeof value === 'object' && Object.keys(value).length === 0;
}
function writeFileAtomicSync(filePath, content) {
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
try {
fs.writeFileSync(tmpPath, content, 'utf8');
fs.renameSync(tmpPath, filePath);
} catch (error) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {
// best-effort cleanup only; preserve the original write failure
}
throw error;
}
}
function journalAction(action, status, extras = {}) {
const { value, ...safeAction } = action;
@@ -424,8 +426,7 @@ function rollbackAppliedMigrationResult({ configDir, journal, journalPath, rollb
if (previousInstallStateBytes === null) {
fs.rmSync(path.join(configDir, INSTALL_STATE_NAME), { force: true });
} else {
fs.mkdirSync(configDir, { recursive: true });
writeFileAtomicSync(path.join(configDir, INSTALL_STATE_NAME), previousInstallStateBytes);
atomicWriteInstallState(configDir, previousInstallStateBytes);
}
} catch (error) {
failures.push({ relPath: INSTALL_STATE_NAME, error: error.message });
@@ -476,12 +477,12 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
const rollback = [];
const installStatePath = path.join(configDir, INSTALL_STATE_NAME);
const previousInstallStateBytes = fs.existsSync(installStatePath)
? fs.readFileSync(installStatePath)
? fs.readFileSync(installStatePath, 'utf8')
: null;
try {
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
platformWriteSync(journalPath, JSON.stringify(journal, null, 2) + '\n');
for (const action of plan.actions) {
if (
@@ -517,7 +518,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
}));
} else {
writeFileAtomicSync(fullPath, JSON.stringify(action.value, null, 2) + '\n');
platformWriteSync(fullPath, JSON.stringify(action.value, null, 2) + '\n');
journal.actions.push(journalAction(action, 'rewritten', {
rollbackRelPath: path.posix.join(rollbackRootRelPath, normalized),
}));
@@ -542,7 +543,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
fs.rmSync(fullPath, { force: true });
}
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2) + '\n', 'utf8');
platformWriteSync(journalPath, JSON.stringify(journal, null, 2) + '\n');
const state = readInstallState(configDir);
const applied = appliedMigrationIds(state);

View File

@@ -18,6 +18,7 @@ const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { output, error: coreError } = require('./core.cjs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
// ─── Constants ───────────────────────────────────────────────────────────────
@@ -125,7 +126,7 @@ function learningsWrite(entry, opts) {
content_hash: hash,
};
fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2), 'utf-8');
platformWriteSync(path.join(dir, `${id}.json`), JSON.stringify(record, null, 2));
return { id, created: true, content_hash: hash };
}

View File

@@ -12,7 +12,7 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const readline = require('readline');
const { output, error, safeReadFile, reapStaleTempFiles } = require('./core.cjs');
const { output, error, reapStaleTempFiles } = require('./core.cjs');
// ─── Session I/O Helpers ──────────────────────────────────────────────────────

View File

@@ -20,6 +20,7 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { platformWriteSync } = require('./shell-command-projection.cjs');
const {
readActiveProfile,
@@ -74,17 +75,13 @@ function readSurface(runtimeConfigDir) {
}
/**
* Write the surface state atomically (write to tmp then rename).
* Write the surface state atomically via the platform seam (mkdir + tmp+rename).
*
* @param {string} runtimeConfigDir
* @param {SurfaceState} surfaceState
*/
function writeSurface(runtimeConfigDir, surfaceState) {
fs.mkdirSync(runtimeConfigDir, { recursive: true });
const finalPath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
const tmpPath = finalPath + '.tmp.' + process.pid;
fs.writeFileSync(tmpPath, JSON.stringify(surfaceState, null, 2) + '\n', 'utf8');
fs.renameSync(tmpPath, finalPath);
platformWriteSync(path.join(runtimeConfigDir, SURFACE_FILE_NAME), JSON.stringify(surfaceState, null, 2) + '\n');
}
// ---------------------------------------------------------------------------

View File

@@ -4,7 +4,8 @@
const fs = require('fs');
const path = require('path');
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, normalizeMd, toPosixPath, output, error } = require('./core.cjs');
const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosixPath, output, error } = require('./core.cjs');
const { platformWriteSync } = require('./shell-command-projection.cjs');
const { planningDir } = require('./planning-workspace.cjs');
const { reconstructFrontmatter } = require('./frontmatter.cjs');
@@ -219,7 +220,7 @@ function cmdTemplateFill(cwd, templateType, options, raw) {
return;
}
fs.writeFileSync(outPath, normalizeMd(fullContent), 'utf-8');
platformWriteSync(outPath, fullContent);
const relPath = toPosixPath(path.relative(cwd, outPath));
output({ created: true, path: relPath, template: templateType }, raw, relPath);
}