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

@@ -1,78 +0,0 @@
/**
* Tests for atomicWriteFileSync helper (issue #1915)
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const CORE_PATH = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs');
describe('atomicWriteFileSync', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir();
});
afterEach(() => {
cleanup(tmpDir);
});
test('is exported from core.cjs', () => {
const core = require(CORE_PATH);
assert.strictEqual(typeof core.atomicWriteFileSync, 'function', 'atomicWriteFileSync must be exported');
});
test('writes correct content to the target file', () => {
const { atomicWriteFileSync } = require(CORE_PATH);
const filePath = path.join(tmpDir, 'test.md');
const content = '# Hello\nworld\n';
atomicWriteFileSync(filePath, content, 'utf-8');
const written = fs.readFileSync(filePath, 'utf-8');
assert.strictEqual(written, content, 'written content must match');
});
test('does not leave .tmp.* files after successful write', () => {
const { atomicWriteFileSync } = require(CORE_PATH);
const filePath = path.join(tmpDir, 'STATE.md');
atomicWriteFileSync(filePath, '# State\n', 'utf-8');
const entries = fs.readdirSync(tmpDir);
const tmpFiles = entries.filter(e => e.includes('.tmp.'));
assert.deepStrictEqual(tmpFiles, [], 'no .tmp.* files should remain after write');
});
test('overwrites an existing file with new content', () => {
const { atomicWriteFileSync } = require(CORE_PATH);
const filePath = path.join(tmpDir, 'config.json');
atomicWriteFileSync(filePath, '{"first":true}', 'utf-8');
atomicWriteFileSync(filePath, '{"second":true}', 'utf-8');
const written = fs.readFileSync(filePath, 'utf-8');
assert.strictEqual(written, '{"second":true}', 'second write must replace first');
});
test('cleans up stale tmp file if present before write', () => {
const { atomicWriteFileSync } = require(CORE_PATH);
const filePath = path.join(tmpDir, 'ROADMAP.md');
// Place a stale tmp file matching the pattern used by atomicWriteFileSync
const staleTmp = filePath + '.tmp.' + process.pid;
fs.writeFileSync(staleTmp, 'stale content', 'utf-8');
atomicWriteFileSync(filePath, '# Roadmap\n', 'utf-8');
const entries = fs.readdirSync(tmpDir);
const tmpFiles = entries.filter(e => e.includes('.tmp.'));
assert.deepStrictEqual(tmpFiles, [], 'stale .tmp.* file must be gone after write');
const written = fs.readFileSync(filePath, 'utf-8');
assert.strictEqual(written, '# Roadmap\n', 'target file must have correct content');
});
});

View File

@@ -26,9 +26,11 @@ const { promisify } = require('util');
const { performance } = require('perf_hooks');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const {
normalizeMd,
} = require('../get-shit-done/bin/lib/core.cjs');
const { normalizeContent } = require('../get-shit-done/bin/lib/shell-command-projection.cjs');
// normalizeMd was removed from core.cjs (Phase 4 — issue #3468); the same algorithm now
// lives in the shell-command-projection seam. Wrap normalizeContent so existing
// behavioral / snapshot / perf assertions stay point-of-truth.
const normalizeMd = (input) => normalizeContent('test.md', input).content;
const execAsync = promisify(exec);

View File

@@ -24,9 +24,7 @@ const {
generateSlugInternal,
normalizePhaseName,
reapStaleTempFiles,
normalizeMd,
comparePhaseNum,
safeReadFile,
pathExistsInternal,
getMilestoneInfo,
getMilestonePhaseFilter,
@@ -588,30 +586,6 @@ describe('generateSlugInternal', () => {
// multi-level decimal, case-insensitive, directory-slug, and full sort order).
// Removed duplicates here to keep a single authoritative test location.
// ─── safeReadFile ──────────────────────────────────────────────────────────────
describe('safeReadFile', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('reads existing file', () => {
const filePath = path.join(tmpDir, 'test.txt');
fs.writeFileSync(filePath, 'hello world');
assert.strictEqual(safeReadFile(filePath), 'hello world');
});
test('returns null for missing file', () => {
assert.strictEqual(safeReadFile('/nonexistent/path/file.txt'), null);
});
});
// ─── pathExistsInternal ────────────────────────────────────────────────────────
describe('pathExistsInternal', () => {
@@ -1213,115 +1187,6 @@ describe('getMilestonePhaseFilter', () => {
});
});
// ─── normalizeMd ─────────────────────────────────────────────────────────────
describe('normalizeMd', () => {
test('returns null/undefined/empty unchanged', () => {
assert.strictEqual(normalizeMd(null), null);
assert.strictEqual(normalizeMd(undefined), undefined);
assert.strictEqual(normalizeMd(''), '');
});
test('MD022: adds blank lines around headings', () => {
const input = 'Some text\n## Heading\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('\n\n## Heading\n\n'), 'heading should have blank lines around it');
});
test('MD032: adds blank line before list after non-list content', () => {
const input = 'Some text\n- item 1\n- item 2\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n- item 1'), 'list should have blank line before it');
});
test('MD032: adds blank line after list before non-list content', () => {
const input = '- item 1\n- item 2\nSome text\n';
const result = normalizeMd(input);
assert.ok(result.includes('- item 2\n\nSome text'), 'list should have blank line after it');
});
test('MD032: does not add extra blank lines between list items', () => {
const input = '- item 1\n- item 2\n- item 3\n';
const result = normalizeMd(input);
assert.ok(result.includes('- item 1\n- item 2\n- item 3'), 'consecutive list items should not get blank lines');
});
test('MD031: adds blank lines around fenced code blocks', () => {
const input = 'Some text\n```js\ncode\n```\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
});
test('MD012: collapses 3+ consecutive blank lines to 2', () => {
const input = 'Line 1\n\n\n\n\nLine 2\n';
const result = normalizeMd(input);
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
assert.ok(result.includes('Line 1\n\nLine 2'), 'should collapse to double newline');
});
test('MD047: ensures file ends with single newline', () => {
const input = 'Content';
const result = normalizeMd(input);
assert.ok(result.endsWith('\n'), 'should end with newline');
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
});
test('MD047: trims trailing multiple newlines', () => {
const input = 'Content\n\n\n';
const result = normalizeMd(input);
assert.ok(result.endsWith('Content\n'), 'should end with single newline after content');
});
test('preserves frontmatter delimiters', () => {
const input = '---\nkey: value\n---\n\n# Heading\n\nContent\n';
const result = normalizeMd(input);
assert.ok(result.startsWith('---\n'), 'should preserve opening frontmatter');
assert.ok(result.includes('---\n\n# Heading'), 'should preserve frontmatter closing');
});
test('handles CRLF line endings', () => {
const input = 'Some text\r\n## Heading\r\nMore text\r\n';
const result = normalizeMd(input);
assert.ok(!result.includes('\r'), 'should normalize to LF');
assert.ok(result.includes('\n\n## Heading\n\n'), 'should add blank lines around heading');
});
test('handles ordered lists', () => {
const input = 'Some text\n1. First\n2. Second\nMore text\n';
const result = normalizeMd(input);
assert.ok(result.includes('Some text\n\n1. First'), 'ordered list should have blank line before');
});
test('does not add blank line between table and list', () => {
const input = '| Col |\n|-----|\n| val |\n- item\n';
const result = normalizeMd(input);
// Table rows start with |, should not add extra blank before list after table
assert.ok(result.includes('| val |\n\n- item'), 'list after table should have blank line');
});
test('complex real-world STATE.md-like content', () => {
const input = [
'# Project State',
'## Current Position',
'Phase: 5 of 10',
'Status: Executing',
'## Decisions',
'- Decision 1',
'- Decision 2',
'## Blockers',
'None',
].join('\n');
const result = normalizeMd(input);
// Every heading should have blank lines around it
assert.ok(result.includes('\n\n## Current Position\n\n'), 'section heading needs blank lines');
assert.ok(result.includes('\n\n## Decisions\n\n'), 'decisions heading needs blank lines');
assert.ok(result.includes('\n\n## Blockers\n\n'), 'blockers heading needs blank lines');
// List should have blank line before it
assert.ok(result.includes('\n\n- Decision 1'), 'list needs blank line before');
});
});
// ─── Stale hook filter regression (#1200) ─────────────────────────────────────
describe('stale hook filter', () => {