* 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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user