Files
msd-core/sdk/src/query/requirements-extract-from-plans.ts
Tom Boucher 5676e2e4ef fix(sdk): forward --ws workstream flag through query dispatch (#2546)
* fix(sdk): forward --ws workstream flag through query dispatch (closes #2524)

- cli.ts: pass args.ws as workstream to registry.dispatch()
- registry.ts: add workstream? param to dispatch(), thread to handler
- utils.ts: add optional workstream? to QueryHandler type signature
- helpers.ts: planningPaths() accepts workstream? and uses relPlanningPath()
- All ~26 query handlers updated to receive and pass workstream to planningPaths()
- Config/commit/intel handlers use _workstream (project-global, not scoped)
- Add failing-then-passing test: tests/bug-2524-sdk-query-ws-flag.test.cjs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sdk): forward workstream to all downstream query helpers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): rewrite #2524 test as static source assertions — no sdk/dist build in CI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:33:24 -04:00

87 lines
2.6 KiB
TypeScript

/**
* requirements.extract-from-plans — aggregate `requirements` frontmatter across all plans in a phase.
*/
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter } from './frontmatter.js';
import {
normalizePhaseName,
comparePhaseNum,
phaseTokenMatches,
planningPaths,
} from './helpers.js';
import type { QueryHandler } from './utils.js';
async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise<string | null> {
const phasesDir = planningPaths(projectDir, workstream).phases;
const normalized = normalizePhaseName(phase);
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => phaseTokenMatches(d, normalized));
return match ? join(phasesDir, match) : null;
} catch {
return null;
}
}
function normalizeReqList(v: unknown): string[] {
if (!v) return [];
if (Array.isArray(v)) return v.map((x) => String(x));
if (typeof v === 'string') return [v];
return [];
}
/**
* Args: `<phase>`
*/
export const requirementsExtractFromPlans: QueryHandler = async (args, projectDir, workstream) => {
const phase = args[0];
if (!phase) {
throw new GSDError('phase required', ErrorClassification.Validation);
}
const normalized = normalizePhaseName(phase);
const phaseDir = await resolvePhaseDir(phase, projectDir, workstream);
if (!phaseDir) {
return {
data: {
phase: normalized,
requirements: [] as string[],
by_plan: {} as Record<string, string[]>,
error: 'Phase not found',
},
};
}
const files = await readdir(phaseDir);
const planFiles = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const byPlan: Record<string, string[]> = {};
const seen = new Set<string>();
for (const planFile of planFiles) {
const planId =
planFile === 'PLAN.md' ? 'PLAN' : planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
const content = await readFile(join(phaseDir, planFile), 'utf-8');
const fm = extractFrontmatter(content) as Record<string, unknown>;
const list = normalizeReqList(fm.requirements);
byPlan[planId] = list;
for (const r of list) {
seen.add(r);
}
}
return {
data: {
phase: normalized,
requirements: [...seen].sort(),
by_plan: byPlan,
},
};
};