feat(sdk): Phase 1 typed query foundation (gsd-sdk query) (#2118)

* feat(sdk): add typed query foundation and gsd-sdk query (Phase 1)

Add sdk/src/query registry and handlers with tests, GSDQueryError, CLI query wiring, and supporting type/tool-scoping hooks. Update CHANGELOG. Vitest 4 constructor mock fixes in milestone-runner tests.

Made-with: Cursor

* chore: gitignore .cursor for local-only Cursor assets

Made-with: Cursor

* fix(sdk): harden query layer for PR review (paths, locks, CLI, ReDoS)

- resolvePathUnderProject: realpath + relative containment for frontmatter and key_links

- commitToSubrepo: path checks + sanitizeCommitMessage

- statePlannedPhase: readModifyWriteStateMd (lock); MUTATION_COMMANDS + events

- key_links: regexForKeyLinkPattern length/ReDoS guard; phase dirs: reject .. and separators

- gsd-sdk: strip --pick before parseArgs; strict parser; QueryRegistry.commands()

- progress: static GSDError import; tests updated

Made-with: Cursor

* feat(sdk): query follow-up — tests, QUERY-HANDLERS, registry, locks, intel depth

Made-with: Cursor

* docs(sdk): use ASCII punctuation in QUERY-HANDLERS.md

Made-with: Cursor
This commit is contained in:
Rezolv
2026-04-12 18:15:04 -04:00
committed by GitHub
parent 66a5f939b0
commit 6f79b1dd5e
36 changed files with 848 additions and 142 deletions

3
.gitignore vendored
View File

@@ -8,6 +8,9 @@ commands.html
# Local test installs # Local test installs
.claude/ .claude/
# Cursor IDE — local agents/skills bundle (never commit)
.cursor/
# Build artifacts (committed to npm, not git) # Build artifacts (committed to npm, not git)
hooks/dist/ hooks/dist/

View File

@@ -11,6 +11,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`@gsd-build/sdk` — Phase 1 typed query foundation** — Registry-based `gsd-sdk query` command, classified errors (`GSDQueryError`), and unit-tested handlers under `sdk/src/query/` (state, roadmap, phase lifecycle, init, config, validation, and related domains). Implements incremental SDK-first migration scope approved in #2083; builds on validated work from #2007 / `feat/sdk-foundation` without migrating workflows or removing `gsd-tools.cjs` in this phase. - **`@gsd-build/sdk` — Phase 1 typed query foundation** — Registry-based `gsd-sdk query` command, classified errors (`GSDQueryError`), and unit-tested handlers under `sdk/src/query/` (state, roadmap, phase lifecycle, init, config, validation, and related domains). Implements incremental SDK-first migration scope approved in #2083; builds on validated work from #2007 / `feat/sdk-foundation` without migrating workflows or removing `gsd-tools.cjs` in this phase.
- **Flow diagram directive for phase researcher** — `gsd-phase-researcher` now enforces data-flow architecture diagrams instead of file-listing diagrams. Language-agnostic directive added to agent prompt and research template. (#2139) - **Flow diagram directive for phase researcher** — `gsd-phase-researcher` now enforces data-flow architecture diagrams instead of file-listing diagrams. Language-agnostic directive added to agent prompt and research template. (#2139)
### Fixed
- **SDK query layer (PR review hardening)** — `commit-to-subrepo` uses realpath-aware path containment and sanitized commit messages; `state.planned-phase` uses the STATE.md lockfile; `verifyKeyLinks` mitigates ReDoS on frontmatter patterns; frontmatter handlers resolve paths under the real project root; phase directory names reject `..` and separators; `gsd-sdk` restores strict CLI parsing by stripping `--pick` before `parseArgs`; `QueryRegistry.commands()` for enumeration; `todoComplete` uses static error imports.
### Changed
- **SDK query follow-up (tests, docs, registry)** — Expanded `QUERY_MUTATION_COMMANDS` for event emission; stale lock cleanup uses PID liveness (`process.kill(pid, 0)`) when a lock file exists; `searchJsonEntries` is depth-bounded (`MAX_JSON_SEARCH_DEPTH`); removed unnecessary `readdirSync`/`Dirent` casts across query handlers; added `sdk/src/query/QUERY-HANDLERS.md` (error vs `{ data.error }`, mutations, locks, intel limits); unit tests for intel, profile, uat, skills, summary, websearch, workstream, registry vs `QUERY_MUTATION_COMMANDS`, and frontmatter extract/splice round-trip.
## [1.35.0] - 2026-04-10 ## [1.35.0] - 2026-04-10
### Added ### Added

View File

@@ -100,10 +100,20 @@ describe('parseCliArgs', () => {
expect(result.maxBudget).toBe(15); expect(result.maxBudget).toBe(15);
}); });
it('ignores unknown options (non-strict for --pick support)', () => { it('rejects unknown options (strict parser)', () => {
// strict: false allows --pick and other query-specific flags expect(() => parseCliArgs(['--unknown-flag'])).toThrow();
const result = parseCliArgs(['--unknown-flag']); });
expect(result.command).toBeUndefined();
it('rejects unknown flags on run command', () => {
expect(() => parseCliArgs(['run', 'hello', '--not-a-real-option'])).toThrow();
});
it('parses query with --pick stripped before strict parse', () => {
const result = parseCliArgs([
'query', 'state.load', '--pick', 'data', '--project-dir', 'C:\\tmp\\proj',
]);
expect(result.command).toBe('query');
expect(result.projectDir).toBe('C:\\tmp\\proj');
}); });
// ─── Init command parsing ────────────────────────────────────────────── // ─── Init command parsing ──────────────────────────────────────────────

View File

