Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/guide/command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runFixCommand } from './command.js';
import type { GuideAction } from '../mapper/mapper.js';

function action(severity: 'enforced' | 'suggested'): GuideAction {
return {
smell: 'broken-config',
severity,
title: 'Broken config',
description: '',
issues: [{ smell: 'broken-config', details: { file: '/a.ts', line: 1, message: 'x' } }],
action: { kind: 'command', scriptPath: '' },
};
}

describe('runFixCommand', () => {
let dir: string;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'hh-command-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

function script(body: string): string {
const path = join(dir, 'fixer');
writeFileSync(path, body);
chmodSync(path, 0o755);
return path;
}

it('passes the smell bag as JSON on stdin and shows the script output to the agent', async () => {
const path = script('#!/usr/bin/env bash\ncat\n');
const run = await runFixCommand(action('enforced'), path, dir);
expect(run.output).toContain('broken-config');
expect(run.output).toContain('/a.ts');
});

it('does not block when the script exits 0, even for an enforced smell', async () => {
const path = script('#!/usr/bin/env bash\nexit 0\n');
const run = await runFixCommand(action('enforced'), path, dir);
expect(run.blocks).toBe(false);
});

it('blocks an enforced smell when the script exits non-zero', async () => {
const path = script('#!/usr/bin/env bash\nexit 3\n');
const run = await runFixCommand(action('enforced'), path, dir);
expect(run.blocks).toBe(true);
});

it('does not block a suggested smell when the script exits non-zero', async () => {
const path = script('#!/usr/bin/env bash\nexit 3\n');
const run = await runFixCommand(action('suggested'), path, dir);
expect(run.blocks).toBe(false);
});

it('hard-fails (blocks) with a notice when the script cannot spawn', async () => {
const run = await runFixCommand(action('suggested'), join(dir, 'does-not-exist'), dir);
expect(run.blocks).toBe(true);
expect(run.output).toContain('could not run');
});
});
31 changes: 31 additions & 0 deletions src/guide/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { runTool } from '../wrap/shell.js';
import { isSpawnFailure } from '../wrap/notices.js';
import type { GuideAction } from '../mapper/mapper.js';

// Running a `command` fix: the script receives the smell's whole bag as JSON on
// stdin and runs once. A spawn/timeout failure is a blocking config error (it
// always blocks); a clean non-zero exit blocks only an `enforced` smell; exit 0
// means handled and never blocks. The script's output is shown to the agent.
export interface CommandRun {
output: string;
blocks: boolean;
}

function combinedOutput(stdout: string, stderr: string): string {
return [stdout, stderr]
.map((stream) => stream.trimEnd())
.filter((stream) => stream.length > 0)
.join('\n');
}

