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
9 changes: 9 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,12 @@ phases (PR #13). Each call below is an _agent decision_.
to `RUFF_RECOMMENDED`/the init scaffold, because recommending the key inside a
ruff table would break ruff. Reversible — a future ruff C0302 (or a TOML
parser) could relocate the threshold.

- **Sensor failures travel on a `SensorSink`.** _(agent decision, #25)_ A
spawn/timeout failure now **fails the run (exit 1)** instead of being a
false-clean. Rather than thread two parallel arrays, sensors share a
`SensorSink { notices; failures }` (`src/wrap/notices.ts`); `failures` records
any sensor that could not run, and `run()` forces exit 1 when it is non-empty
while every successful sensor's output still renders. The failure message stays
in `notices` too, so display is unchanged. Reversible — the sink could collapse
back to a bare notices array if the policy were reverted.
8 changes: 6 additions & 2 deletions docs/sensors.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ structure suits them.
Sensors are **additive** (each appends to the bag) and **deterministic**
(detection is mechanical, no judgement).

A sensor must never throw on tool spawn/timeout failure: failures surface as
a stderr notice and zero issues, never a lost run.
A sensor must never throw on tool spawn/timeout failure. Instead the failure
surfaces as a stderr notice with zero issues for that tool, and **fails the run
(exit 1)** — a broken tool is a failed run, not a false-clean. Every other
sensor that ran successfully still contributes its full output; only the overall
exit code reflects the failure (independent of whether any violations were
found).

## Sensor runner

Expand Down
3 changes: 2 additions & 1 deletion src/checks/eslint-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
firstLine,
isSpawnFailure,
noticesFor,
spawnFailureOutcome,
spawnFailureWarning,
type BinResolution,
} from '../wrap/notices.js';
Expand Down Expand Up @@ -134,7 +135,7 @@ async function runEslint(resolution: BinResolution, cwd: string, files: string[]
const notices = noticesFor('eslint', resolution, cwd);
const result = await executeEslint(resolution, cwd, files);
const parsed = tryParseJson(result.stdout);
if (isSpawnFailure(result)) return emptyOutcome([...notices, spawnFailureWarning('eslint', cwd, result.warnings)]);
if (isSpawnFailure(result)) return spawnFailureOutcome(notices, spawnFailureWarning('eslint', cwd, result.warnings));
if (isConfigError(result, parsed)) return emptyOutcome([...notices, ...failureNotices(cwd, result)]);
return { violations: parseEslintJson(result.stdout), stderr: notices };
}
Expand Down
4 changes: 2 additions & 2 deletions src/checks/jscpd-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { type ShellResult } from '../wrap/shell.js';
import { detectTool } from '../detect/tool.js';
import { hasPackageJsonKey } from '../detect/package-json.js';
import { absolutize, emptyOutcome, firstLine, noticesFor, type BinResolution } from '../wrap/notices.js';
import { isSpawnSkip, spawnWrapped } from '../wrap/run.js';
import { isSpawnSkip, skipOutcome, spawnWrapped } from '../wrap/run.js';
import type { Check, CheckOutcome, Violation } from '../types.js';

const require = createRequire(import.meta.url);
Expand Down Expand Up @@ -151,7 +151,7 @@ function missingReportOutcome(inputs: RunInputs, result: ShellResult): CheckOutc
async function runOnce(inputs: RunInputs, reportDir: string): Promise<CheckOutcome> {
const { resolution, cwd } = inputs;
const result = await spawnWrapped({ tool: 'jscpd', resolution, cwd, args: buildArgs(reportDir) });
if (isSpawnSkip(result)) return emptyOutcome([...inputs.notices, result.skipWarning]);
if (isSpawnSkip(result)) return skipOutcome(result, inputs.notices);
const report = tryReadReport(reportDir);
if (report === null) return missingReportOutcome(inputs, result);
return { violations: reportToViolations(report, inputs.scope, inputs.cwd), stderr: inputs.notices };
Expand Down
4 changes: 2 additions & 2 deletions src/checks/knip-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { hasPackageJsonKey } from '../detect/package-json.js';
import { absolutize, emptyOutcome, firstLine, noticesFor, type BinResolution } from '../wrap/notices.js';
import { isSpawnSkip, parseJsonStdout, spawnWrapped } from '../wrap/run.js';
import { isSpawnSkip, parseJsonStdout, skipOutcome, spawnWrapped } from '../wrap/run.js';
import { buildKnipArgs, consumerKnipMajor, resolveKnipBin } from './knip-resolve.js';
import {
KNOWN_KEYS,
Expand Down Expand Up @@ -149,7 +149,7 @@ function hasKnipConfig(cwd: string): boolean {

async function runKnip(resolution: BinResolution, cwd: string, notices: string[]): Promise<CheckOutcome> {
const result = await spawnWrapped({ tool: 'knip', resolution, cwd, args: buildKnipArgs(resolution, cwd) });
if (isSpawnSkip(result)) return emptyOutcome([...notices, result.skipWarning]);
if (isSpawnSkip(result)) return skipOutcome(result, notices);
const report = parseJsonStdout<KnipReport>(result.stdout, '{');
if (report === null) return emptyOutcome([...notices, exitFailureWarning(cwd, result.exitCode, result.stderr)]);
return { violations: reportToViolations(report, cwd), stderr: notices };
Expand Down
29 changes: 28 additions & 1 deletion src/runner.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from 'vitest';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { run } from './runner.js';
import { lastCommitHash } from './baseline/file-hash.js';
Expand Down Expand Up @@ -170,6 +170,33 @@ describe('runner.run with scope', () => {
});
});

describe('runner.run sensor failure', () => {
let dir: string;

afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
});

// A directory where the eslint bin is expected forces a spawn failure (EACCES)
// without depending on the host toolchain.
function breakEslintBin(cwd: string): void {
mkdirSync(join(cwd, 'node_modules', '.bin', 'eslint'), { recursive: true });
}

it('fails the run (exit 1) on a sensor spawn failure, still rendering other sensors', async () => {
dir = mkdtempSync(join(tmpdir(), 'hh-sensorfail-'));
writeFileSync(join(dir, 'eslint.config.js'), 'export default [];\n');
writeFileSync(join(dir, 'app.ts'), '// a flagged explanatory comment\nexport const a = 1;\n');
breakEslintBin(dir);

const result = await run(dir, { applyBaseline: false });

expect(result.exitCode).toBe(1);
expect(result.stderr.join('\n')).toContain('eslint');
expect(result.stdout).toContain('app.ts');
});
});

describe('runner.run with baseline', () => {
let repo: GitRepo;

Expand Down
24 changes: 13 additions & 11 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { buildPresetSensors, issueToViolation, violationToIssue } from './sensor
import { buildPythonPresetSensors } from './sensors/python-preset.js';
import { mapIssues, type MapperDirs, type RoutingLookup } from './mapper/mapper.js';
import { guide } from './guide/guide.js';
import type { SensorSink } from './wrap/notices.js';
import type { Sensor } from './sensors/types.js';
import type { HabitHooksConfig, Language } from './config/schema.js';
import type { Rule, Violation } from './types.js';
Expand Down Expand Up @@ -111,21 +112,21 @@ function sensorActive(sensor: Sensor, rulesById: Map<string, Rule>, ctx: RunCont
});
}

function presetSensors(ctx: RunContext, rulesById: Map<string, Rule>, sink: SensorSink): Sensor[] {
if (ctx.language === 'python') return buildPythonPresetSensors({ sink, cwd: ctx.cwd });
return buildPresetSensors({ sink, commentRule: rulesById.get(COMMENT_SMELL) });
}

// Active sensors detect over the full discovered file set and their issues are
// merged; rule-scoped file filtering is applied afterwards so the sensor stage
// stays a pure smell detector (docs/sensors.md).
function presetSensors(ctx: RunContext, rulesById: Map<string, Rule>, notices: string[]): Sensor[] {
if (ctx.language === 'python') return buildPythonPresetSensors({ notices, cwd: ctx.cwd });
return buildPresetSensors({ notices, commentRule: rulesById.get(COMMENT_SMELL) });
}

async function detect(ctx: RunContext, rules: Rule[]): Promise<{ violations: Violation[]; notices: string[] }> {
const notices: string[] = [];
async function detect(ctx: RunContext, rules: Rule[]): Promise<{ violations: Violation[]; sink: SensorSink }> {
const sink: SensorSink = { notices: [], failures: [] };
const rulesById = new Map(rules.map((r) => [r.id, r] as const));
const all = presetSensors(ctx, rulesById, notices);
const all = presetSensors(ctx, rulesById, sink);
const active = all.filter((sensor) => sensorActive(sensor, rulesById, ctx));
const issues = await new SensorRunner(active).run({ files: ctx.files, cwd: ctx.cwd });
return { violations: issues.map(issueToViolation), notices };
return { violations: issues.map(issueToViolation), sink };
}

// Keep a violation when its smell has no rule (uncoached), or its file is not a
Expand Down Expand Up @@ -164,6 +165,7 @@ export async function run(cwd: string, options: RunOptions = {}): Promise<RunRes
const dirs: MapperDirs = { overrideDir: ctx.promptsDir, packagedDir: resolvePackagedDir() };
const mapped = mapIssues(violations.map(violationToIssue), buildRouting(rules), dirs);
const rendered = await guide({ result: mapped, dirs, cwd });
const stderr = [...ctx.configWarnings, ...detected.notices];
return { stdout: rendered.stdout, exitCode: rendered.exitCode, violations, stderr, scopeMode: ctx.scope.mode };
const exitCode = detected.sink.failures.length > 0 ? 1 : rendered.exitCode;
const stderr = [...ctx.configWarnings, ...detected.sink.notices];
return { stdout: rendered.stdout, exitCode, violations, stderr, scopeMode: ctx.scope.mode };
}
15 changes: 8 additions & 7 deletions src/sensors/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { runTool } from '../wrap/shell.js';
import { isSpawnFailure } from '../wrap/notices.js';
import { isSpawnFailure, recordSpawnFailure, spawnFailureWarning, type SensorSink } from '../wrap/notices.js';
import type { Issue, Sensor, SensorContext } from './types.js';

// The declarative adapter (docs/sensors.md): when a tool already emits JSON, a
Expand Down Expand Up @@ -86,19 +86,20 @@ function parseJson(stdout: string): Json {
}
}