@@ -36,13 +36,27 @@ export interface ParsedCliArgs {
version: boolean; version: boolean;
} }
/**
* Strip `--pick <field>` from argv before parseArgs so the global parser stays strict.
* Query dispatch removes --pick separately in main(); this only affects CLI parsing.
*/
function argvForCliParse(argv: string[]): string[] {
if (argv[0] !== 'query') return argv;
const copy = [...argv];
const pickIdx = copy.indexOf('--pick');
if (pickIdx !== -1 && pickIdx + 1 < copy.length) {
copy.splice(pickIdx, 2);
}
return copy;
}
/** /**
* Parse CLI arguments into a structured object. * Parse CLI arguments into a structured object.
* Exported for testing — the main() function uses this internally. * Exported for testing — the main() function uses this internally.
*/ */
export function parseCliArgs(argv: string[]): ParsedCliArgs { export function parseCliArgs(argv: string[]): ParsedCliArgs {
const { values, positionals } = parseArgs({ const { values, positionals } = parseArgs({
args: argv, args: argvForCliParse(argv),
options: { options: {
'project-dir': { type: 'string', default: process.cwd() }, 'project-dir': { type: 'string', default: process.cwd() },
'ws-port': { type: 'string' }, 'ws-port': { type: 'string' },
@@ -54,7 +68,7 @@ export function parseCliArgs(argv: string[]): ParsedCliArgs {
version: { type: 'boolean', short: 'v', default: false }, version: { type: 'boolean', short: 'v', default: false },
}, },
allowPositionals: true, allowPositionals: true,
strict: false, strict: true,
}); });
const command = positionals[0] as string | undefined; const command = positionals[0] as string | undefined;

View File

@@ -0,0 +1,26 @@
# Query handler conventions (`sdk/src/query/`)
This document records contracts for the typed query layer consumed by `gsd-sdk query` and programmatic `createRegistry()` callers.
## Error handling
- **Validation and programmer errors**: Handlers throw `GSDError` with an `ErrorClassification` (e.g. missing required args, invalid phase). The CLI maps these to exit codes via `exitCodeFor()`.
- **Expected domain failures**: Handlers return `{ data: { error: string, ... } }` for cases that are not exceptional in normal use (file not found, intel disabled, todo missing, etc.). Callers must check `data.error` when present.
- Do not mix both styles for the same failure mode in new code: prefer **throw** for "caller must fix input"; prefer **`data.error`** for "operation could not complete in this project state."
## Mutation commands and events
- `QUERY_MUTATION_COMMANDS` in `index.ts` lists every command name (including space-delimited aliases) that performs durable writes. It drives optional `GSDEventStream` wrapping so mutations emit structured events.
- Init composition handlers (`init.*`) are **not** included: they return JSON for workflows; agents perform filesystem work.
## Session correlation (`sessionId`)
- Mutation events include `sessionId: ''` until a future phase threads session identifiers through the query dispatch path. Consumers should not rely on `sessionId` for correlation today.
## Lockfiles (`state-mutation.ts`)
- `STATE.md` (and ROADMAP) locks use a sibling `.lock` file with the holder's PID. Stale locks are cleared when the PID no longer exists (`process.kill(pid, 0)` fails) or when the lock file is older than the existing time-based threshold.
## Intel JSON search
- `searchJsonEntries` in `intel.ts` caps recursion depth (`MAX_JSON_SEARCH_DEPTH`) to avoid stack overflow on pathological nested JSON.

View File

@@ -18,9 +18,9 @@
*/ */
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { planningPaths } from './helpers.js'; import { GSDError } from '../errors.js';
import { planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js'; import type { QueryHandler } from './utils.js';
// ─── execGit ────────────────────────────────────────────────────────────── // ─── execGit ──────────────────────────────────────────────────────────────
@@ -227,11 +227,20 @@ export const commitToSubrepo: QueryHandler = async (args, projectDir) => {
return { data: { committed: false, reason: 'commit message required' } }; return { data: { committed: false, reason: 'commit message required' } };
} }
const sanitized = sanitizeCommitMessage(message);
if (!sanitized && message) {
return { data: { committed: false, reason: 'commit message empty after sanitization' } };
}
try { try {
for (const file of files) { for (const file of files) {
const resolved = join(projectDir, file); try {
if (!resolved.startsWith(projectDir)) { await resolvePathUnderProject(projectDir, file);
return { data: { committed: false, reason: `file path escapes project: ${file}` } }; } catch (err) {
if (err instanceof GSDError) {
return { data: { committed: false, reason: `${err.message}: ${file}` } };
}
throw err;
} }
} }
@@ -239,7 +248,7 @@ export const commitToSubrepo: QueryHandler = async (args, projectDir) => {
spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe' }); spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe' });
const commitResult = spawnSync( const commitResult = spawnSync(
'git', ['-C', projectDir, 'commit', '-m', message], 'git', ['-C', projectDir, 'commit', '-m', sanitized],
{ stdio: 'pipe', encoding: 'utf-8' }, { stdio: 'pipe', encoding: 'utf-8' },
); );
if (commitResult.status !== 0) { if (commitResult.status !== 0) {
@@ -251,7 +260,7 @@ export const commitToSubrepo: QueryHandler = async (args, projectDir) => {
{ encoding: 'utf-8' }, { encoding: 'utf-8' },
); );
const hash = hashResult.stdout.trim(); const hash = hashResult.stdout.trim();
return { data: { committed: true, hash, message } }; return { data: { committed: true, hash, message: sanitized } };
} catch (err) { } catch (err) {
return { data: { committed: false, reason: String(err) } }; return { data: { committed: false, reason: String(err) } };
} }

View File

@@ -232,3 +232,28 @@ describe('frontmatterValidate', () => {
expect(FRONTMATTER_SCHEMAS).toHaveProperty('verification'); expect(FRONTMATTER_SCHEMAS).toHaveProperty('verification');
}); });
}); });
// ─── Round-trip (extract → reconstruct → splice) ───────────────────────────
describe('frontmatter round-trip', () => {
it('preserves scalar and list fields through extract + splice', () => {
const original = `---
phase: "01"
plan: "02"
type: execute
wave: 1
depends_on: []
tags: [a, b]
---
# Title
`;
const fm = extractFrontmatter(original) as Record<string, unknown>;
const spliced = spliceFrontmatter('# Title\n', fm);
expect(spliced.startsWith('---\n')).toBe(true);
const round = extractFrontmatter(spliced) as Record<string, unknown>;
expect(String(round.phase)).toBe('01');
// YAML may round-trip wave as number or string depending on parser output
expect(Number(round.wave)).toBe(1);
expect(Array.isArray(round.tags)).toBe(true);
});
});

View File

@@ -18,10 +18,9 @@
*/ */
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { join, isAbsolute } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js'; import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter } from './frontmatter.js'; import { extractFrontmatter } from './frontmatter.js';
import { normalizeMd } from './helpers.js'; import { normalizeMd, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js'; import type { QueryHandler } from './utils.js';
// ─── FRONTMATTER_SCHEMAS ────────────────────────────────────────────────── // ─── FRONTMATTER_SCHEMAS ──────────────────────────────────────────────────
@@ -178,7 +177,15 @@ export const frontmatterSet: QueryHandler = async (args, projectDir) => {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation); throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
} }
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string; let content: string;
try { try {
@@ -220,7 +227,15 @@ export const frontmatterMerge: QueryHandler = async (args, projectDir) => {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation); throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
} }
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string; let content: string;
try { try {
@@ -285,7 +300,15 @@ export const frontmatterValidate: QueryHandler = async (args, projectDir) => {
); );
} }
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string; let content: string;
try { try {

View File

@@ -17,10 +17,9 @@
*/ */
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join, isAbsolute } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js'; import { GSDError, ErrorClassification } from '../errors.js';
import type { QueryHandler } from './utils.js'; import type { QueryHandler } from './utils.js';
import { escapeRegex } from './helpers.js'; import { escapeRegex, resolvePathUnderProject } from './helpers.js';
// ─── splitInlineArray ─────────────────────────────────────────────────────── // ─── splitInlineArray ───────────────────────────────────────────────────────
@@ -329,7 +328,15 @@ export const frontmatterGet: QueryHandler = async (args, projectDir) => {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation); throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
} }
const fullPath = isAbsolute(filePath) ? filePath : join(projectDir, filePath); let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, filePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: filePath } };
}
throw err;
}
let content: string; let content: string;
try { try {

View File

@@ -2,7 +2,11 @@
* Unit tests for shared query helpers. * Unit tests for shared query helpers.
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError } from '../errors.js';
import { import {
escapeRegex, escapeRegex,
normalizePhaseName, normalizePhaseName,
@@ -13,6 +17,7 @@ import {
stateExtractField, stateExtractField,
planningPaths, planningPaths,
normalizeMd, normalizeMd,
resolvePathUnderProject,
} from './helpers.js'; } from './helpers.js';
// ─── escapeRegex ──────────────────────────────────────────────────────────── // ─── escapeRegex ────────────────────────────────────────────────────────────
@@ -223,3 +228,27 @@ describe('normalizeMd', () => {
expect(result).toBe(input); expect(result).toBe(input);
}); });
}); });
// ─── resolvePathUnderProject ────────────────────────────────────────────────
describe('resolvePathUnderProject', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-path-'));
await writeFile(join(tmpDir, 'safe.md'), 'x', 'utf-8');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('resolves a relative file under the project root', async () => {
const p = await resolvePathUnderProject(tmpDir, 'safe.md');
expect(p.endsWith('safe.md')).toBe(true);
});
it('rejects paths that escape the project root', async () => {
await expect(resolvePathUnderProject(tmpDir, '../../etc/passwd')).rejects.toThrow(GSDError);
});
});

View File