export async function runFixCommand(action: GuideAction, scriptPath: string, cwd: string): Promise<CommandRun> {
const result = await runTool({ bin: scriptPath, args: [], cwd, stdin: JSON.stringify(action.issues) });
if (isSpawnFailure(result)) {
const detail = result.warnings.length > 0 ? result.warnings.join('; ') : 'spawn failure';
return { output: `❌ fix command for ${action.smell} could not run: ${detail}`, blocks: true };
}
return {
output: combinedOutput(result.stdout, result.stderr),
blocks: result.exitCode !== 0 && action.severity === 'enforced',
};
}
76 changes: 55 additions & 21 deletions src/guide/guide.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { guide } from './guide.js';
Expand Down Expand Up @@ -57,19 +57,25 @@ describe('guide', () => {
return path;
}

function run(result: MapResult): { stdout: string; exitCode: number } {
return guide({ result, dirs: { packagedDir: dir } });
function script(name: string, body: string): string {
const path = template(name, body);
chmodSync(path, 0o755);
return path;
}

function run(result: MapResult): Promise<{ stdout: string; exitCode: number }> {
return guide({ result, dirs: { packagedDir: dir }, cwd: dir });
}

it('prints the clean banner and exit 0 when there is nothing to coach', () => {
const out = run({ actions: [], uncoached: [] });
it('prints the clean banner and exit 0 when there is nothing to coach', async () => {
const out = await run({ actions: [], uncoached: [] });
expect(out.exitCode).toBe(0);
expect(out.stdout).toContain('automated checks passed.');
expect(out.stdout).toContain('reviewer sub-agent');
expect(out.stdout.endsWith('\n\n')).toBe(true);
});

it('composes a section: title, prose (not the description), and the default issue list', () => {
it('composes a section: title, prose (not the description), and the default issue list', async () => {
const path = template('too-many-parameters.md', 'Prose for {{ smell }}.');
const action = promptAction({
smell: 'too-many-parameters',
Expand All @@ -80,7 +86,7 @@ describe('guide', () => {
description: 'Functions with many parameters violate single responsibility.',
});

const out = run({ actions: [action], uncoached: [] });
const out = await run({ actions: [action], uncoached: [] });

expect(out.stdout).toContain('Habit Hooks: 1 violation');
expect(out.stdout).toContain('❌ Too many parameters');
Expand All @@ -93,8 +99,8 @@ describe('guide', () => {
expect(out.exitCode).toBe(1);
});

it('falls back to the description as the body for a promptless command action', () => {
const path = template('fix-it.sh', '#!/bin/sh\n');
it('runs a command fix, streams its output to the agent, and exit 0 does not block an enforced smell', async () => {
const path = script('broken-config', '#!/usr/bin/env bash\necho "ran the fixer"\nexit 0\n');
const action = commandAction({
smell: 'broken-config',
severity: 'enforced',
Expand All @@ -104,15 +110,43 @@ describe('guide', () => {
description: 'Run the fixer script to repair the config.',
});

const out = run({ actions: [action], uncoached: [] });
const out = await run({ actions: [action], uncoached: [] });

expect(out.stdout).toContain('❌ Broken config');
expect(out.stdout).toContain('Run the fixer script to repair the config.');
expect(out.stdout).toContain('/a.ts:2 - issue at 2');
expect(out.stdout).toContain('ran the fixer');
expect(out.exitCode).toBe(0);
});

it('lets a non-zero command fix block an enforced smell (exit 1)', async () => {
const path = script('broken-config', '#!/usr/bin/env bash\nexit 1\n');
const action = commandAction({
smell: 'broken-config',
severity: 'enforced',
path,
issues: [issue('broken-config', '/a.ts', 2)],
});

expect((await run({ actions: [action], uncoached: [] })).exitCode).toBe(1);
});

it('hard-fails the run (exit 1) with a notice when the fix command cannot spawn', async () => {
const action = commandAction({
smell: 'broken-config',
severity: 'suggested',
path: join(dir, 'missing-script'),
issues: [issue('broken-config', '/a.ts', 2)],
});

const out = await run({ actions: [action], uncoached: [] });

expect(out.exitCode).toBe(1);
expect(out.stdout).toContain('could not run');
});

it('emits no stray blank line when the body is empty', () => {
const path = template('empty-body.sh', '#!/bin/sh\n');
it('emits no stray blank line when the body is empty', async () => {
const path = script('empty-body', '#!/usr/bin/env bash\nexit 0\n');
const action = commandAction({
smell: 'empty-body',
severity: 'enforced',
Expand All @@ -122,13 +156,13 @@ describe('guide', () => {
description: '',
});

const out = run({ actions: [action], uncoached: [] });
const out = await run({ actions: [action], uncoached: [] });

expect(out.stdout).toContain('❌ Empty body\n\nViolations:');
expect(out.stdout).not.toContain('❌ Empty body\n\n\n');
});

it('separates top-level sections with three blank lines', () => {
it('separates top-level sections with three blank lines', async () => {
const path = template('first.md', 'First prose.');
const second = template('second.md', 'Second prose.');
const actionA = promptAction({
Expand All @@ -146,23 +180,23 @@ describe('guide', () => {
title: 'Second',
});

const out = run({ actions: [actionA, actionB], uncoached: [] });
const out = await run({ actions: [actionA, actionB], uncoached: [] });

expect(out.stdout).toContain('/a.ts:1 - issue at 1\n\n\n\n❌ Second');
});

it('exits 0 when only suggested smells fired', () => {
it('exits 0 when only suggested smells fired', async () => {
const path = template('warning-comment.md', 'prose');
const action = promptAction({
smell: 'warning-comment',
severity: 'suggested',
path,
issues: [issue('warning-comment', '/a.ts', 2)],
});
expect(run({ actions: [action], uncoached: [] }).exitCode).toBe(0);
expect((await run({ actions: [action], uncoached: [] })).exitCode).toBe(0);
});

it('uses a per-smell <smell>.issues.njk to group issues by file', () => {
it('uses a per-smell <smell>.issues.njk to group issues by file', async () => {
const path = template('oversized-function.md', 'prose');
template(
'oversized-function.issues.njk',
Expand All @@ -175,18 +209,18 @@ describe('guide', () => {
];
const action = promptAction({ smell: 'oversized-function', severity: 'enforced', path, issues });

const out = run({ actions: [action], uncoached: [] });
const out = await run({ actions: [action], uncoached: [] });

expect(out.stdout).toContain('/a.ts: 2');
expect(out.stdout).toContain('/b.ts: 1');
expect(out.stdout).not.toContain('Violations:');
});

it('lists the uncoached bucket with provenance and does not escalate the exit code', () => {
it('lists the uncoached bucket with provenance and does not escalate the exit code', async () => {
const uncoached: Issue[] = [
{ smell: 'no-console', details: { file: '/a.ts', line: 3, message: 'x', source: 'eslint:no-console' } },
];
const out = run({ actions: [], uncoached });
const out = await run({ actions: [], uncoached });
expect(out.stdout).toContain('Uncoached smells');
expect(out.stdout).toContain('eslint:no-console');
expect(out.stdout).toContain('/a.ts:3');
Expand Down
46 changes: 34 additions & 12 deletions src/guide/guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Environment } from 'nunjucks';
import { createEnv, renderTemplate } from './render.js';
import { runFixCommand, type CommandRun } from './command.js';
import type { GuideAction, MapperDirs, MapResult } from '../mapper/mapper.js';
import type { Issue } from '../sensors/types.js';

Expand All @@ -21,6 +22,12 @@ export interface GuideResult {
export interface GuideInput {
result: MapResult;
dirs: MapperDirs;
cwd: string;
}

interface PreparedAction {
action: GuideAction;
command: CommandRun | null;
}

const CLEAN_BANNER =
Expand Down Expand Up @@ -56,11 +63,13 @@ function renderProse(env: Environment, action: GuideAction): string {
return renderTemplate(env, action.action.templatePath, { smell: action.smell, issues: action.issues });
}

function composeSection(env: Environment, action: GuideAction, dirs: MapperDirs): string {
function composeSection(env: Environment, prepared: PreparedAction, dirs: MapperDirs): string {
const { action, command } = prepared;
const prose = renderProse(env, action);
const body = prose.length > 0 ? prose : action.description;
const head = [`❌ ${action.title}`, body].filter((part) => part.length > 0).join('\n\n');
return `${head}\n\n${renderIssues(env, action, dirs)}`;
const base = `${head}\n\n${renderIssues(env, action, dirs)}`;
return command !== null && command.output.length > 0 ? `${base}\n\n${command.output}` : base;
}

function totalIssues(result: MapResult): number {
Expand All @@ -81,19 +90,32 @@ function renderUncoached(issues: Issue[]): string {
return `⚠️ Uncoached smells\n\n${issues.map(uncoachedLine).join('\n')}`;
}

function exitFor(actions: GuideAction[]): 0 | 1 {
return actions.some((a) => a.severity === 'enforced' && a.issues.length > 0) ? 1 : 0;
function actionBlocks(prepared: PreparedAction): boolean {
const { action, command } = prepared;
if (command !== null) return command.blocks;
return action.severity === 'enforced' && action.issues.length > 0;
}

function exitFor(prepared: PreparedAction[]): 0 | 1 {
return prepared.some(actionBlocks) ? 1 : 0;
}

function prepareAction(action: GuideAction, cwd: string): Promise<PreparedAction> {
const fix = action.action;
if (fix.kind !== 'command' || action.issues.length === 0) {
return Promise.resolve({ action, command: null });
}
return runFixCommand(action, fix.scriptPath, cwd).then((command) => ({ action, command }));
}

export function guide(input: GuideInput): GuideResult {
const { result, dirs } = input;
export async function guide(input: GuideInput): Promise<GuideResult> {
const { result, dirs, cwd } = input;
const total = totalIssues(result);
if (total === 0) return { stdout: `${CLEAN_BANNER}\n\n`, exitCode: 0 };
const env = createEnv(searchPathsFor(dirs));
const sections = [
header(total),
...result.actions.map((action) => composeSection(env, action, dirs)),
renderUncoached(result.uncoached),
];
return { stdout: `${sections.filter((s) => s.length > 0).join('\n\n\n\n')}\n\n`, exitCode: exitFor(result.actions) };
const prepared = await Promise.all(result.actions.map((action) => prepareAction(action, cwd)));
const bodies = prepared.map((p) => composeSection(env, p, dirs));
const sections = [header(total), ...bodies, renderUncoached(result.uncoached)];
const stdout = `${sections.filter((s) => s.length > 0).join('\n\n\n\n')}\n\n`;
return { stdout, exitCode: exitFor(prepared) };
}
2 changes: 1 addition & 1 deletion src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ export async function run(cwd: string, options: RunOptions = {}): Promise<RunRes
const violations = filterViolations(detected.violations, rules, ctx);
const dirs: MapperDirs = { overrideDir: ctx.promptsDir, packagedDir: resolvePackagedDir() };
const mapped = mapIssues(violations.map(violationToIssue), buildRouting(rules), dirs);
const rendered = guide({ result: mapped, dirs });
const rendered = await guide({ result: mapped, dirs, cwd });
const stderr = [...ctx.configWarnings, ...detected.notices];
return { stdout: rendered.stdout, exitCode: rendered.exitCode, violations, stderr };
}
11 changes: 9 additions & 2 deletions src/wrap/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface RunToolOptions {
args: string[];
cwd: string;
timeoutMs?: number;
stdin?: string;
}

interface StreamBuffers {
Expand All @@ -38,6 +39,12 @@ function collectStreams(child: ChildProcessWithoutNullStreams): StreamBuffers {
return buffers;
}

function writeStdin(child: ChildProcessWithoutNullStreams, stdin: string | undefined): void {
if (stdin === undefined) return;
child.stdin.on('error', () => undefined);
child.stdin.end(stdin);
}

function settleOnce(settler: Settler, result: ShellResult): void {
if (settler.done.value) return;
settler.done.value = true;
Expand Down Expand Up @@ -71,9 +78,9 @@ export function runTool(opts: RunToolOptions): Promise<ShellResult> {
return new Promise((resolve) => {
const child = spawn(opts.bin, opts.args, { cwd: opts.cwd });
const buffers = collectStreams(child);
const done = { value: false };
writeStdin(child, opts.stdin);
const timer = startKillTimer(child, () => settleOnce(settler, emptyFailure(`${opts.bin} timed out after ${timeoutMs}ms`)), timeoutMs);
const settler: Settler = { resolve, done, clearTimer: () => clearTimeout(timer) };
const settler: Settler = { resolve, done: { value: false }, clearTimer: () => clearTimeout(timer) };
child.on('error', (e) => settleOnce(settler, emptyFailure(`${opts.bin} failed to spawn: ${e.message}`)));
child.on('close', (code, signal) => settleOnce(settler, closeResult({ buffers, bin: opts.bin, code, signal })));
});
Expand Down