Test gap detection, coverage analysis, and automated test generation — drives the testgaps background worker via hooks_worker-dispatch; SPARC Refinement-phase…

Test gap detection, coverage analysis, and automated test generation. SPARC Refinement-phase canonical owner.
/plugin marketplace add ruvnet/ruflo
/plugin install ruflo-testgen@ruflo
ruflo-core plugin (provides MCP server)@claude-flow/cli v3.6 major+minor.bash plugins/ruflo-testgen/scripts/smoke.sh is the contract.This plugin's two MCP/CLI surfaces:
| Surface | Invocation |
|---|---|
MCP: dispatch the testgaps worker | mcp tool call hooks_worker-dispatch --json -- '{"trigger":"testgaps"}' |
CLI: coverage-gaps (table of gaps) | npx @claude-flow/cli@latest hooks coverage-gaps --format table --limit 20 |
CLI: coverage-route (route a task by gap) | npx @claude-flow/cli@latest hooks coverage-route --task "add auth tests" |
CLI: coverage-suggest (suggest tests for a path) | npx @claude-flow/cli@latest hooks coverage-suggest --path src/ |
testgaps is one of 12 background workers documented in ruflo-loop-workers ADR-0001.
This plugin owns the Refinement phase per ruflo-sparc ADR-0001 §"Phase-to-plugin alignment". When SPARC's sparc-refine skill runs, it composes:
Together they enforce the Refinement gate: ≥80% coverage on new code + diff risk score below threshold.
This plugin owns the test-gaps AgentDB namespace (kebab-case, follows the convention from ruflo-agentdb ADR-0001 §"Namespace convention"). Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.
test-gaps indexes detected gaps by file + priority + last-seen timestamp. Accessed via memory_* (namespace-routed).
bash plugins/ruflo-testgen/scripts/smoke.sh
# Expected: "10 passed, 0 failed"
ruflo-loop-workers — defines the testgaps background workerruflo-sparc — Refinement-phase canonical handoffruflo-jujutsu — diff-aware refactor companion in the Refinement gateruflo-agentdb — namespace convention ownerThis plugin also loads as a function-hook mod (ADR-445 pattern, hooks/hooks.json → register.ts). No network, no process spawn, no model call.
hooks_worker-dispatch and coverage calls made this session. It is observe-only: testgen has no harmful input to guard, so it never denies and has nothing to configure..claude-flow/testgen-mod/status.json ({version: 1, updatedMs, guard, checked, blocked, seen}), written at session start and when a counter changes./testgen-mod answers locally: status, workers. (The plugin's own commands are prompt commands, which a hook cannot answer, so the mod has its own name.)Test it: claude plugin validate plugins/ruflo-testgen, claude plugin test plugins/ruflo-testgen, bash plugins/ruflo-testgen/scripts/smoke.sh.
hooks/register.ts 61 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { verdict, watched } from './guard'
5import { readOptions, type ModOptions } from './options'
6import { newStats, STATUS_PATH, statusText, type Stats } from './status'
7
8type Dollar = Parameters<Hook<'session.start'>>[0]
9
10/** Everything one session of the mod keeps: its settings, its counters and the project root. */
11type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
12
13async function flush($: Dollar, s: Session): Promise<void> {
14 if (s.root === undefined) return
15 try {
16 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, s.opts, await $.clock.now()))
17 } catch {
18 /* the status file is a courtesy */
19 }
20}
21
22/**
23 * Test generation as a mod (ADR-445 pattern): a status file and `/testgen-mod` that count the worker dispatches and coverage calls this session. Observe-only: testgen has no harmful input in its domain, so there is no guard and nothing to configure. No network, no process: only tools already connected.
24 */
25export const register: Register = (on, options) => {
26 const s: Session = { opts: readOptions(options), stats: newStats() }
27
28 on('session.start', async ($, e, next) => {
29 const result = await next(e)
30 s.root = (await $.session.root()) as string | undefined
31 try {
32 await $.command.register({ name: 'testgen-mod', description: 'Testgen mod: status, workers' })
33 } catch {
34 /* a name taken by another plugin must not stop the mod */
35 }
36 await flush($, s)
37 return result
38 })
39
40 // Tighten-only: a deny, or the event unchanged. Only this plugin's own tools are looked at.
41 on('tool.call', async ($, e, next) => {
42 const label = watched(e.tool, e)
43 if (label === undefined) return next(e)
44 s.stats.checked++
45 s.stats.seen[label] = (s.stats.seen[label] ?? 0) + 1
46 const reason = s.opts.guard ? verdict(e.tool, e) : undefined
47 if (reason !== undefined) {
48 s.stats.blocked++
49 s.stats.lastReason = reason.slice(0, 160)
50 }
51 await flush($, s)
52 return reason === undefined ? next(e) : { deny: reason }
53 })
54
55 /** `/testgen-mod` (the plugin's own commands are prompt commands, which no hook can answer). */
56 on('command.run', { command: 'testgen-mod' }, async ($, e) => {
57 const args = typeof e.args === 'string' ? e.args : ''
58 return { text: await answer(args, { opts: s.opts, stats: s.stats, tools: async () => (await $.tool.list()).map(t => t.name) }) }
59 })
60}
61hooks/command.ts 28 lines1import type { ModOptions } from './options'
2import type { Stats } from './status'
3
4/** `/testgen-mod` is answered locally and takes no model turn. */
5export type CommandDeps = { readonly opts: ModOptions; readonly stats: Stats; readonly tools: () => Promise<readonly string[]> }
6
7const HELP = ['/testgen-mod status', '/testgen-mod workers'].join('\n')
8
9export async function answer(args: string, deps: CommandDeps): Promise<string> {
10 const [verb = '', ...rest] = args.trim().split(/\s+/)
11 const arg = rest.join(' ')
12 const { opts, stats } = deps
13
14 if (verb === '' || verb === 'help') return HELP
15
16 if (verb === 'status') {
17 const seen = Object.entries(stats.seen).map(([k, n]) => `${k} ${n}`).join(' · ')
18 return [`observe-only · checked ${stats.checked}`, seen === '' ? 'no testgen calls this session' : seen].join('\n')
19 }
20
21 if (verb === 'workers') {
22 const n = stats.seen['worker dispatch'] ?? 0
23 return n === 0 ? 'No worker has been dispatched this session. The testgaps worker runs through hooks_worker-dispatch.' : `${n} worker dispatch${n === 1 ? '' : 'es'} this session.`
24 }
25
26 return `Unknown: ${verb}\n${HELP}`
27}
28hooks/guard.ts 25 lines1/** The tool's short name: `mcp__<server>__<tool>` to `<tool>`. */
2export const shortName = (name: string) => (name.startsWith('mcp__') && name.lastIndexOf('__') > 5 ? name.slice(name.lastIndexOf('__') + 2) : name)
3
4const field = (input: unknown, key: string): string => {
5 const v = typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[key] : undefined
6 return typeof v === 'string' ? v : ''
7}
8
9const WATCH: Readonly<Record<string, string>> = {
10 'hooks_worker-dispatch': 'worker dispatch',
11 'hooks_coverage-gaps': 'coverage gaps',
12 'hooks_coverage-route': 'coverage route',
13 'hooks_coverage-suggest': 'coverage suggest',
14}
15
16/** The label of a testgen-related call, else undefined. Observed only: nothing here ever denies. */
17export function watched(tool: string, _input: unknown): string | undefined {
18 return WATCH[shortName(tool)]
19}
20
21/** Never refuses: testgen is observe-only. */
22export function verdict(_tool: string, _input: unknown): string | undefined {
23 return undefined
24}
25hooks/options.ts 14 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (guard on). */
4export type ModOptions = { readonly guard: boolean }
5
6// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
7export const flag = (value: unknown, fallback: boolean) =>
8 value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
9// END SHARED FLAG
10
11export function readOptions(options: PluginOptions | undefined): ModOptions {
12 return { guard: flag((options ?? {}).guard, true) }
13}
14hooks/status.ts 12 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/testgen-mod/status.json` for the console. */
2export type Stats = { checked: number; blocked: number; seen: Record<string, number>; lastReason?: string }
3
4export const newStats = (): Stats => ({ checked: 0, blocked: 0, seen: {} })
5
6export const STATUS_PATH = '.claude-flow/testgen-mod/status.json'
7
8/** The file's text; `version` lets the console refuse a shape it does not know. */
9export function statusText(stats: Stats, mode: { guard: boolean }, nowMs: number): string {
10 return `${JSON.stringify({ version: 1, updatedMs: nowMs, guard: mode.guard, ...stats }, null, 2)}\n`
11}
12