refactor: consolidate STATE.md field helpers, fix command injection in isGitIgnored
Refactoring: - Extract stateReplaceFieldWithFallback() to state.cjs as single source of truth for the try-primary-then-fallback pattern that was duplicated inline across phase.cjs, milestone.cjs, and state.cjs - Replace all inline bold-only regex patterns in cmdPhaseComplete with shared stateReplaceField/stateExtractField helpers — now supports both **Bold:** and plain Field: STATE.md formats (fixes the same bug as #924) - Replace inline bold-only regex patterns in cmdMilestoneComplete with shared helpers - Replace inline bold-only regex for Completed Phases/Total Phases/Progress counters in cmdPhaseComplete with stateExtractField/stateReplaceField - Replace inline bold-only Total Phases regex in cmdPhaseRemove with shared helpers Security: - Fix command injection surface in isGitIgnored (core.cjs): replace execSync with string concatenation with execFileSync using array arguments — prevents shell interpretation of special characters in file paths Tests (7 new): - 5 tests for stateReplaceFieldWithFallback: primary field, fallback, neither, preference, and plain format - 1 regression test: phase complete with plain-format STATE.md fields - 1 regression test: milestone complete with plain-format STATE.md fields 854 tests pass (was 847). No behavioral regressions.
This commit is contained in:
@@ -476,6 +476,23 @@ describe('milestone complete command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('updates STATE.md with plain format fields', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\nStatus: In progress\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('milestone complete v1.0 --name Test', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(state.includes('v1.0 milestone complete'), 'plain Status field should be updated');
|
||||
});
|
||||
|
||||
test('handles empty phases directory', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
|
||||
@@ -1508,6 +1508,31 @@ describe('phase complete command', () => {
|
||||
assert.strictEqual(cells[1], 'v1.0', 'Milestone column should be preserved');
|
||||
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
|
||||
});
|
||||
|
||||
test('updates STATE.md with plain format fields (no bold)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n### Phase 1: Only\n**Goal:** Test\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\nPhase: 1 of 1 (Only)\nStatus: In progress\nPlan: 01-01\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(state.includes('Milestone complete'), 'plain Status field should be updated');
|
||||
assert.ok(state.includes('Not started'), 'plain Plan field should be updated');
|
||||
// Verify compound format preserved
|
||||
assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -506,7 +506,7 @@ describe('STATE.md frontmatter sync', () => {
|
||||
// stateExtractField and stateReplaceField helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const { stateExtractField, stateReplaceField } = require('../get-shit-done/bin/lib/state.cjs');
|
||||
const { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('../get-shit-done/bin/lib/state.cjs');
|
||||
|
||||
describe('stateExtractField and stateReplaceField helpers', () => {
|
||||
// stateExtractField tests
|
||||
@@ -585,6 +585,45 @@ describe('stateExtractField and stateReplaceField helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// stateReplaceFieldWithFallback — consolidated fallback helper
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stateReplaceFieldWithFallback', () => {
|
||||
test('replaces primary field when present', () => {
|
||||
const content = '# State\n\n**Status:** Old\n';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', null, 'New');
|
||||
assert.ok(result.includes('**Status:** New'));
|
||||
});
|
||||
|
||||
test('falls back to secondary field when primary not found', () => {
|
||||
const content = '# State\n\nLast activity: 2024-01-01\n';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Last Activity', 'Last activity', '2025-03-19');
|
||||
assert.ok(result.includes('Last activity: 2025-03-19'), 'should update fallback field');
|
||||
});
|
||||
|
||||
test('returns content unchanged when neither field matches', () => {
|
||||
const content = '# State\n\n**Phase:** 3\n';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', 'state', 'New');
|
||||
assert.strictEqual(result, content, 'content should be unchanged');
|
||||
});
|
||||
|
||||
test('prefers primary over fallback when both exist', () => {
|
||||
const content = '# State\n\n**Status:** Old\nStatus: Also old\n';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', 'Status', 'New');
|
||||
// Bold format is tried first by stateReplaceField
|
||||
assert.ok(result.includes('**Status:** New'), 'should replace bold (primary) format');
|
||||
});
|
||||
|
||||
test('works with plain format fields', () => {
|
||||
const content = '# State\n\nPhase: 1 of 3 (Foundation)\nStatus: In progress\nPlan: 01-01\n';
|
||||
let updated = stateReplaceFieldWithFallback(content, 'Status', null, 'Complete');
|
||||
assert.ok(updated.includes('Status: Complete'), 'should update plain Status');
|
||||
updated = stateReplaceFieldWithFallback(updated, 'Current Plan', 'Plan', 'Not started');
|
||||
assert.ok(updated.includes('Plan: Not started'), 'should fall back to Plan field');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// cmdStateLoad, cmdStateGet, cmdStatePatch, cmdStateUpdate CLI tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user