@@ -17,7 +17,9 @@
* ``` * ```
*/ */
import { join } from 'node:path'; import { join, relative, resolve, isAbsolute, normalize } from 'node:path';
import { realpath } from 'node:fs/promises';
import { GSDError, ErrorClassification } from '../errors.js';
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -322,3 +324,30 @@ export function planningPaths(projectDir: string): PlanningPaths {
requirements: toPosixPath(join(base, 'REQUIREMENTS.md')), requirements: toPosixPath(join(base, 'REQUIREMENTS.md')),
}; };
} }
// ─── resolvePathUnderProject ───────────────────────────────────────────────
/**
* Resolve a user-supplied path against the project and ensure it cannot escape
* the real project root (prefix checks are insufficient; symlinks are handled
* via realpath).
*
* @param projectDir - Project root directory
* @param userPath - Relative or absolute path from user input
* @returns Canonical resolved path within the project
*/
export async function resolvePathUnderProject(projectDir: string, userPath: string): Promise<string> {
const projectReal = await realpath(projectDir);
const candidate = isAbsolute(userPath) ? normalize(userPath) : resolve(projectReal, userPath);
let realCandidate: string;
try {
realCandidate = await realpath(candidate);
} catch {
realCandidate = candidate;
}
const rel = relative(projectReal, realCandidate);
if (rel.startsWith('..') || (isAbsolute(rel) && rel.length > 0)) {
throw new GSDError('path escapes project directory', ErrorClassification.Validation);
}
return realCandidate;
}

View File