async function runDeclarative(spec: DeclarativeSensorSpec, ctx: SensorContext, notices: string[]): Promise<Issue[]> {
async function runDeclarative(spec: DeclarativeSensorSpec, ctx: SensorContext, sink: SensorSink): Promise<Issue[]> {
if (ctx.files.length === 0) return [];
const { bin, args } = buildArgv(spec.command, ctx.files);
const result = await runTool({ bin, args, cwd: ctx.cwd });
if (isSpawnFailure(result)) {
notices.push(`habit-hooks: ${spec.id} skipped in ${ctx.cwd} (${result.warnings[0] ?? 'spawn failure'})`);
recordSpawnFailure(sink, spawnFailureWarning(spec.id, ctx.cwd, result.warnings));
return [];
}
return extractIssues(parseJson(result.stdout), spec);
}

// Wrap a JSON-emitting tool as a leaf sensor via the declarative spec. Spawn
// failures surface as a stderr notice and zero issues, never a lost run.
export function declarativeSensor(spec: DeclarativeSensorSpec, notices: string[]): Sensor {
return { id: spec.id, produces: spec.produces, run: (ctx) => runDeclarative(spec, ctx, notices) };
// Wrap a JSON-emitting tool as a leaf sensor via the declarative spec. A spawn
// or timeout failure fails the run (docs/sensors.md): the message is shown and
// recorded as a failure, with zero issues for that tool.
export function declarativeSensor(spec: DeclarativeSensorSpec, sink: SensorSink): Sensor {
return { id: spec.id, produces: spec.produces, run: (ctx) => runDeclarative(spec, ctx, sink) };
}
14 changes: 7 additions & 7 deletions src/sensors/deptry-sensor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runTool } from '../wrap/shell.js';
import { isSpawnFailure } from '../wrap/notices.js';
import { isSpawnFailure, recordSpawnFailure, spawnFailureWarning, type SensorSink } from '../wrap/notices.js';
import { extractIssues, type AdapterSpec } from './adapter.js';
import type { Issue, Sensor } from './types.js';

Expand All @@ -27,25 +27,25 @@ function parseReport(path: string): Issue[] {
}
}

