* test(#4481): reproduce mid-sentence state field reads * fix(#4481): anchor bold STATE field reads to line start * docs(#4481): add changeset for #4510 * fix(#4481): align bold field readers with anchored writers
This commit is contained in:
5
.changeset/rapid-pumas-parade.md
Normal file
5
.changeset/rapid-pumas-parade.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4510
|
||||
---
|
||||
**STATE.md field reads now target declared field lines** — prose lookalikes are ignored while indented bold fields remain readable, keeping CLI output, sync diagnostics, and writers aligned.
|
||||
@@ -371,7 +371,7 @@ export function stateFieldContinuation(content: string, fieldName: string): stri
|
||||
// is deliberately absent: a `| Field | value |` row is bounded by its closing
|
||||
// pipe and cannot wrap.
|
||||
const match =
|
||||
new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i').exec(content) ??
|
||||
new RegExp(`^[ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'im').exec(content) ??
|
||||
new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im').exec(content);
|
||||
if (!match) return null;
|
||||
|
||||
@@ -400,8 +400,9 @@ export function stateFieldContinuation(content: string, fieldName: string): stri
|
||||
|
||||
export function stateExtractField(content: string, fieldName: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
// Bold inline format: **FieldName:** value
|
||||
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
|
||||
// Bold line-start format: **FieldName:** value. Leading same-line whitespace
|
||||
// matches the writer's established indented-field tolerance.
|
||||
const boldPattern = new RegExp(`^[ \\t]*\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'im');
|
||||
const boldMatch = content.match(boldPattern);
|
||||
if (boldMatch)
|
||||
return boldMatch[1].trim();
|
||||
|
||||
@@ -588,7 +588,7 @@ function cmdStateGet(cwd: string, section: string | undefined, raw: boolean): vo
|
||||
const fieldEscaped = escapeRegex(section);
|
||||
|
||||
// Check for **field:** value (bold format)
|
||||
const boldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
|
||||
const boldPattern = new RegExp(`^[ \\t]*\\*\\*${fieldEscaped}:\\*\\*[ \\t]*(.*)`, 'im');
|
||||
const boldMatch = content.match(boldPattern);
|
||||
if (boldMatch) {
|
||||
output({ [section]: boldMatch[1].trim() }, raw, boldMatch[1].trim());
|
||||
|
||||
@@ -1590,6 +1590,18 @@ describe('cmdStateGet (state get)', () => {
|
||||
assert.strictEqual(output['Status'], 'Active', 'should extract Status field value');
|
||||
});
|
||||
|
||||
test('bold field lookup ignores prose lookalikes and accepts indentation (#4481)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\nA note cites **Status:** stale prose.\n **Status:** Active\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state get Status', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output['Status'], 'Active');
|
||||
});
|
||||
|
||||
test('extracts markdown section content', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
@@ -3892,6 +3904,46 @@ Progress: [..........] 0%
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4481 — field reads agree with line-anchored field writes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#4481: stateExtractField ignores mid-sentence bold lookalikes', () => {
|
||||
const documentWithLookalikes = [
|
||||
'# Project State',
|
||||
'',
|
||||
'A note cites **Progress:** dashboards, **Total Plans in Phase:** aggregation, and **Last Activity:** retention.',
|
||||
'',
|
||||
' **Progress:** [█░░░░░░░░░] 10%',
|
||||
'\t**Total Plans in Phase:** 2',
|
||||
' **Last Activity:** 2026-01-01',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('returns the real line-start value for every sync field', () => {
|
||||
assert.strictEqual(stateDocument.stateExtractField(documentWithLookalikes, 'Progress'), '[█░░░░░░░░░] 10%');
|
||||
assert.strictEqual(stateDocument.stateExtractField(documentWithLookalikes, 'Total Plans in Phase'), '2');
|
||||
assert.strictEqual(stateDocument.stateExtractField(documentWithLookalikes, 'Last Activity'), '2026-01-01');
|
||||
});
|
||||
|
||||
test('sync change advisories name the same old values the writer replaces', () => {
|
||||
const result = stateTransitionMod.transitionCore(
|
||||
documentWithLookalikes,
|
||||
{ kind: 'sync', totalPlansInPhase: 3, percent: 20 },
|
||||
{ clock: { localToday: () => '2026-09-07' } },
|
||||
);
|
||||
const changes = result.data.changes;
|
||||
|
||||
assert.ok(changes.includes('Total Plans in Phase: 2 -> 3'));
|
||||
assert.ok(changes.some((change) => change.startsWith('Progress: [█░░░░░░░░░] 10% -> ')));
|
||||
assert.ok(changes.includes('Last Activity: 2026-01-01 -> 2026-09-07'));
|
||||
assert.ok(result.content.includes('A note cites **Progress:** dashboards, **Total Plans in Phase:** aggregation, and **Last Activity:** retention.'));
|
||||
assert.ok(result.content.includes('\t**Total Plans in Phase:** 3'));
|
||||
assert.ok(result.content.includes(' **Progress:** [██░░░░░░░░] 20%'));
|
||||
assert.ok(result.content.includes(' **Last Activity:** 2026-09-07'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// #4243 — begin-phase: prose bold-lookalikes stay untouched (anchored bold
|
||||
// form in stateReplaceField) and frontmatter round-trips unknown keys
|
||||
|
||||
Reference in New Issue
Block a user