* feat(shell-projection): add exec* dispatch and platform* file I/O seam (Phase 1, #3465) Extends shell-command-projection.cjs with two new sections: Subprocess dispatch: - execGit(args, opts) → { exitCode, stdout, stderr } - execNpm(args, opts) → same; owns shell:true on Windows (npm.cmd) - execTool(program, args, opts) → same; ENOENT → exitCode 127, no throw - probeTty(opts) → string | null; returns null on Windows and non-tty Platform file I/O: - normalizeContent(filePath, content) → { content, encoding }; pure function; .md → full normalizeMd pass; other → CRLF→LF + trailing newline - platformWriteSync(filePath, content, opts) → ensureDir + normalizeContent + atomic write - platformReadSync(filePath, opts) → null on ENOENT; throws when required:true - platformEnsureDir(dirPath) → mkdirSync recursive, idempotent Absorbs normalizeMd fence-tracking logic from core.cjs (no circular dep). Existing rendering exports untouched. core.cjs compat exports unchanged until Phase 4. Updates CONTEXT.md with Shell Command Projection Module canonical definition. 31 new behavioral tests; full suite green. Closes #3465 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(context): record shell-projection expansion session learnings * chore(changeset): add entry for shell-projection I/O seam (#3465) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(adr): add ADR-0010 skill-surface budget module and ADR-0011 review default reviewers (Phase 1, #3465) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(context): record phase 1 rebase and PR session learnings (#3465) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(shell-projection): drop substring match on stderr — assert typed shape only The lint-no-source-grep rule prohibits substring matching on stderr (or any test-output text). The exitCode === 127 assertion already proves the ENOENT path; the stderr content was implementation-detail of the OS. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pr3470): address CodeRabbit findings and minimal-core drift * docs(adr0010): align profile interface with phase-1 scope * docs(adr): index newly added 0010/0011 ADR drafts in README enh-3271 invariant requires every file in docs/adr/ to be linked from the README. Adds entries for the three ADR drafts committed earlier in this PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
10 KiB
JavaScript
264 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
|
|
const {
|
|
execGit,
|
|
execNpm,
|
|
execTool,
|
|
probeTty,
|
|
normalizeContent,
|
|
platformWriteSync,
|
|
platformReadSync,
|
|
platformEnsureDir,
|
|
} = require(path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'shell-command-projection.cjs'));
|
|
|
|
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
// ─── execGit ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('execGit', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempGitProject(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('returns { exitCode, stdout, stderr } shape', () => {
|
|
const result = execGit(['--version']);
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
|
|
});
|
|
|
|
test('exitCode 0 for successful command', () => {
|
|
const result = execGit(['--version']);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
});
|
|
|
|
test('stdout contains version string for --version', () => {
|
|
const result = execGit(['--version']);
|
|
assert.strictEqual(typeof result.stdout, 'string');
|
|
assert.ok(result.stdout.length > 0, 'stdout should not be empty for git --version');
|
|
});
|
|
|
|
test('exitCode non-zero for failing command — does not throw', () => {
|
|
const result = execGit(['status', '--porcelain'], { cwd: '/tmp/definitely-not-a-git-repo-8675309' });
|
|
assert.notStrictEqual(result.exitCode, 0);
|
|
});
|
|
|
|
test('respects cwd option', () => {
|
|
const result = execGit(['status', '--porcelain'], { cwd: tmpDir });
|
|
assert.strictEqual(result.exitCode, 0);
|
|
});
|
|
});
|
|
|
|
// ─── execNpm ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('execNpm', () => {
|
|
test('returns { exitCode, stdout, stderr } shape', () => {
|
|
const result = execNpm(['--version']);
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
|
|
});
|
|
|
|
test('exitCode 0 for npm --version', () => {
|
|
const result = execNpm(['--version']);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
});
|
|
|
|
test('stdout is non-empty for npm --version', () => {
|
|
const result = execNpm(['--version']);
|
|
assert.ok(result.stdout.trim().length > 0);
|
|
});
|
|
});
|
|
|
|
// ─── execTool ────────────────────────────────────────────────────────────────
|
|
|
|
describe('execTool', () => {
|
|
test('returns { exitCode, stdout, stderr } shape for known program', () => {
|
|
const result = execTool('node', ['--version']);
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'exitCode'), 'missing exitCode');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stdout'), 'missing stdout');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'stderr'), 'missing stderr');
|
|
});
|
|
|
|
test('exitCode 0 for node --version', () => {
|
|
const result = execTool('node', ['--version']);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
});
|
|
|
|
test('exitCode 127 and no throw when program does not exist', () => {
|
|
const result = execTool('definitely-not-a-real-program-8675309', []);
|
|
assert.strictEqual(result.exitCode, 127);
|
|
assert.strictEqual(result.stdout, '');
|
|
assert.strictEqual(typeof result.stderr, 'string');
|
|
});
|
|
});
|
|
|
|
// ─── probeTty ────────────────────────────────────────────────────────────────
|
|
|
|
describe('probeTty', () => {
|
|
test('returns string or null — never throws', () => {
|
|
const result = probeTty();
|
|
assert.ok(result === null || typeof result === 'string', `expected string|null, got ${typeof result}`);
|
|
});
|
|
|
|
test('returns null when platform is win32', () => {
|
|
const result = probeTty({ platform: 'win32' });
|
|
assert.strictEqual(result, null);
|
|
});
|
|
});
|
|
|
|
// ─── normalizeContent ────────────────────────────────────────────────────────
|
|
|
|
describe('normalizeContent', () => {
|
|
test('returns { content, encoding } shape', () => {
|
|
const result = normalizeContent('file.md', 'hello\n');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'content'), 'missing content');
|
|
assert.ok(Object.prototype.hasOwnProperty.call(result, 'encoding'), 'missing encoding');
|
|
});
|
|
|
|
test('normalizes CRLF to LF for .md files', () => {
|
|
const result = normalizeContent('file.md', 'line1\r\nline2\r\n');
|
|
assert.ok(!result.content.includes('\r\n'), 'CRLF should be normalized to LF');
|
|
});
|
|
|
|
test('normalizes CRLF to LF for non-.md files', () => {
|
|
const result = normalizeContent('file.json', '{"a":1}\r\n');
|
|
assert.ok(!result.content.includes('\r\n'), 'CRLF should be normalized to LF');
|
|
});
|
|
|
|
test('enforces single trailing newline for .md files', () => {
|
|
const result = normalizeContent('file.md', 'hello');
|
|
assert.ok(result.content.endsWith('\n'), 'should end with newline');
|
|
assert.ok(!result.content.endsWith('\n\n'), 'should not end with double newline');
|
|
});
|
|
|
|
test('enforces single trailing newline for non-.md files', () => {
|
|
const result = normalizeContent('file.txt', 'hello');
|
|
assert.ok(result.content.endsWith('\n'));
|
|
assert.ok(!result.content.endsWith('\n\n'));
|
|
});
|
|
|
|
test('applies full markdownlint normalization for .md files — blank line before heading', () => {
|
|
const input = [
|
|
'# Title',
|
|
'paragraph',
|
|
'## Section',
|
|
].join('\n');
|
|
const result = normalizeContent('file.md', input);
|
|
assert.ok(result.content.includes('\n\n## Section'), 'MD022: blank line before heading');
|
|
});
|
|
|
|
test('does NOT apply markdown structural rules to non-.md files', () => {
|
|
const input = 'paragraph\n## Not a heading in json\n';
|
|
const result = normalizeContent('file.json', input);
|
|
assert.strictEqual(result.content, input);
|
|
});
|
|
|
|
test('encoding defaults to utf-8', () => {
|
|
const result = normalizeContent('file.md', 'hello\n');
|
|
assert.strictEqual(result.encoding, 'utf-8');
|
|
});
|
|
});
|
|
|
|
// ─── platformWriteSync ───────────────────────────────────────────────────────
|
|
|
|
describe('platformWriteSync', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('written file exists and is a regular file', () => {
|
|
const filePath = path.join(tmpDir, 'output.md');
|
|
platformWriteSync(filePath, '# Hello\n');
|
|
assert.ok(fs.statSync(filePath).isFile());
|
|
});
|
|
|
|
test('written file has non-zero size', () => {
|
|
const filePath = path.join(tmpDir, 'output.md');
|
|
platformWriteSync(filePath, '# Hello\n');
|
|
assert.ok(fs.statSync(filePath).size > 0);
|
|
});
|
|
|
|
test('creates parent directory if absent', () => {
|
|
const filePath = path.join(tmpDir, 'nested', 'deep', 'output.md');
|
|
platformWriteSync(filePath, '# Hello\n');
|
|
assert.ok(fs.statSync(filePath).isFile());
|
|
});
|
|
|
|
test('mtime advances on re-write', (t) => {
|
|
const filePath = path.join(tmpDir, 'output.md');
|
|
platformWriteSync(filePath, '# First\n');
|
|
const mtimeBefore = fs.statSync(filePath).mtimeMs;
|
|
// Small delay to ensure mtime differs
|
|
const start = Date.now();
|
|
while (Date.now() - start < 10) { /* busy wait */ }
|
|
platformWriteSync(filePath, '# Second\n');
|
|
const mtimeAfter = fs.statSync(filePath).mtimeMs;
|
|
assert.ok(mtimeAfter >= mtimeBefore, 'mtime should advance or stay same on re-write');
|
|
});
|
|
|
|
test('no temp file left on disk after successful write', () => {
|
|
const filePath = path.join(tmpDir, 'output.md');
|
|
platformWriteSync(filePath, '# Hello\n');
|
|
const tmpFiles = fs.readdirSync(tmpDir).filter(f => f.includes('.tmp.'));
|
|
assert.strictEqual(tmpFiles.length, 0, 'no temp files should remain');
|
|
});
|
|
});
|
|
|
|
// ─── platformReadSync ────────────────────────────────────────────────────────
|
|
|
|
describe('platformReadSync', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('returns null for missing file when required is false (default)', () => {
|
|
const result = platformReadSync(path.join(tmpDir, 'nonexistent.md'));
|
|
assert.strictEqual(result, null);
|
|
});
|
|
|
|
test('throws for missing file when required is true', () => {
|
|
assert.throws(
|
|
() => platformReadSync(path.join(tmpDir, 'nonexistent.md'), { required: true }),
|
|
/ENOENT/,
|
|
);
|
|
});
|
|
|
|
test('returns string content for existing file', () => {
|
|
const filePath = path.join(tmpDir, 'existing.md');
|
|
fs.writeFileSync(filePath, '# Hello\n', 'utf-8');
|
|
const result = platformReadSync(filePath);
|
|
assert.strictEqual(typeof result, 'string');
|
|
assert.ok(result.length > 0);
|
|
});
|
|
});
|
|
|
|
// ─── platformEnsureDir ───────────────────────────────────────────────────────
|
|
|
|
describe('platformEnsureDir', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempDir(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('creates directory if absent', () => {
|
|
const dirPath = path.join(tmpDir, 'new', 'nested', 'dir');
|
|
platformEnsureDir(dirPath);
|
|
assert.ok(fs.statSync(dirPath).isDirectory());
|
|
});
|
|
|
|
test('no error when directory already exists — idempotent', () => {
|
|
const dirPath = path.join(tmpDir, 'existing');
|
|
fs.mkdirSync(dirPath);
|
|
assert.doesNotThrow(() => platformEnsureDir(dirPath));
|
|
});
|
|
});
|