@@ -89,28 +89,46 @@ export { extractField } from './registry.js';
// ─── Mutation commands set ──────────────────────────────────────────────── // ─── Mutation commands set ────────────────────────────────────────────────
/** /**
* Set of command names that represent mutation operations. * Command names that perform durable writes (disk, git, or global profile store).
* Used to wire event emission after successful dispatch. * Used to wire event emission after successful dispatch. Both dotted and
* space-delimited aliases must be listed when both exist.
*
* See QUERY-HANDLERS.md for semantics. Init composition handlers are omitted
* (they emit JSON for workflows; agents perform writes).
*/ */
const MUTATION_COMMANDS = new Set([ export const QUERY_MUTATION_COMMANDS = new Set<string>([
'state.update', 'state.patch', 'state.begin-phase', 'state.advance-plan', 'state.update', 'state.patch', 'state.begin-phase', 'state.advance-plan',
'state.record-metric', 'state.update-progress', 'state.add-decision', 'state.record-metric', 'state.update-progress', 'state.add-decision',
'state.add-blocker', 'state.resolve-blocker', 'state.record-session', 'state.add-blocker', 'state.resolve-blocker', 'state.record-session',
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'state.planned-phase', 'state planned-phase',
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section', 'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
'commit', 'check-commit', 'commit', 'check-commit', 'commit-to-subrepo',
'template.fill', 'template.select', 'template.fill', 'template.select', 'template select',
'validate.health', 'validate health', 'validate.health', 'validate health',
'phase.add', 'phase.insert', 'phase.remove', 'phase.complete', 'phase.add', 'phase.insert', 'phase.remove', 'phase.complete',
'phase.scaffold', 'phases.clear', 'phases.archive', 'phase.scaffold', 'phases.clear', 'phases.archive',
'phase add', 'phase insert', 'phase remove', 'phase complete', 'phase add', 'phase insert', 'phase remove', 'phase complete',
'phase scaffold', 'phases clear', 'phases archive', 'phase scaffold', 'phases clear', 'phases archive',
'roadmap.update-plan-progress', 'roadmap update-plan-progress',
'requirements.mark-complete', 'requirements mark-complete',
'todo.complete', 'todo complete',
'milestone.complete', 'milestone complete',
'workstream.create', 'workstream.set', 'workstream.complete', 'workstream.progress',
'workstream create', 'workstream set', 'workstream complete', 'workstream progress',
'docs-init',
'learnings.copy', 'learnings copy',
'intel.snapshot', 'intel.patch-meta', 'intel snapshot', 'intel patch-meta',
'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md',
]); ]);
// ─── Event builder ──────────────────────────────────────────────────────── // ─── Event builder ────────────────────────────────────────────────────────
/** /**
* Build a mutation event based on the command prefix and result. * Build a mutation event based on the command prefix and result.
*
* `sessionId` is empty until a future phase wires session correlation into
* the query layer; see QUERY-HANDLERS.md.
*/ */
function buildMutationEvent(cmd: string, args: string[], result: QueryResult): GSDEvent { function buildMutationEvent(cmd: string, args: string[], result: QueryResult): GSDEvent {
const base = { const base = {
@@ -118,14 +136,37 @@ function buildMutationEvent(cmd: string, args: string[], result: QueryResult): G
sessionId: '', sessionId: '',
}; };
if (cmd.startsWith('state.')) { if (cmd.startsWith('template.') || cmd.startsWith('template ')) {
const data = result.data as Record<string, unknown> | null;
return { return {
...base, ...base,
type: GSDEventType.StateMutation, type: GSDEventType.TemplateFill,
templateType: (data?.template as string) ?? args[0] ?? '',
path: (data?.path as string) ?? args[1] ?? '',
created: (data?.created as boolean) ?? false,
} as GSDTemplateFillEvent;
}
if (cmd === 'commit' || cmd === 'check-commit' || cmd === 'commit-to-subrepo') {
const data = result.data as Record<string, unknown> | null;
return {
...base,
type: GSDEventType.GitCommit,
hash: (data?.hash as string) ?? null,
committed: (data?.committed as boolean) ?? false,
reason: (data?.reason as string) ?? '',
} as GSDGitCommitEvent;
}
if (cmd.startsWith('frontmatter.') || cmd.startsWith('frontmatter ')) {
return {
...base,
type: GSDEventType.FrontmatterMutation,
command: cmd, command: cmd,
fields: args.slice(0, 2), file: args[0] ?? '',
fields: args.slice(1),
success: true, success: true,
} as GSDStateMutationEvent; } as GSDFrontmatterMutationEvent;
} }
if (cmd.startsWith('config-')) { if (cmd.startsWith('config-')) {
@@ -138,26 +179,14 @@ function buildMutationEvent(cmd: string, args: string[], result: QueryResult): G
} as GSDConfigMutationEvent; } as GSDConfigMutationEvent;
} }
if (cmd.startsWith('frontmatter.')) { if (cmd.startsWith('validate.') || cmd.startsWith('validate ')) {
return { return {
...base, ...base,
type: GSDEventType.FrontmatterMutation, type: GSDEventType.ConfigMutation,
command: cmd, command: cmd,
file: args[0] ?? '', key: args[0] ?? '',
fields: args.slice(1),
success: true, success: true,
} as GSDFrontmatterMutationEvent; } as GSDConfigMutationEvent;
}
if (cmd === 'commit' || cmd === 'check-commit') {
const data = result.data as Record<string, unknown> | null;
return {
...base,
type: GSDEventType.GitCommit,
hash: (data?.hash as string) ?? null,
committed: (data?.committed as boolean) ?? false,
reason: (data?.reason as string) ?? '',
} as GSDGitCommitEvent;
} }
if (cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ')) { if (cmd.startsWith('phase.') || cmd.startsWith('phase ') || cmd.startsWith('phases.') || cmd.startsWith('phases ')) {
@@ -170,25 +199,24 @@ function buildMutationEvent(cmd: string, args: string[], result: QueryResult): G
} as GSDStateMutationEvent; } as GSDStateMutationEvent;
} }
if (cmd.startsWith('validate.') || cmd.startsWith('validate ')) { if (cmd.startsWith('state.') || cmd.startsWith('state ')) {
return { return {
...base, ...base,
type: GSDEventType.ConfigMutation, type: GSDEventType.StateMutation,
command: cmd, command: cmd,
key: args[0] ?? '', fields: args.slice(0, 2),
success: true, success: true,
} as GSDConfigMutationEvent; } as GSDStateMutationEvent;
} }
// template.fill / template.select // roadmap, requirements, todo, milestone, workstream, intel, profile, learnings, docs-init
const data = result.data as Record<string, unknown> | null;
return { return {
...base, ...base,
type: GSDEventType.TemplateFill, type: GSDEventType.StateMutation,
templateType: (data?.template as string) ?? args[0] ?? '', command: cmd,
path: (data?.path as string) ?? args[1] ?? '', fields: args.slice(0, 2),
created: (data?.created as boolean) ?? false, success: true,
} as GSDTemplateFillEvent; } as GSDStateMutationEvent;
} }
// ─── Factory ─────────────────────────────────────────────────────────────── // ─── Factory ───────────────────────────────────────────────────────────────
@@ -408,7 +436,7 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
// Wire event emission for mutation commands // Wire event emission for mutation commands
if (eventStream) { if (eventStream) {
for (const cmd of MUTATION_COMMANDS) { for (const cmd of QUERY_MUTATION_COMMANDS) {
const original = registry.getHandler(cmd); const original = registry.getHandler(cmd);
if (original) { if (original) {
registry.register(cmd, async (args: string[], projectDir: string) => { registry.register(cmd, async (args: string[], projectDir: string) => {

View File

@@ -18,7 +18,7 @@
* ``` * ```
*/ */
import { existsSync, readdirSync, statSync } from 'node:fs'; import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
@@ -90,9 +90,9 @@ export const initNewProject: QueryHandler = async (_args, projectDir) => {
function findCodeFiles(dir: string, depth: number): boolean { function findCodeFiles(dir: string, depth: number): boolean {
if (depth > 3) return false; if (depth > 3) return false;
let entries: Array<{ isDirectory(): boolean; isFile(): boolean; name: string }>; let entries: Dirent[];
try { try {
entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; isFile(): boolean; name: string }>; entries = readdirSync(dir, { withFileTypes: true });
} catch { } catch {
return false; return false;
} }
@@ -202,7 +202,7 @@ export const initProgress: QueryHandler = async (_args, projectDir) => {
// Scan phase directories // Scan phase directories
try { try {
const entries = readdirSync(paths.phases, { withFileTypes: true }); const entries = readdirSync(paths.phases, { withFileTypes: true });
const dirs = (entries as unknown as Array<{ isDirectory(): boolean; name: string }>) const dirs = entries
.filter(e => e.isDirectory()) .filter(e => e.isDirectory())
.map(e => e.name) .map(e => e.name)
.sort((a, b) => { .sort((a, b) => {
@@ -339,7 +339,7 @@ export const initManager: QueryHandler = async (_args, projectDir) => {
// Pre-compute directory listing once // Pre-compute directory listing once
let phaseDirEntries: string[] = []; let phaseDirEntries: string[] = [];
try { try {
phaseDirEntries = (readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>) phaseDirEntries = readdirSync(paths.phases, { withFileTypes: true })
.filter(e => e.isDirectory()) .filter(e => e.isDirectory())
.map(e => e.name); .map(e => e.name);
} catch { /* intentionally empty */ } } catch { /* intentionally empty */ }

View File

@@ -17,7 +17,7 @@
* ``` * ```
*/ */
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises'; import { readFile, readdir } from 'node:fs/promises';
import { join, relative, basename } from 'node:path'; import { join, relative, basename } from 'node:path';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
@@ -830,9 +830,9 @@ export const initListWorkspaces: QueryHandler = async (_args, _projectDir) => {
const workspaces: Array<Record<string, unknown>> = []; const workspaces: Array<Record<string, unknown>> = [];
if (existsSync(defaultBase)) { if (existsSync(defaultBase)) {
let entries: Array<{ isDirectory(): boolean; name: string }> = []; let entries: Dirent[] = [];
try { try {
entries = readdirSync(defaultBase, { withFileTypes: true }) as unknown as typeof entries; entries = readdirSync(defaultBase, { withFileTypes: true });
} catch { entries = []; } } catch { entries = []; }
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;

View File

@@ -0,0 +1,90 @@
/**
* Tests for intel query handlers and JSON search helpers.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
searchJsonEntries,
MAX_JSON_SEARCH_DEPTH,
intelStatus,
intelSnapshot,
} from './intel.js';
describe('searchJsonEntries', () => {
it('finds matches in shallow objects', () => {
const data = { files: [{ name: 'AuthService' }, { name: 'Other' }] };
const found = searchJsonEntries(data, 'auth');
expect(found.length).toBeGreaterThan(0);
});
it('stops at max depth without throwing', () => {
let nested: Record<string, unknown> = { leaf: 'findme' };
for (let i = 0; i < MAX_JSON_SEARCH_DEPTH + 5; i++) {
nested = { inner: nested };
}
const found = searchJsonEntries({ root: nested }, 'findme');
expect(Array.isArray(found)).toBe(true);
});
});
describe('intelStatus', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-intel-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns disabled when intel.enabled is not true', async () => {
const r = await intelStatus([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.disabled).toBe(true);
});
it('returns file map when intel is enabled', async () => {
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', intel: { enabled: true } }),
);
const r = await intelStatus([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.disabled).not.toBe(true);
expect(data.files).toBeDefined();
});
});
describe('intelSnapshot', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-intel-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', intel: { enabled: true } }),
);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('writes .last-refresh.json when intel is enabled', async () => {
await mkdir(join(tmpDir, '.planning', 'intel'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'intel', 'stack.json'), JSON.stringify({ _meta: { updated_at: new Date().toISOString() } }));
const r = await intelSnapshot([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.saved).toBe(true);
const snap = await readFile(join(tmpDir, '.planning', 'intel', '.last-refresh.json'), 'utf-8');
expect(JSON.parse(snap)).toHaveProperty('hashes');
});
});

View File

@@ -74,27 +74,32 @@ function hashFile(filePath: string): string | null {
} }
} }
function searchJsonEntries(data: unknown, term: string): unknown[] { /** Max recursion depth when walking JSON for intel queries (avoids stack overflow). */
export const MAX_JSON_SEARCH_DEPTH = 48;
export function searchJsonEntries(data: unknown, term: string, depth = 0): unknown[] {
const lowerTerm = term.toLowerCase(); const lowerTerm = term.toLowerCase();
const results: unknown[] = []; const results: unknown[] = [];
if (depth > MAX_JSON_SEARCH_DEPTH) return results;
if (!data || typeof data !== 'object') return results; if (!data || typeof data !== 'object') return results;
function matchesInValue(value: unknown): boolean { function matchesInValue(value: unknown, d: number): boolean {
if (d > MAX_JSON_SEARCH_DEPTH) return false;
if (typeof value === 'string') return value.toLowerCase().includes(lowerTerm); if (typeof value === 'string') return value.toLowerCase().includes(lowerTerm);
if (Array.isArray(value)) return value.some(v => matchesInValue(v)); if (Array.isArray(value)) return value.some(v => matchesInValue(v, d + 1));
if (value && typeof value === 'object') return Object.values(value as object).some(v => matchesInValue(v)); if (value && typeof value === 'object') return Object.values(value as object).some(v => matchesInValue(v, d + 1));
return false; return false;
} }
if (Array.isArray(data)) { if (Array.isArray(data)) {
for (const entry of data) { for (const entry of data) {
if (matchesInValue(entry)) results.push(entry); if (matchesInValue(entry, depth + 1)) results.push(entry);
} }
} else { } else {
for (const [, value] of Object.entries(data as object)) { for (const [, value] of Object.entries(data as object)) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (const entry of value) { for (const entry of value) {
if (matchesInValue(entry)) results.push(entry); if (matchesInValue(entry, depth + 1)) results.push(entry);
} }
} }
} }

View File

@@ -45,6 +45,19 @@ function assertNoNullBytes(value: string, label: string): void {
} }
} }
/** Reject `..` or path separators in phase directory names. */
function assertSafePhaseDirName(dirName: string, label = 'phase directory'): void {
if (/[/\\]|\.\./.test(dirName)) {
throw new GSDError(`${label} contains invalid path segments`, ErrorClassification.Validation);
}
}
function assertSafeProjectCode(code: string): void {
if (code && /[/\\]|\.\./.test(code)) {
throw new GSDError('project_code contains invalid characters', ErrorClassification.Validation);
}
}
// ─── Slug generation (inline) ──────────────────────────────────────────── // ─── Slug generation (inline) ────────────────────────────────────────────
/** Generate kebab-case slug from description. Port of generateSlugInternal. */ /** Generate kebab-case slug from description. Port of generateSlugInternal. */
@@ -150,6 +163,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
// Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation') // Optional project code prefix (e.g., 'CK' -> 'CK-01-foundation')
const projectCode = (config.project_code as string) || ''; const projectCode = (config.project_code as string) || '';
assertSafeProjectCode(projectCode);
const prefix = projectCode ? `${projectCode}-` : ''; const prefix = projectCode ? `${projectCode}-` : '';
let newPhaseId: number | string = ''; let newPhaseId: number | string = '';
@@ -164,6 +178,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
if (!newPhaseId) { if (!newPhaseId) {
throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation); throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation);
} }
assertSafePhaseDirName(String(newPhaseId), 'custom phase id');
dirName = `${prefix}${newPhaseId}-${slug}`; dirName = `${prefix}${newPhaseId}-${slug}`;
} else { } else {
// Sequential mode: find highest integer phase number (in current milestone only) // Sequential mode: find highest integer phase number (in current milestone only)
@@ -182,6 +197,8 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
dirName = `${prefix}${paddedNum}-${slug}`; dirName = `${prefix}${paddedNum}-${slug}`;
} }
assertSafePhaseDirName(dirName);
const dirPath = join(planningPaths(projectDir).phases, dirName); const dirPath = join(planningPaths(projectDir).phases, dirName);
// Create directory with .gitkeep so git tracks empty folders // Create directory with .gitkeep so git tracks empty folders
@@ -293,8 +310,10 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => {
insertConfig = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8')); insertConfig = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8'));
} catch { /* use defaults */ } } catch { /* use defaults */ }
const projectCode = (insertConfig.project_code as string) || ''; const projectCode = (insertConfig.project_code as string) || '';
assertSafeProjectCode(projectCode);
const pfx = projectCode ? `${projectCode}-` : ''; const pfx = projectCode ? `${projectCode}-` : '';
dirName = `${pfx}${decimalPhase}-${slug}`; dirName = `${pfx}${decimalPhase}-${slug}`;
assertSafePhaseDirName(dirName);
const dirPath = join(phasesDir, dirName); const dirPath = join(phasesDir, dirName);
// Create directory with .gitkeep // Create directory with .gitkeep
@@ -421,6 +440,7 @@ export const phaseScaffold: QueryHandler = async (args, projectDir) => {
} }
const slug = generateSlugInternal(name); const slug = generateSlugInternal(name);
const dirNameNew = `${padded}-${slug}`; const dirNameNew = `${padded}-${slug}`;
assertSafePhaseDirName(dirNameNew, 'scaffold phase directory');
const phasesParent = planningPaths(projectDir).phases; const phasesParent = planningPaths(projectDir).phases;
await mkdir(phasesParent, { recursive: true }); await mkdir(phasesParent, { recursive: true });
const dirPath = join(phasesParent, dirNameNew); const dirPath = join(phasesParent, dirNameNew);

View File

@@ -55,11 +55,7 @@ export type PipelineStage = 'prepare' | 'execute' | 'finalize';
function collectFiles(dir: string, base: string): string[] { function collectFiles(dir: string, base: string): string[] {
const results: string[] = []; const results: string[] = [];
if (!existsSync(dir)) return results; if (!existsSync(dir)) return results;
const entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ const entries = readdirSync(dir, { withFileTypes: true });
isDirectory(): boolean;
isFile(): boolean;
name: string;
}>;
for (const entry of entries) { for (const entry of entries) {
const fullPath = join(dir, entry.name); const fullPath = join(dir, entry.name);
const relPath = relative(base, fullPath); const relPath = relative(base, fullPath);
@@ -159,8 +155,9 @@ export function wrapWithPipeline(
// as event emission wiring in index.ts // as event emission wiring in index.ts
const commandsToWrap: string[] = []; const commandsToWrap: string[] = [];
// We need to enumerate commands. QueryRegistry doesn't expose keys directly, // Enumerate mutation commands via the caller-provided set. QueryRegistry also
// so we wrap the register method temporarily to collect known commands, // exposes commands() for full command lists when needed by tooling.
// We wrap the register method temporarily to collect known commands,
// then restore. Instead, we use the mutation commands set + a marker approach: // then restore. Instead, we use the mutation commands set + a marker approach:
// wrap mutation commands for dry-run, and wrap all via onPrepare/onFinalize. // wrap mutation commands for dry-run, and wrap all via onPrepare/onFinalize.
// //

View File

@@ -0,0 +1,54 @@
/**
* Tests for profile / learnings query handlers (filesystem writes use temp dirs).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { writeProfile, learningsCopy } from './profile.js';
describe('writeProfile', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-profile-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('writes USER-PROFILE.md from --input JSON', async () => {
const analysisPath = join(tmpDir, 'analysis.json');
await writeFile(analysisPath, JSON.stringify({ communication_style: 'terse' }), 'utf-8');
const result = await writeProfile(['--input', analysisPath], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.written).toBe(true);
const md = await readFile(join(tmpDir, '.planning', 'USER-PROFILE.md'), 'utf-8');
expect(md).toContain('User Developer Profile');
expect(md).toMatch(/Communication Style/i);
});
});
describe('learningsCopy', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-learn-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns copied:false when LEARNINGS.md is missing', async () => {
const result = await learningsCopy([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.copied).toBe(false);
expect(data.reason).toContain('LEARNINGS');
});
});

View File

@@ -212,7 +212,7 @@ export const scanSessions: QueryHandler = async (_args, _projectDir) => {
let sessionCount = 0; let sessionCount = 0;
try { try {
const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true });
for (const pDir of projectDirs.filter(e => e.isDirectory())) { for (const pDir of projectDirs.filter(e => e.isDirectory())) {
const pPath = join(SESSIONS_DIR, pDir.name); const pPath = join(SESSIONS_DIR, pDir.name);
const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl')); const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl'));
@@ -232,7 +232,7 @@ export const profileSample: QueryHandler = async (_args, _projectDir) => {
let projectsSampled = 0; let projectsSampled = 0;
try { try {
const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true });
for (const pDir of projectDirs.filter(e => e.isDirectory()).slice(0, 5)) { for (const pDir of projectDirs.filter(e => e.isDirectory()).slice(0, 5)) {
const pPath = join(SESSIONS_DIR, pDir.name); const pPath = join(SESSIONS_DIR, pDir.name);
const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl')).slice(0, 3); const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl')).slice(0, 3);

View File

@@ -17,6 +17,7 @@
import { readFile, readdir } from 'node:fs/promises'; import { readFile, readdir } from 'node:fs/promises';
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { join, relative } from 'node:path'; import { join, relative } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { comparePhaseNum, normalizePhaseName, planningPaths, toPosixPath } from './helpers.js'; import { comparePhaseNum, normalizePhaseName, planningPaths, toPosixPath } from './helpers.js';
import { getMilestoneInfo, roadmapAnalyze } from './roadmap.js'; import { getMilestoneInfo, roadmapAnalyze } from './roadmap.js';
import type { QueryHandler } from './utils.js'; import type { QueryHandler } from './utils.js';
@@ -137,7 +138,7 @@ export const statsJson: QueryHandler = async (_args, projectDir) => {
if (existsSync(paths.phases)) { if (existsSync(paths.phases)) {
try { try {
const entries = readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const entries = readdirSync(paths.phases, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (!entry.isDirectory()) continue; if (!entry.isDirectory()) continue;
phasesTotal++; phasesTotal++;
@@ -242,10 +243,7 @@ export const listTodos: QueryHandler = async (args, projectDir) => {
export const todoComplete: QueryHandler = async (args, projectDir) => { export const todoComplete: QueryHandler = async (args, projectDir) => {
const filename = args[0]; const filename = args[0];
if (!filename) { if (!filename) {
throw new (await import('../errors.js')).GSDError( throw new GSDError('filename required for todo complete', ErrorClassification.Validation);
'filename required for todo complete',
(await import('../errors.js')).ErrorClassification.Validation,
);
} }
const pendingDir = join(projectDir, '.planning', 'todos', 'pending'); const pendingDir = join(projectDir, '.planning', 'todos', 'pending');
@@ -253,10 +251,7 @@ export const todoComplete: QueryHandler = async (args, projectDir) => {
const sourcePath = join(pendingDir, filename); const sourcePath = join(pendingDir, filename);
if (!existsSync(sourcePath)) { if (!existsSync(sourcePath)) {
throw new (await import('../errors.js')).GSDError( throw new GSDError(`Todo not found: ${filename}`, ErrorClassification.Validation);
`Todo not found: ${filename}`,
(await import('../errors.js')).ErrorClassification.Validation,
);
} }
mkdirSync(completedDir, { recursive: true }); mkdirSync(completedDir, { recursive: true });

View File

@@ -4,7 +4,7 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { QueryRegistry, extractField } from './registry.js'; import { QueryRegistry, extractField } from './registry.js';
import { createRegistry } from './index.js'; import { createRegistry, QUERY_MUTATION_COMMANDS } from './index.js';
import type { QueryResult } from './utils.js'; import type { QueryResult } from './utils.js';
// ─── extractField ────────────────────────────────────────────────────────── // ─── extractField ──────────────────────────────────────────────────────────
@@ -87,6 +87,26 @@ describe('QueryRegistry', () => {
await expect(registry.dispatch('unknown-cmd', ['arg1'], '/tmp/project')) await expect(registry.dispatch('unknown-cmd', ['arg1'], '/tmp/project'))
.rejects.toThrow('Unknown command: "unknown-cmd"'); .rejects.toThrow('Unknown command: "unknown-cmd"');
}); });
it('commands() returns all registered command names', () => {
const registry = new QueryRegistry();
registry.register('alpha', async () => ({ data: 1 }));
registry.register('beta', async () => ({ data: 2 }));
expect(registry.commands().sort()).toEqual(['alpha', 'beta']);
});
});
// ─── QUERY_MUTATION_COMMANDS vs registry ───────────────────────────────────
describe('QUERY_MUTATION_COMMANDS', () => {
it('has a registered handler for every mutation command name', () => {
const registry = createRegistry();
const missing: string[] = [];
for (const cmd of QUERY_MUTATION_COMMANDS) {
if (!registry.has(cmd)) missing.push(cmd);
}
expect(missing).toEqual([]);
});
}); });
// ─── createRegistry ──────────────────────────────────────────────────────── // ─── createRegistry ────────────────────────────────────────────────────────

View File

@@ -86,6 +86,13 @@ export class QueryRegistry {
return this.handlers.has(command); return this.handlers.has(command);
} }
/**
* List all registered command names (for tooling, pipelines, and tests).
*/
commands(): string[] {
return Array.from(this.handlers.keys());
}
/** /**
* Get the handler for a command without dispatching. * Get the handler for a command without dispatching.
* *

View File

@@ -0,0 +1,30 @@
/**
* Tests for agent skills query handler.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { agentSkills } from './skills.js';
describe('agentSkills', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-'));
await mkdir(join(tmpDir, '.cursor', 'skills', 'my-skill'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns deduped skill names from project skill dirs', async () => {
const r = await agentSkills(['gsd-executor'], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.skill_count).toBeGreaterThan(0);
expect((data.skills as string[]).length).toBeGreaterThan(0);
});
});

View File

@@ -33,7 +33,7 @@ export const agentSkills: QueryHandler = async (args, projectDir) => {
for (const dir of skillDirs) { for (const dir of skillDirs) {
if (!existsSync(dir)) continue; if (!existsSync(dir)) continue;
try { try {
const entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
if (entry.isDirectory()) skills.push(entry.name); if (entry.isDirectory()) skills.push(entry.name);
} }

View File

@@ -112,11 +112,32 @@ function updateCurrentPositionFields(content: string, fields: Record<string, str
// ─── Lockfile helpers ───────────────────────────────────────────────────── // ─── Lockfile helpers ─────────────────────────────────────────────────────
/**
* If the lock file contains a PID, return whether that process is gone (stolen
* locks after SIGKILL/crash). Null if the file could not be read.
*/
async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
try {
const raw = await readFile(lockPath, 'utf-8');
const pid = parseInt(raw.trim(), 10);
if (!Number.isFinite(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
} catch {
return null;
}
}
/** /**
* Acquire a lockfile for STATE.md operations. * Acquire a lockfile for STATE.md operations.
* *
* Uses O_CREAT|O_EXCL for atomic creation. Retries up to 10 times with * Uses O_CREAT|O_EXCL for atomic creation. Retries up to 10 times with
* 200ms + jitter delay. Cleans stale locks older than 10 seconds. * 200ms + jitter delay. Cleans stale locks when the holder PID is dead, or when
* the lock file is older than 10 seconds (existing heuristic).
* *
* @param statePath - Path to STATE.md * @param statePath - Path to STATE.md
* @returns Path to the lockfile * @returns Path to the lockfile
@@ -136,6 +157,11 @@ export async function acquireStateLock(statePath: string): Promise<string> {
} catch (err: unknown) { } catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') { if (err instanceof Error && (err as NodeJS.ErrnoException).code === 'EEXIST') {
try { try {
const dead = await isLockProcessDead(lockPath);
if (dead === true) {
await unlink(lockPath);
continue;
}
const s = await stat(lockPath); const s = await stat(lockPath);
if (Date.now() - s.mtimeMs > 10000) { if (Date.now() - s.mtimeMs > 10000) {
await unlink(lockPath); await unlink(lockPath);
@@ -714,22 +740,20 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir) => {
const phaseArg = args.find((a, i) => args[i - 1] === '--phase') || args[0]; const phaseArg = args.find((a, i) => args[i - 1] === '--phase') || args[0];
const nameArg = args.find((a, i) => args[i - 1] === '--name') || ''; const nameArg = args.find((a, i) => args[i - 1] === '--name') || '';
const plansArg = args.find((a, i) => args[i - 1] === '--plans') || '0'; const plansArg = args.find((a, i) => args[i - 1] === '--plans') || '0';
const paths = planningPaths(projectDir);
if (!phaseArg) { if (!phaseArg) {
return { data: { updated: false, reason: '--phase argument required' } }; return { data: { updated: false, reason: '--phase argument required' } };
} }
try { try {
let content = await readFile(paths.state, 'utf-8');
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const record = `\n**Planned Phase:** ${phaseArg} (${nameArg}) — ${plansArg} plans — ${timestamp}\n`; const record = `\n**Planned Phase:** ${phaseArg} (${nameArg}) — ${plansArg} plans — ${timestamp}\n`;
if (/\*\*Planned Phase:\*\*/.test(content)) { await readModifyWriteStateMd(projectDir, (body) => {
content = content.replace(/\*\*Planned Phase:\*\*[^\n]*\n/, record); if (/\*\*Planned Phase:\*\*/.test(body)) {
} else { return body.replace(/\*\*Planned Phase:\*\*[^\n]*\n/, record);
content += record; }
} return body + record;
await writeFile(paths.state, content, 'utf-8'); });
return { data: { updated: true, phase: phaseArg, name: nameArg, plans: plansArg } }; return { data: { updated: true, phase: phaseArg, name: nameArg, plans: plansArg } };
} catch { } catch {
return { data: { updated: false, reason: 'STATE.md not found or unreadable' } }; return { data: { updated: false, reason: 'STATE.md not found or unreadable' } };

View File

@@ -0,0 +1,55 @@
/**
* Tests for summary / history digest handlers.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { summaryExtract, historyDigest } from './summary.js';
describe('summaryExtract', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-sum-'));
await mkdir(join(tmpDir, '.planning', 'phases', '01-x'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('extracts headings from a summary file', async () => {
const rel = '.planning/phases/01-x/01-SUMMARY.md';
await writeFile(
join(tmpDir, '.planning', 'phases', '01-x', '01-SUMMARY.md'),
'# Summary\n\n## What Was Done\n\nBuilt the thing.\n\n## Tests\n\nUnit tests pass.\n',
'utf-8',
);
const r = await summaryExtract([rel], tmpDir);
const data = r.data as Record<string, Record<string, string>>;
expect(data.sections.what_was_done).toContain('Built');
});
});
describe('historyDigest', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-hist-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns digest object for project without phases', async () => {
const r = await historyDigest([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.phases).toBeDefined();
expect(data.decisions).toBeDefined();
});
});

View File

@@ -62,7 +62,7 @@ export const historyDigest: QueryHandler = async (_args, projectDir) => {
const milestonesDir = join(projectDir, '.planning', 'milestones'); const milestonesDir = join(projectDir, '.planning', 'milestones');
if (existsSync(milestonesDir)) { if (existsSync(milestonesDir)) {
try { try {
const milestoneEntries = readdirSync(milestonesDir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const milestoneEntries = readdirSync(milestonesDir, { withFileTypes: true });
const archivedPhaseDirs = milestoneEntries const archivedPhaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name)) .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name) .map(e => e.name)
@@ -70,7 +70,7 @@ export const historyDigest: QueryHandler = async (_args, projectDir) => {
for (const archiveName of archivedPhaseDirs) { for (const archiveName of archivedPhaseDirs) {
const archivePath = join(milestonesDir, archiveName); const archivePath = join(milestonesDir, archiveName);
try { try {
const dirs = readdirSync(archivePath, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const dirs = readdirSync(archivePath, { withFileTypes: true });
for (const d of dirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { for (const d of dirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
allPhaseDirs.push({ name: d.name, fullPath: join(archivePath, d.name) }); allPhaseDirs.push({ name: d.name, fullPath: join(archivePath, d.name) });
} }
@@ -82,7 +82,7 @@ export const historyDigest: QueryHandler = async (_args, projectDir) => {
// Current phases // Current phases
if (existsSync(paths.phases)) { if (existsSync(paths.phases)) {
try { try {
const currentDirs = readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const currentDirs = readdirSync(paths.phases, { withFileTypes: true });
for (const d of currentDirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) { for (const d of currentDirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
allPhaseDirs.push({ name: d.name, fullPath: join(paths.phases, d.name) }); allPhaseDirs.push({ name: d.name, fullPath: join(paths.phases, d.name) });
} }

73
sdk/src/query/uat.test.ts Normal file
View File

@@ -0,0 +1,73 @@
/**
* Tests for UAT query handlers.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { uatRenderCheckpoint, auditUat } from './uat.js';
const SAMPLE_UAT = `---
status: draft
---
# UAT
## Current Test
number: 1
name: Login flow
expected: |
User can sign in
## Other
`;
describe('uatRenderCheckpoint', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-uat-'));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns error when --file is missing', async () => {
const r = await uatRenderCheckpoint([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.error).toBeDefined();
});
it('renders checkpoint for valid UAT file', async () => {
const f = join(tmpDir, '01-UAT.md');
await writeFile(f, SAMPLE_UAT, 'utf-8');
const r = await uatRenderCheckpoint(['--file', '01-UAT.md'], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.checkpoint).toBeDefined();
expect(String(data.checkpoint)).toContain('CHECKPOINT');
expect(data.test_number).toBe(1);
});
});
describe('auditUat', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-uat-audit-'));
await mkdir(join(tmpDir, '.planning', 'phases', '01-x'), { recursive: true });
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns empty results when no UAT files', async () => {
const r = await auditUat([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(Array.isArray(data.results)).toBe(true);
expect((data.summary as Record<string, number>).total_files).toBe(0);
});
});

View File

@@ -142,7 +142,7 @@ export const auditUat: QueryHandler = async (_args, projectDir) => {
} }
const results: Record<string, unknown>[] = []; const results: Record<string, unknown>[] = [];
const entries = readdirSync(paths.phases, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const entries = readdirSync(paths.phases, { withFileTypes: true });
for (const entry of entries.filter(e => e.isDirectory())) { for (const entry of entries.filter(e => e.isDirectory())) {
const phaseMatch = entry.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i); const phaseMatch = entry.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);

View File

@@ -10,7 +10,21 @@ import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os'; import { tmpdir, homedir } from 'node:os';
import { GSDError } from '../errors.js'; import { GSDError } from '../errors.js';
import { verifyKeyLinks, validateConsistency, validateHealth } from './validate.js'; import { verifyKeyLinks, validateConsistency, validateHealth, regexForKeyLinkPattern } from './validate.js';
// ─── regexForKeyLinkPattern ────────────────────────────────────────────────
describe('regexForKeyLinkPattern', () => {
it('preserves normal regex patterns used in key_links', () => {
const re = regexForKeyLinkPattern('import.*foo.*from.*target');
expect(re.test("import { foo } from './target.js';")).toBe(true);
});
it('falls back to literal match for nested-quantifier patterns', () => {
const re = regexForKeyLinkPattern('(a+)+');
expect(re.source).toContain('\\');
});
});
// ─── verifyKeyLinks ──────────────────────────────────────────────────────── // ─── verifyKeyLinks ────────────────────────────────────────────────────────
@@ -198,7 +212,7 @@ must_haves:
expect(links[0].detail).toBe('Target referenced in source'); expect(links[0].detail).toBe('Target referenced in source');
}); });
it('returns Invalid regex pattern for bad regex', async () => { it('falls back to literal match when regex syntax is invalid', async () => {
await writeFile(join(tmpDir, 'source.ts'), 'const x = 1;'); await writeFile(join(tmpDir, 'source.ts'), 'const x = 1;');
await writeFile(join(tmpDir, 'target.ts'), 'const y = 2;'); await writeFile(join(tmpDir, 'target.ts'), 'const y = 2;');
@@ -227,7 +241,7 @@ must_haves:
const data = result.data as Record<string, unknown>; const data = result.data as Record<string, unknown>;
const links = data.links as Array<Record<string, unknown>>; const links = data.links as Array<Record<string, unknown>>;
expect(links[0].verified).toBe(false); expect(links[0].verified).toBe(false);
expect((links[0].detail as string).startsWith('Invalid regex pattern')).toBe(true); expect((links[0].detail as string)).toContain('not found');
}); });
it('returns error when no must_haves.key_links in plan', async () => { it('returns error when no must_haves.key_links in plan', async () => {

View File

@@ -16,13 +16,38 @@
import { readFile, readdir, writeFile } from 'node:fs/promises'; import { readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join, isAbsolute, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js'; import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js'; import { extractFrontmatter, parseMustHavesBlock } from './frontmatter.js';
import { escapeRegex, normalizePhaseName, planningPaths } from './helpers.js'; import { escapeRegex, normalizePhaseName, planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js'; import type { QueryHandler } from './utils.js';
/** Max length for key_links regex patterns (ReDoS mitigation). */
const MAX_KEY_LINK_PATTERN_LEN = 512;
/**
* Build a RegExp for must_haves key_links pattern matching.
* Long or nested-quantifier patterns fall back to a literal match via escapeRegex.
*/
export function regexForKeyLinkPattern(pattern: string): RegExp {
if (typeof pattern !== 'string' || pattern.length === 0) {
return /$^/;
}
if (pattern.length > MAX_KEY_LINK_PATTERN_LEN) {
return new RegExp(escapeRegex(pattern.slice(0, MAX_KEY_LINK_PATTERN_LEN)));
}
// Mitigate catastrophic backtracking on nested quantifier forms
if (/\([^)]*[\+\*][^)]*\)[\+\*]/.test(pattern)) {
return new RegExp(escapeRegex(pattern));
}
try {
return new RegExp(pattern);
} catch {
return new RegExp(escapeRegex(pattern));
}
}
// ─── verifyKeyLinks ─────────────────────────────────────────────────────── // ─── verifyKeyLinks ───────────────────────────────────────────────────────
/** /**
@@ -48,7 +73,15 @@ export const verifyKeyLinks: QueryHandler = async (args, projectDir) => {
throw new GSDError('file path contains null bytes', ErrorClassification.Validation); throw new GSDError('file path contains null bytes', ErrorClassification.Validation);
} }
const fullPath = isAbsolute(planFilePath) ? planFilePath : join(projectDir, planFilePath); let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, planFilePath);
} catch (err) {
if (err instanceof GSDError) {
return { data: { error: err.message, path: planFilePath } };
}
throw err;
}
let content: string; let content: string;
try { try {
@@ -77,37 +110,33 @@ export const verifyKeyLinks: QueryHandler = async (args, projectDir) => {
let sourceContent: string | null = null; let sourceContent: string | null = null;
try { try {
sourceContent = await readFile(join(projectDir, check.from), 'utf-8'); const fromPath = await resolvePathUnderProject(projectDir, check.from);
sourceContent = await readFile(fromPath, 'utf-8');
} catch { } catch {
// Source file not found // Source file not found or path invalid
} }
if (!sourceContent) { if (!sourceContent) {
check.detail = 'Source file not found'; check.detail = 'Source file not found';
} else if (linkObj.pattern) { } else if (linkObj.pattern) {
// T-12-05: Wrap new RegExp in try/catch const regex = regexForKeyLinkPattern(linkObj.pattern as string);
try { if (regex.test(sourceContent)) {
const regex = new RegExp(linkObj.pattern as string); check.verified = true;
if (regex.test(sourceContent)) { check.detail = 'Pattern found in source';
check.verified = true; } else {
check.detail = 'Pattern found in source'; let targetContent: string | null = null;
} else { try {
// Try target file const toPath = await resolvePathUnderProject(projectDir, check.to);
let targetContent: string | null = null; targetContent = await readFile(toPath, 'utf-8');
try { } catch {
targetContent = await readFile(join(projectDir, check.to), 'utf-8'); // Target file not found
} catch { }
// Target file not found if (targetContent && regex.test(targetContent)) {
} check.verified = true;
if (targetContent && regex.test(targetContent)) { check.detail = 'Pattern found in target';
check.verified = true; } else {
check.detail = 'Pattern found in target'; check.detail = `Pattern "${linkObj.pattern}" not found in source or target`;
} else {
check.detail = `Pattern "${linkObj.pattern}" not found in source or target`;
}
} }
} catch {
check.detail = `Invalid regex pattern: ${linkObj.pattern}`;
} }
} else { } else {
// No pattern: check if target path is referenced in source content // No pattern: check if target path is referenced in source content

View File

@@ -558,7 +558,7 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir) => {
return { data: { valid: true, issues: [], checked: 0 } }; return { data: { valid: true, issues: [], checked: 0 } };
} }
const entries = readdirSync(phasesDir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const entries = readdirSync(phasesDir, { withFileTypes: true });
let checked = 0; let checked = 0;
for (const entry of entries) { for (const entry of entries) {

View File

@@ -0,0 +1,31 @@
/**
* Tests for websearch handler (no network when API key unset).
*/
import { describe, it, expect } from 'vitest';
import { websearch } from './websearch.js';
describe('websearch', () => {
it('returns available:false when BRAVE_API_KEY is not set', async () => {
const prev = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
const r = await websearch(['test query'], '/tmp');
const data = r.data as Record<string, unknown>;
expect(data.available).toBe(false);
if (prev !== undefined) process.env.BRAVE_API_KEY = prev;
});
it('returns error when query is missing and BRAVE_API_KEY is set', async () => {
const prev = process.env.BRAVE_API_KEY;
process.env.BRAVE_API_KEY = 'test-dummy-key';
try {
const r = await websearch([], '/tmp');
const data = r.data as Record<string, unknown>;
expect(data.available).toBe(false);
expect(data.error).toBe('Query required');
} finally {
if (prev !== undefined) process.env.BRAVE_API_KEY = prev;
else delete process.env.BRAVE_API_KEY;
}
});
});

View File

@@ -0,0 +1,51 @@
/**
* Tests for workstream query handlers.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { workstreamList, workstreamCreate } from './workstream.js';
describe('workstreamList', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-ws-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns flat mode when no workstreams directory', async () => {
const r = await workstreamList([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.mode).toBe('flat');
expect(Array.isArray(data.workstreams)).toBe(true);
});
});
describe('workstreamCreate', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-ws2-'));
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'balanced' }));
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('creates workstream directory tree', async () => {
const r = await workstreamCreate(['test-ws'], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.created).toBe(true);
});
});

View File

@@ -71,7 +71,7 @@ export const workstreamList: QueryHandler = async (_args, projectDir) => {
const dir = workstreamsDir(projectDir); const dir = workstreamsDir(projectDir);
if (!existsSync(dir)) return { data: { mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' } }; if (!existsSync(dir)) return { data: { mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' } };
try { try {
const entries = readdirSync(dir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const entries = readdirSync(dir, { withFileTypes: true });
const workstreams = entries.filter(e => e.isDirectory()).map(e => e.name); const workstreams = entries.filter(e => e.isDirectory()).map(e => e.name);
return { data: { mode: 'workstream', workstreams, count: workstreams.length } }; return { data: { mode: 'workstream', workstreams, count: workstreams.length } };
} catch { } catch {
@@ -212,7 +212,7 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
const filesMoved: string[] = []; const filesMoved: string[] = [];
try { try {
const entries = readdirSync(wsDir, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>; const entries = readdirSync(wsDir, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {
renameSync(join(wsDir, entry.name), join(archivePath, entry.name)); renameSync(join(wsDir, entry.name), join(archivePath, entry.name));
filesMoved.push(entry.name); filesMoved.push(entry.name);
@@ -230,7 +230,7 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
let remainingWs = 0; let remainingWs = 0;
try { try {
remainingWs = (readdirSync(wsRoot, { withFileTypes: true }) as unknown as Array<{ isDirectory(): boolean; name: string }>) remainingWs = readdirSync(wsRoot, { withFileTypes: true })
.filter(e => e.isDirectory()).length; .filter(e => e.isDirectory()).length;
if (remainingWs === 0) rmdirSync(wsRoot); if (remainingWs === 0) rmdirSync(wsRoot);
} catch { /* best-effort */ } } catch { /* best-effort */ }