* refactor(#180): migrate state document module to sdk/src/state * test(#180): ratchet lint allowlists for state module relocation
This commit is contained in:
@@ -26,7 +26,7 @@ export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
|
||||
import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
|
||||
import { canonicalizeRuntimeName } from '../runtime-name-policy.js';
|
||||
import { workspacePlanningPaths, resolveWorkspaceContext, type PlanningPaths } from './workspace.js';
|
||||
export { stateExtractField } from './state-document.js';
|
||||
export { stateExtractField } from '../state/index.js';
|
||||
import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js';
|
||||
|
||||
// ─── Runtime-aware agents directory resolution ─────────────────────────────
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
releaseStateLock,
|
||||
stateReplaceField,
|
||||
} from './state-mutation.js';
|
||||
import { stateExtractField, stateReplaceFieldWithFallback } from './state-document.js';
|
||||
import { stateExtractField, stateReplaceFieldWithFallback } from '../state/index.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
import {
|
||||
assertNoNullBytes,
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Unit tests for STATE.md Document Module — stateExtractField.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stateExtractField,
|
||||
stateReplaceField,
|
||||
stateReplaceFieldWithFallback,
|
||||
normalizeStateStatus,
|
||||
computeProgressPercent,
|
||||
shouldPreserveExistingProgress,
|
||||
normalizeProgressNumbers,
|
||||
} from './state-document.js';
|
||||
|
||||
describe('stateExtractField', () => {
|
||||
it('extracts value from bold pattern', () => {
|
||||
const content = 'Some content\n**FieldName:** the value\nMore content';
|
||||
expect(stateExtractField(content, 'FieldName')).toBe('the value');
|
||||
});
|
||||
|
||||
it('extracts value from plain pattern', () => {
|
||||
const content = 'Some content\nFieldName: the value\nMore content';
|
||||
expect(stateExtractField(content, 'FieldName')).toBe('the value');
|
||||
});
|
||||
|
||||
it('returns null when field is missing', () => {
|
||||
const content = 'Some content\nOtherField: something\nMore content';
|
||||
expect(stateExtractField(content, 'FieldName')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stateReplaceField', () => {
|
||||
it('replaces value in bold pattern', () => {
|
||||
const content = 'Some content\n**Status:** old value\nMore content';
|
||||
const result = stateReplaceField(content, 'Status', 'new value');
|
||||
expect(result).toBe('Some content\n**Status:** new value\nMore content');
|
||||
});
|
||||
|
||||
it('replaces value in plain pattern', () => {
|
||||
const content = 'Some content\nStatus: old value\nMore content';
|
||||
const result = stateReplaceField(content, 'Status', 'new value');
|
||||
expect(result).toBe('Some content\nStatus: new value\nMore content');
|
||||
});
|
||||
|
||||
it('returns null when field is missing', () => {
|
||||
const content = 'Some content\nOtherField: something\nMore content';
|
||||
const result = stateReplaceField(content, 'Status', 'new value');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stateReplaceFieldWithFallback', () => {
|
||||
it('replaces primary field when it exists', () => {
|
||||
const content = 'Status: old\nState: backup';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
|
||||
expect(result).toBe('Status: new\nState: backup');
|
||||
});
|
||||
|
||||
it('replaces fallback field when primary is missing', () => {
|
||||
const content = 'Other: something\nState: backup';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
|
||||
expect(result).toBe('Other: something\nState: new');
|
||||
});
|
||||
|
||||
it('returns content unchanged when neither field exists', () => {
|
||||
const content = 'Other: something\nAnother: value';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', 'State', 'new');
|
||||
expect(result).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeStateStatus', () => {
|
||||
it('returns paused for status containing "paused"', () => {
|
||||
expect(normalizeStateStatus('paused')).toBe('paused');
|
||||
});
|
||||
|
||||
it('returns paused for status containing "stopped"', () => {
|
||||
expect(normalizeStateStatus('stopped')).toBe('paused');
|
||||
});
|
||||
|
||||
it('returns paused when non-null pausedAt is provided', () => {
|
||||
expect(normalizeStateStatus('active', '2024-01-01')).toBe('paused');
|
||||
});
|
||||
|
||||
it('returns executing for status containing "executing"', () => {
|
||||
expect(normalizeStateStatus('executing')).toBe('executing');
|
||||
});
|
||||
|
||||
it('returns executing for status "in progress"', () => {
|
||||
expect(normalizeStateStatus('in progress')).toBe('executing');
|
||||
});
|
||||
|
||||
it('returns executing for status "ready to execute"', () => {
|
||||
expect(normalizeStateStatus('ready to execute')).toBe('executing');
|
||||
});
|
||||
|
||||
it('returns planning for status containing "planning"', () => {
|
||||
expect(normalizeStateStatus('planning')).toBe('planning');
|
||||
});
|
||||
|
||||
it('returns discussing for status containing "discussing"', () => {
|
||||
expect(normalizeStateStatus('discussing')).toBe('discussing');
|
||||
});
|
||||
|
||||
it('returns verifying for status containing "verif"', () => {
|
||||
expect(normalizeStateStatus('verifying')).toBe('verifying');
|
||||
});
|
||||
|
||||
it('returns completed for status containing "complete"', () => {
|
||||
expect(normalizeStateStatus('completed')).toBe('completed');
|
||||
});
|
||||
|
||||
it('returns completed for status containing "done"', () => {
|
||||
expect(normalizeStateStatus('done')).toBe('completed');
|
||||
});
|
||||
|
||||
it('returns unknown for unrecognized status', () => {
|
||||
expect(normalizeStateStatus('something-else')).toBe('something-else');
|
||||
});
|
||||
|
||||
it('returns unknown for null status', () => {
|
||||
expect(normalizeStateStatus(null)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeProgressPercent', () => {
|
||||
it('uses only plans data when phases data is absent', () => {
|
||||
expect(computeProgressPercent(3, 10, null, null)).toBe(30);
|
||||
});
|
||||
|
||||
it('uses only phases data when plans data is absent', () => {
|
||||
expect(computeProgressPercent(null, null, 2, 4)).toBe(50);
|
||||
});
|
||||
|
||||
it('uses minimum fraction when both plans and phases data are present', () => {
|
||||
// plans: 8/10 = 80%, phases: 3/10 = 30% → min = 30%
|
||||
expect(computeProgressPercent(8, 10, 3, 10)).toBe(30);
|
||||
});
|
||||
|
||||
it('returns null when neither plans nor phases data is present', () => {
|
||||
expect(computeProgressPercent(null, null, null, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when total is 0 (treated as no data)', () => {
|
||||
expect(computeProgressPercent(0, 0, null, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldPreserveExistingProgress', () => {
|
||||
it('returns true when existing total_phases exceeds derived', () => {
|
||||
expect(shouldPreserveExistingProgress({ total_phases: 10 }, { total_phases: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when derived exceeds existing', () => {
|
||||
expect(shouldPreserveExistingProgress({ total_phases: 5 }, { total_phases: 10 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when existingProgress is not an object', () => {
|
||||
expect(shouldPreserveExistingProgress(null, { total_phases: 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when both are null', () => {
|
||||
expect(shouldPreserveExistingProgress(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeProgressNumbers', () => {
|
||||
it('coerces all five tracked keys to numbers', () => {
|
||||
const input = {
|
||||
total_phases: '10',
|
||||
completed_phases: '3',
|
||||
total_plans: '5',
|
||||
completed_plans: '2',
|
||||
percent: '60',
|
||||
};
|
||||
expect(normalizeProgressNumbers(input)).toEqual({
|
||||
total_phases: 10,
|
||||
completed_phases: 3,
|
||||
total_plans: 5,
|
||||
completed_plans: 2,
|
||||
percent: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns non-object input unchanged', () => {
|
||||
expect(normalizeProgressNumbers(null)).toBeNull();
|
||||
expect(normalizeProgressNumbers('string')).toBe('string');
|
||||
});
|
||||
|
||||
it('preserves extra keys untouched', () => {
|
||||
const input = { total_phases: '4', extra_key: 'hello' };
|
||||
const result = normalizeProgressNumbers(input) as Record<string, unknown>;
|
||||
expect(result.total_phases).toBe(4);
|
||||
expect(result.extra_key).toBe('hello');
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* STATE.md Document Module.
|
||||
*
|
||||
* Pure transforms for STATE.md text. This module does not read the filesystem
|
||||
* and does not own persistence or locking.
|
||||
*/
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function stateExtractField(content: string, fieldName: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
|
||||
const boldMatch = content.match(boldPattern);
|
||||
if (boldMatch) return boldMatch[1].trim();
|
||||
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
|
||||
const plainMatch = content.match(plainPattern);
|
||||
return plainMatch ? plainMatch[1].trim() : null;
|
||||
}
|
||||
|
||||
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
if (boldPattern.test(content)) {
|
||||
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
}
|
||||
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
|
||||
if (plainPattern.test(content)) {
|
||||
return content.replace(plainPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stateReplaceFieldWithFallback(
|
||||
content: string,
|
||||
primary: string,
|
||||
fallback: string | null,
|
||||
value: string,
|
||||
): string {
|
||||
let result = stateReplaceField(content, primary, value);
|
||||
if (result) return result;
|
||||
if (fallback) {
|
||||
result = stateReplaceField(content, fallback, value);
|
||||
if (result) return result;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
export function normalizeStateStatus(status: string | null | undefined, pausedAt?: string | null): string {
|
||||
let normalizedStatus = status || 'unknown';
|
||||
const statusLower = (status || '').toLowerCase();
|
||||
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
|
||||
normalizedStatus = 'paused';
|
||||
} else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
|
||||
normalizedStatus = 'executing';
|
||||
} else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
|
||||
normalizedStatus = 'planning';
|
||||
} else if (statusLower.includes('discussing')) {
|
||||
normalizedStatus = 'discussing';
|
||||
} else if (statusLower.includes('verif')) {
|
||||
normalizedStatus = 'verifying';
|
||||
} else if (statusLower.includes('complete') || statusLower.includes('done')) {
|
||||
normalizedStatus = 'completed';
|
||||
} else if (statusLower.includes('ready to execute')) {
|
||||
normalizedStatus = 'executing';
|
||||
}
|
||||
return normalizedStatus;
|
||||
}
|
||||
|
||||
export function computeProgressPercent(
|
||||
completedPlans: number | null,
|
||||
totalPlans: number | null,
|
||||
completedPhases: number | null,
|
||||
totalPhases: number | null,
|
||||
): number | null {
|
||||
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
|
||||
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
|
||||
|
||||
if (!hasPlanData && !hasPhaseData) return null;
|
||||
|
||||
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
|
||||
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
|
||||
|
||||
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
function existingProgressExceedsDerived(
|
||||
existingProgress: Record<string, unknown>,
|
||||
derivedProgress: Record<string, unknown>,
|
||||
key: string,
|
||||
): boolean {
|
||||
const existing = toFiniteNumber(existingProgress[key]);
|
||||
const derived = toFiniteNumber(derivedProgress[key]);
|
||||
return existing !== null && derived !== null && existing > derived;
|
||||
}
|
||||
|
||||
export function shouldPreserveExistingProgress(
|
||||
existingProgress: unknown,
|
||||
derivedProgress: unknown,
|
||||
): existingProgress is Record<string, unknown> {
|
||||
if (!existingProgress || typeof existingProgress !== 'object') return false;
|
||||
if (!derivedProgress || typeof derivedProgress !== 'object') return false;
|
||||
|
||||
const existing = existingProgress as Record<string, unknown>;
|
||||
const derived = derivedProgress as Record<string, unknown>;
|
||||
return (
|
||||
existingProgressExceedsDerived(existing, derived, 'total_phases') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
|
||||
existingProgressExceedsDerived(existing, derived, 'completed_plans')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeProgressNumbers(progress: unknown): unknown {
|
||||
if (!progress || typeof progress !== 'object') return progress;
|
||||
|
||||
const normalized: Record<string, unknown> = { ...(progress as Record<string, unknown>) };
|
||||
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
|
||||
const number = toFiniteNumber(normalized[key]);
|
||||
if (number !== null) normalized[key] = number;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
} from './helpers.js';
|
||||
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
|
||||
import { scanPhasePlans } from './plan-scan.js';
|
||||
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from './state-document.js';
|
||||
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, computeProgressPercent } from '../state/index.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']);
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
normalizeStateStatus,
|
||||
shouldPreserveExistingProgress,
|
||||
stateExtractField,
|
||||
} from './state-document.js';
|
||||
} from '../state/index.js';
|
||||
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
|
||||
import { scanPhasePlans } from './plan-scan.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { scanPhasePlans } from './plan-scan.js';
|
||||
import { stateExtractField } from './state-document.js';
|
||||
import { stateExtractField } from '../state/index.js';
|
||||
import { readActiveWorkstream } from './active-workstream-store.js';
|
||||
import { buildWorkstreamInventory } from '../workstream-inventory/builder.js';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user