* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) Golden/read-only parity tests and registry alignment, query handler fixes (check-completion, state-mutation, commit, validate, summary, etc.), and WAITING.json dual-write for .gsd/.planning readers. Refs gsd-build/get-shit-done#2341 * fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback) - Recognize in-flight 🟡 milestone bullets like 🚧. - Derive from last **vX.Y Title** before ## Phases when emoji absent. - Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y in cleaned text instead of first (avoids v1.0 from shipped list). - Fixes init.execute-phase milestone_version and buildStateFrontmatter after state.begin-phase (syncStateFrontmatter). * feat(sdk): phase list, plan task structure, requirements extract handlers - Register phase.list-plans, phase.list-artifacts, plan.task-structure, requirements.extract-from-plans (SDK-only; golden-policy exceptions). - Add unit tests; document in QUERY-HANDLERS.md. - writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored. * feat(sdk): centralize getGsdAgentsDir in query helpers Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary ~/.claude/agents, legacy path). Use from init and docs-init init bundles. docs(15): add 15-CONTEXT for autonomous phase-15 run. * feat(sdk): query CLI CJS fallback and session correlation - createRegistry(eventStream, sessionId) threads correlation into mutation events - gsd-sdk query falls back to gsd-tools.cjs when no native handler matches (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings - Export createRegistry from @gsd-build/sdk; add sdk/README.md - Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId - Agents: prefer node dist/cli.js query over cat/grep for STATE and plans * fix(sdk): init phase_found parity, docs-init agents path, state field extract - Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal) - docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled - state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard) - Tests: commit_docs default true; config-get golden uses temp config; golden integration green Refs: #2302 * refactor(sdk): share SessionJsonlRecord in profile-extract-messages CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages. * fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify) - Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command - plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment - check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default - state.validate/sync: phaseTokenMatches + comparePhaseNum ordering - verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter - audit-open: has_scan_errors, unreadable rows, human report when scans fail - requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note - ingest-docs: repo-root path containment; classifier output slug-hash Golden parity test strips has_scan_errors until CJS adds field. * fix: Resolve CodeRabbit security and quality findings - Secure intel.ts and cli.ts against path traversal - Catch and validate git add status in commit.ts - Expand roadmap milestone marker extraction - Fix parsing array-of-objects in frontmatter YAML - Fix unhandled config evaluations - Improve coverage test parity mapping * test: raise planner character extraction limit to 48K * fix(sdk): resolve TS build error in docs-init passing config
This commit is contained in:
@@ -18,17 +18,40 @@
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
import { planningPaths, toPosixPath } from './helpers.js';
|
||||
import { GSDError, ErrorClassification } from '../errors.js';
|
||||
import { extractFrontmatter } from './frontmatter.js';
|
||||
import { planningPaths, resolvePathUnderProject, sanitizeForDisplay, toPosixPath } from './helpers.js';
|
||||
import { getMilestonePhaseFilter } from './state.js';
|
||||
import type { QueryHandler } from './utils.js';
|
||||
|
||||
/** Same string as `buildCheckpoint` in `get-shit-done/bin/lib/uat.cjs`. */
|
||||
function buildUatCheckpoint(currentTest: { number: number; name: string; expected: string }): string {
|
||||
return [
|
||||
'╔══════════════════════════════════════════════════════════════╗',
|
||||
'║ CHECKPOINT: Verification Required ║',
|
||||
'╚══════════════════════════════════════════════════════════════╝',
|
||||
'',
|
||||
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
||||
'',
|
||||
currentTest.expected,
|
||||
'',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
'Type `pass` or describe what\'s wrong.',
|
||||
'──────────────────────────────────────────────────────────────',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ─── uatRenderCheckpoint ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render the current UAT checkpoint — reads a UAT file, parses the
|
||||
* "Current Test" section, and returns a formatted checkpoint prompt.
|
||||
*
|
||||
* Port of `cmdRenderCheckpoint` from `uat.cjs` (paths via `requireSafePath`,
|
||||
* checkpoint via `buildCheckpoint`, name/expected via `sanitizeForDisplay`).
|
||||
*
|
||||
* Args: --file <path>
|
||||
*/
|
||||
export const uatRenderCheckpoint: QueryHandler = async (args, projectDir) => {
|
||||
@@ -38,7 +61,13 @@ export const uatRenderCheckpoint: QueryHandler = async (args, projectDir) => {
|
||||
return { data: { error: 'UAT file required: use uat render-checkpoint --file <path>' } };
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(projectDir, filePath);
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = await resolvePathUnderProject(projectDir, filePath);
|
||||
} catch {
|
||||
return { data: { error: `UAT file not found: ${filePath}` } };
|
||||
}
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return { data: { error: `UAT file not found: ${filePath}` } };
|
||||
}
|
||||
@@ -56,7 +85,7 @@ export const uatRenderCheckpoint: QueryHandler = async (args, projectDir) => {
|
||||
}
|
||||
|
||||
if (/\[testing complete\]/i.test(section)) {
|
||||
return { data: { complete: true, checkpoint: null } };
|
||||
return { data: { error: 'UAT session is already complete; no pending checkpoint to render' } };
|
||||
}
|
||||
|
||||
const numberMatch = section.match(/^number:\s*(\d+)\s*$/m);
|
||||
@@ -66,110 +95,220 @@ export const uatRenderCheckpoint: QueryHandler = async (args, projectDir) => {
|
||||
const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
|
||||
|
||||
if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
|
||||
return { data: { error: 'Current Test section is malformed — requires number, name, and expected fields' } };
|
||||
return { data: { error: 'Current Test section is malformed' } };
|
||||
}
|
||||
|
||||
let expected: string;
|
||||
let expectedRaw: string;
|
||||
if (expectedBlockMatch) {
|
||||
expected = expectedBlockMatch[1]
|
||||
expectedRaw = expectedBlockMatch[1]
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^ {2}/, ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
} else {
|
||||
expected = expectedInlineMatch![1].trim();
|
||||
expectedRaw = expectedInlineMatch![1].trim();
|
||||
}
|
||||
|
||||
const testNumber = parseInt(numberMatch[1], 10);
|
||||
const testName = nameMatch[1].trim();
|
||||
const currentTest = {
|
||||
complete: false as const,
|
||||
number: parseInt(numberMatch[1], 10),
|
||||
name: sanitizeForDisplay(nameMatch[1].trim()),
|
||||
expected: sanitizeForDisplay(expectedRaw),
|
||||
};
|
||||
|
||||
const checkpoint = [
|
||||
'\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557',
|
||||
'\u2551 CHECKPOINT: Verification Required \u2551',
|
||||
'\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d',
|
||||
'',
|
||||
`**Test ${testNumber}: ${testName}**`,
|
||||
'',
|
||||
expected,
|
||||
'',
|
||||
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500',
|
||||
"Type `pass` or describe what's wrong.",
|
||||
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500',
|
||||
].join('\n');
|
||||
const checkpoint = buildUatCheckpoint(currentTest);
|
||||
|
||||
return {
|
||||
data: {
|
||||
file_path: toPosixPath(relative(projectDir, resolvedPath)),
|
||||
test_number: testNumber,
|
||||
test_name: testName,
|
||||
test_number: currentTest.number,
|
||||
test_name: currentTest.name,
|
||||
checkpoint,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// ─── auditUat ────────────────────────────────────────────────────────────
|
||||
// ─── auditUat (cmdAuditUat) ────────────────────────────────────────────────
|
||||
|
||||
function parseUatItems(content: string): string[] {
|
||||
const items: string[] = [];
|
||||
for (const line of content.split('\n')) {
|
||||
if (/^-\s*\[\s*\]/.test(line) || /^-\s*\[[ ]\]/.test(line)) {
|
||||
items.push(line.trim());
|
||||
/** Port of `categorizeItem` from `uat.cjs`. */
|
||||
function categorizeItem(
|
||||
result: string,
|
||||
reason: string | undefined,
|
||||
blockedBy: string | undefined,
|
||||
): string {
|
||||
if (result === 'blocked' || blockedBy) {
|
||||
if (blockedBy) {
|
||||
if (/server/i.test(blockedBy)) return 'server_blocked';
|
||||
if (/device|physical/i.test(blockedBy)) return 'device_needed';
|
||||
if (/build|release|preview/i.test(blockedBy)) return 'build_needed';
|
||||
if (/third.party|twilio|stripe/i.test(blockedBy)) return 'third_party';
|
||||
}
|
||||
return 'blocked';
|
||||
}
|
||||
if (result === 'skipped') {
|
||||
if (reason) {
|
||||
if (/server|not running|not available/i.test(reason)) return 'server_blocked';
|
||||
if (/simulator|physical|device/i.test(reason)) return 'device_needed';
|
||||
if (/build|release|preview/i.test(reason)) return 'build_needed';
|
||||
}
|
||||
return 'skipped_unresolved';
|
||||
}
|
||||
if (result === 'pending') return 'pending';
|
||||
if (result === 'human_needed') return 'human_uat';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/** Port of `parseUatItems` from `uat.cjs`. */
|
||||
function parseUatItems(content: string): Record<string, unknown>[] {
|
||||
const items: Record<string, unknown>[] = [];
|
||||
const testPattern =
|
||||
/###\s*(\d+)\.\s*([^\n]+)\nexpected:\s*([^\n]+)\nresult:\s*(\w+)(?:\n(?:reported|reason|blocked_by):\s*[^\n]*)?/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = testPattern.exec(content)) !== null) {
|
||||
const [, num, name, expected, result] = match;
|
||||
if (result === 'pending' || result === 'skipped' || result === 'blocked') {
|
||||
const afterMatch = content.slice(match.index);
|
||||
const nextHeading = afterMatch.indexOf('\n###', 1);
|
||||
const blockText = nextHeading > 0 ? afterMatch.slice(0, nextHeading) : afterMatch;
|
||||
const reasonMatch = blockText.match(/reason:\s*(.+)/);
|
||||
const blockedByMatch = blockText.match(/blocked_by:\s*(.+)/);
|
||||
|
||||
const item: Record<string, unknown> = {
|
||||
test: parseInt(num, 10),
|
||||
name: name.trim(),
|
||||
expected: expected.trim(),
|
||||
result,
|
||||
category: categorizeItem(result, reasonMatch?.[1], blockedByMatch?.[1]),
|
||||
};
|
||||
if (reasonMatch) item.reason = reasonMatch[1].trim();
|
||||
if (blockedByMatch) item.blocked_by = blockedByMatch[1].trim();
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseVerificationItems(content: string): string[] {
|
||||
const items: string[] = [];
|
||||
const gapSection = /## gaps?|## issues?|## failures?/i;
|
||||
let inGapSection = false;
|
||||
for (const line of content.split('\n')) {
|
||||
if (/^##/.test(line)) { inGapSection = gapSection.test(line); continue; }
|
||||
if (inGapSection && line.trim().startsWith('-')) items.push(line.trim());
|
||||
/** Port of `parseVerificationItems` from `uat.cjs`. */
|
||||
function parseVerificationItems(content: string, status: string): Record<string, unknown>[] {
|
||||
const items: Record<string, unknown>[] = [];
|
||||
if (status === 'human_needed') {
|
||||
const hvSection = content.match(/##\s*Human Verification.*?\n([\s\S]*?)(?=\n##\s|\n---\s|$)/i);
|
||||
if (hvSection) {
|
||||
const lines = hvSection[1].split('\n');
|
||||
for (const line of lines) {
|
||||
const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
||||
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
||||
|
||||
if (tableMatch) {
|
||||
items.push({
|
||||
test: parseInt(tableMatch[1], 10),
|
||||
name: tableMatch[2].trim(),
|
||||
result: 'human_needed',
|
||||
category: 'human_uat',
|
||||
});
|
||||
} else if (numberedMatch) {
|
||||
items.push({
|
||||
test: parseInt(numberedMatch[1], 10),
|
||||
name: numberedMatch[2].trim(),
|
||||
result: 'human_needed',
|
||||
category: 'human_uat',
|
||||
});
|
||||
} else if (bulletMatch && bulletMatch[1].length > 10) {
|
||||
items.push({
|
||||
name: bulletMatch[1].trim(),
|
||||
result: 'human_needed',
|
||||
category: 'human_uat',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function extractFrontmatterStatus(content: string): string {
|
||||
const match = content.match(/^---[\s\S]*?^status:\s*(.+?)[\r\n]/m);
|
||||
return match ? match[1].trim() : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-phase UAT / VERIFICATION audit — port of `cmdAuditUat` (`uat.cjs`).
|
||||
*/
|
||||
export const auditUat: QueryHandler = async (_args, projectDir) => {
|
||||
const paths = planningPaths(projectDir);
|
||||
if (!existsSync(paths.phases)) {
|
||||
return { data: { results: [], summary: { total_files: 0, total_items: 0 } } };
|
||||
throw new GSDError('No phases directory found in planning directory', ErrorClassification.Blocked);
|
||||
}
|
||||
|
||||
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
|
||||
const results: Record<string, unknown>[] = [];
|
||||
const entries = readdirSync(paths.phases, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries.filter(e => e.isDirectory())) {
|
||||
const phaseMatch = entry.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : entry.name;
|
||||
const phaseDir = join(paths.phases, entry.name);
|
||||
const dirs = readdirSync(paths.phases, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.filter(isDirInMilestone)
|
||||
.sort();
|
||||
|
||||
for (const dir of dirs) {
|
||||
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
|
||||
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
||||
const phaseDir = join(paths.phases, dir);
|
||||
const files = readdirSync(phaseDir);
|
||||
|
||||
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
||||
const content = readFileSync(join(phaseDir, file), 'utf-8');
|
||||
const items = parseUatItems(content);
|
||||
if (items.length > 0) {
|
||||
results.push({ phase: phaseNum, phase_dir: entry.name, file, file_path: toPosixPath(relative(projectDir, join(phaseDir, file))), type: 'uat', status: extractFrontmatterStatus(content), items });
|
||||
const fm = extractFrontmatter(content);
|
||||
results.push({
|
||||
phase: phaseNum,
|
||||
phase_dir: dir,
|
||||
file,
|
||||
file_path: toPosixPath(relative(projectDir, join(phaseDir, file))),
|
||||
type: 'uat',
|
||||
status: (fm.status || 'unknown') as string,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
||||
const content = readFileSync(join(phaseDir, file), 'utf-8');
|
||||
const status = extractFrontmatterStatus(content);
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown') as string;
|
||||
if (status === 'human_needed' || status === 'gaps_found') {
|
||||
const items = parseVerificationItems(content);
|
||||
const items = parseVerificationItems(content, status);
|
||||
if (items.length > 0) {
|
||||
results.push({ phase: phaseNum, phase_dir: entry.name, file, file_path: toPosixPath(relative(projectDir, join(phaseDir, file))), type: 'verification', status, items });
|
||||
results.push({
|
||||
phase: phaseNum,
|
||||
phase_dir: dir,
|
||||
file,
|
||||
file_path: toPosixPath(relative(projectDir, join(phaseDir, file))),
|
||||
type: 'verification',
|
||||
status,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalItems = results.reduce((sum, r) => sum + ((r.items as unknown[]).length), 0);
|
||||
return { data: { results, summary: { total_files: results.length, total_items: totalItems } } };
|
||||
const summary: {
|
||||
total_files: number;
|
||||
total_items: number;
|
||||
by_category: Record<string, number>;
|
||||
by_phase: Record<string, number>;
|
||||
} = {
|
||||
total_files: results.length,
|
||||
total_items: results.reduce((sum, r) => sum + (r.items as unknown[]).length, 0),
|
||||
by_category: {},
|
||||
by_phase: {},
|
||||
};
|
||||
|
||||
for (const r of results) {
|
||||
if (!summary.by_phase[r.phase as string]) summary.by_phase[r.phase as string] = 0;
|
||||
for (const item of r.items as Array<{ category?: string }>) {
|
||||
summary.by_phase[r.phase as string]++;
|
||||
const cat = item.category || 'unknown';
|
||||
summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { data: { results, summary } };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user