async function runReport(cwd: string, out: string, notices: string[]): Promise<boolean> {
async function runReport(cwd: string, out: string, sink: SensorSink): Promise<boolean> {
const result = await runTool({ bin: 'deptry', args: ['.', '--json-output', out], cwd });
if (isSpawnFailure(result)) {
notices.push(`habit-hooks: deptry skipped in ${cwd} (${result.warnings[0] ?? 'spawn failure'})`);
recordSpawnFailure(sink, spawnFailureWarning('deptry', cwd, result.warnings));
return false;
}
return true;
}

async function runDeptry(cwd: string, notices: string[]): Promise<Issue[]> {
async function runDeptry(cwd: string, sink: SensorSink): Promise<Issue[]> {
const dir = mkdtempSync(join(tmpdir(), 'hh-deptry-'));
try {
const out = join(dir, 'deptry.json');
return (await runReport(cwd, out, notices)) ? parseReport(out) : [];
return (await runReport(cwd, out, sink)) ? parseReport(out) : [];
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

export function deptrySensor(notices: string[]): Sensor {
return { id: 'deptry', produces: ['unused-dependency'], run: (ctx) => runDeptry(ctx.cwd, notices) };
export function deptrySensor(sink: SensorSink): Sensor {
return { id: 'deptry', produces: ['unused-dependency'], run: (ctx) => runDeptry(ctx.cwd, sink) };
}
2 changes: 1 addition & 1 deletion src/sensors/preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe('issueToViolation', () => {

describe('buildPresetSensors', () => {
it('registers the four TS preset sensors with their smell keys', () => {
const sensors = buildPresetSensors({ notices: [] });
const sensors = buildPresetSensors({ sink: { notices: [], failures: [] } });
expect(sensors.map((s) => s.id)).toEqual(['eslint', 'comment', 'jscpd', 'knip']);
const eslint = sensors.find((s) => s.id === 'eslint');
expect(eslint?.produces).toContain('too-many-parameters');
Expand Down
18 changes: 10 additions & 8 deletions src/sensors/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { eslintWrap } from '../checks/eslint-wrap.js';
import { commentCheck } from '../checks/comment-check.js';
import { jscpdWrap } from '../checks/jscpd-wrap.js';
import { knipWrap } from '../checks/knip-wrap.js';
import type { SensorSink } from '../wrap/notices.js';
import type { Check, CheckOutcome, Rule, Violation } from '../types.js';
import type { Issue, Sensor } from './types.js';

Expand Down Expand Up @@ -47,7 +48,7 @@ function normalizeOutcome(result: Violation[] | CheckOutcome): CheckOutcome {
export interface LeafSpec {
check: Check;
produces: string[];
notices: string[];
sink: SensorSink;
rules?: Rule[];
}

Expand All @@ -57,31 +58,32 @@ export function checkLeafSensor(spec: LeafSpec): Sensor {
produces: spec.produces,
async run(ctx) {
const outcome = normalizeOutcome(await spec.check.run(ctx.files, spec.rules ?? [], ctx.cwd));
if (outcome.stderr) spec.notices.push(...outcome.stderr);
if (outcome.stderr) spec.sink.notices.push(...outcome.stderr);
if (outcome.failures) spec.sink.failures.push(...outcome.failures);
return outcome.violations.map(violationToIssue);
},
};
}

export interface PresetInput {
notices: string[];
sink: SensorSink;
commentRule?: Rule;
}

// commentRule carries the resolved comment thresholds the ts-morph scan needs.
function commentSensor(input: PresetInput): Sensor {
const rules = input.commentRule ? [input.commentRule] : [];
return checkLeafSensor({ check: commentCheck, produces: ['non-essential-comment'], notices: input.notices, rules });
return checkLeafSensor({ check: commentCheck, produces: ['non-essential-comment'], sink: input.sink, rules });
}

// The TypeScript/JavaScript preset: four leaf sensors over eslint, ts-morph
// comments, jscpd, and knip.
export function buildPresetSensors(input: PresetInput): Sensor[] {
const { notices } = input;
const { sink } = input;
return [
checkLeafSensor({ check: eslintWrap, produces: ESLINT_PRODUCES, notices }),
checkLeafSensor({ check: eslintWrap, produces: ESLINT_PRODUCES, sink }),
commentSensor(input),
checkLeafSensor({ check: jscpdWrap, produces: ['duplicated-code'], notices }),
checkLeafSensor({ check: knipWrap, produces: KNIP_PRODUCES, notices }),
checkLeafSensor({ check: jscpdWrap, produces: ['duplicated-code'], sink }),
checkLeafSensor({ check: knipWrap, produces: KNIP_PRODUCES, sink }),
];
}
13 changes: 8 additions & 5 deletions src/sensors/python-preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ describe('python preset', () => {
});

it('registers ruff, jscpd, deptry, and line-count sensors with their smell keys', () => {
const sensors = buildPythonPresetSensors({ notices: [], cwd: dir });
const sensors = buildPythonPresetSensors({ sink: { notices: [], failures: [] }, cwd: dir });
expect(sensors.map((s) => s.id)).toEqual(['ruff', 'jscpd', 'deptry', 'line-count']);
expect(sensors[0]?.produces).toContain('too-many-parameters');
expect(sensors[2]?.produces).toEqual(['unused-dependency']);
Expand All @@ -35,7 +35,7 @@ describe('python preset', () => {
it.skipIf(!RUFF_AVAILABLE)('runs ruff and maps PLR0913/F841 to canonical smells with provenance', async () => {
const file = join(dir, 'sample.py');
writeFileSync(file, SAMPLE);
const ruff = buildPythonPresetSensors({ notices: [], cwd: dir })[0];
const ruff = buildPythonPresetSensors({ sink: { notices: [], failures: [] }, cwd: dir })[0];
if (ruff === undefined) throw new Error('expected ruff sensor');

const issues = await ruff.run({ files: [file], cwd: dir, deps: [] });
Expand All @@ -48,15 +48,18 @@ describe('python preset', () => {
expect(params?.details.file).toBe(file);
}, 30_000);

it('emits a stderr notice and zero issues when ruff is not on PATH', async () => {
const notices: string[] = [];
const ruff = buildPythonPresetSensors({ notices, cwd: dir })[0];
it('records a failure and a notice (zero issues) when ruff cannot spawn', async () => {
const sink = { notices: [] as string[], failures: [] as string[] };
const ruff = buildPythonPresetSensors({ sink, cwd: dir })[0];
if (ruff === undefined) throw new Error('expected ruff sensor');
const file = join(dir, 'a.py');
writeFileSync(file, 'x = 1\n');

const issues = await ruff.run({ files: [file], cwd: '/nonexistent-path-xyz', deps: [] });

expect(issues).toEqual([]);
expect(sink.failures).toHaveLength(1);
expect(sink.failures[0]).toContain('ruff');
expect(sink.notices).toContain(sink.failures[0]);
}, 30_000);
});
11 changes: 6 additions & 5 deletions src/sensors/python-preset.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 { jscpdWrap } from '../checks/jscpd-wrap.js';
import { TOOL_CONFIG_FILENAMES } from '../detect/tool.js';
import type { SensorSink } from '../wrap/notices.js';
import { declarativeSensor, type DeclarativeSensorSpec } from './adapter.js';
import { checkLeafSensor } from './preset.js';
import { deptrySensor } from './deptry-sensor.js';
Expand All @@ -28,7 +29,7 @@ const RUFF_SPEC: DeclarativeSensorSpec = {
};

export interface PythonPresetInput {
notices: string[];
sink: SensorSink;
cwd: string;
}

Expand All @@ -48,11 +49,11 @@ function readTextOrEmpty(path: string): string {
}

export function buildPythonPresetSensors(input: PythonPresetInput): Sensor[] {
const { notices, cwd } = input;
const { sink, cwd } = input;
return [
declarativeSensor(RUFF_SPEC, notices),
checkLeafSensor({ check: jscpdWrap, produces: ['duplicated-code'], notices }),
deptrySensor(notices),
declarativeSensor(RUFF_SPEC, sink),
checkLeafSensor({ check: jscpdWrap, produces: ['duplicated-code'], sink }),
deptrySensor(sink),
lineCountSensor(readMaxModuleLines(cwd)),
];
}
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export interface Violation {
export interface CheckOutcome {
violations: Violation[];
stderr?: string[];
// Spawn/timeout failure messages: a sensor that could not run at all. These
// fail the run (exit 1), distinct from benign stderr notices.
failures?: string[];
}

export interface Check {
Expand Down
Loading