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
14 changes: 14 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,17 @@ phases (PR #13). Each call below is an _agent decision_.
snoozed file look falsely clean, and shares one reaper (`reapBaseline`) with
manual `baseline prune`. The pruned set is printed so the mutation is never
silent. Reversible — auto-prune could move into `run()` or behind a flag.

- **Python `oversized-file` via a line-count leaf sensor, threshold text-scanned
from config.** _(agent decision, #19)_ ruff 0.15 has no `C0302` port and
**rejects an unknown `max-module-lines` key under `[tool.ruff]`** (verified:
`unknown field max-module-lines`), so the locked decision's "ruff-config-style
key in the consumer's ruff config" cannot live inside `[tool.ruff]`. The
language-agnostic `lineCountSensor` emits `oversized-file` for files over the
threshold; the threshold is read by a no-TOML-parser **text scan** for
`max-module-lines = N` across the consumer's ruff config + `pyproject.toml`
(default 200, matching the TS `max-lines`). Consumers set it in a ruff-ignored
location such as `[tool.habit-hooks]` in `pyproject.toml`. It is **not** added
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.
5 changes: 4 additions & 1 deletion docs/smell-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,12 @@ to (the rest of the catalogue is shared — only the sensor layer differs).
| `ruff:F401` | `unused-import` |
| `jscpd:duplication` | `duplicated-code` |
| `deptry:DEP002` | `unused-dependency` |
| `line-count:max-module-lines` | `oversized-file` |

TS-only smells (`explicit-any`, `var-declaration`, …) simply do not appear in
the Python preset. `oversized-file` has no clean ruff rule and is deferred (see
the Python preset. `oversized-file` has no clean ruff rule, so the Python preset
emits it from a language-agnostic line-count sensor whose threshold
(`max-module-lines`, default 200) is read from the consumer's config text (see
`DECISIONS.md`).

## Uncoached smells
Expand Down
40 changes: 39 additions & 1 deletion src/python-acceptance.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { run } from './runner.js';
Expand Down Expand Up @@ -48,3 +50,39 @@ describe.skipIf(!PY_TOOLS)('acceptance: python preset on python-project fixture'
expect((await run(pythonProject)).exitCode).toBe(1);
}, 30_000);
});

// oversized-file is a pure line count (no ruff rule), so this runs without the
// Python toolchain — the ruff/deptry sensors simply find nothing.
describe('python oversized-file (line-count sensor)', () => {
let dir: string;

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

function pythonProjectAt(maxModuleLines?: number): void {
dir = mkdtempSync(join(tmpdir(), 'hh-py-oversized-'));
writeFileSync(join(dir, 'habit-hooks.config.json'), JSON.stringify({ language: 'python' }));
if (maxModuleLines !== undefined) {
writeFileSync(join(dir, 'pyproject.toml'), `[tool.habit-hooks]\nmax-module-lines = ${String(maxModuleLines)}\n`);
}
}

it('fires for a .py file over the configured max-module-lines threshold', async () => {
pythonProjectAt(5);
writeFileSync(join(dir, 'big.py'), `${Array.from({ length: 12 }, (_, i) => `a${String(i)} = ${String(i)}`).join('\n')}\n`);

const result = await run(dir);

expect(result.violations.some((v) => v.ruleId === 'oversized-file')).toBe(true);
}, 30_000);

it('does not fire for a small .py file at the default threshold', async () => {
pythonProjectAt();
writeFileSync(join(dir, 'small.py'), 'a = 1\nb = 2\n');

const result = await run(dir);

expect(result.violations.some((v) => v.ruleId === 'oversized-file')).toBe(false);
}, 30_000);
});
2 changes: 1 addition & 1 deletion src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function sensorActive(sensor: Sensor, rulesById: Map<string, Rule>, ctx: RunCont
// 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 });
if (ctx.language === 'python') return buildPythonPresetSensors({ notices, cwd: ctx.cwd });
return buildPresetSensors({ notices, commentRule: rulesById.get(COMMENT_SMELL) });
}

Expand Down
41 changes: 41 additions & 0 deletions src/sensors/line-count-sensor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { lineCountSensor } from './line-count-sensor.js';

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

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

function writeLines(name: string, lines: number): string {
const path = join(dir, name);
writeFileSync(path, `${Array.from({ length: lines }, (_, i) => `a${String(i)} = ${String(i)}`).join('\n')}\n`);
return path;
}

it('emits oversized-file only for files over the threshold', async () => {
const big = writeLines('big.py', 10);
const small = writeLines('small.py', 3);

const issues = await lineCountSensor(5).run({ files: [big, small], cwd: dir, deps: [] });

expect(issues).toHaveLength(1);
expect(issues[0]?.smell).toBe('oversized-file');
expect(issues[0]?.details.file).toBe(big);
expect(issues[0]?.details.message).toContain('10 lines');
});

it('counts physical lines and ignores a trailing newline (boundary is strictly over)', async () => {
const file = writeLines('exact.py', 5);

expect(await lineCountSensor(5).run({ files: [file], cwd: dir, deps: [] })).toEqual([]);
expect(await lineCountSensor(4).run({ files: [file], cwd: dir, deps: [] })).toHaveLength(1);
});
});
53 changes: 53 additions & 0 deletions src/sensors/line-count-sensor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs';
import type { Issue, Sensor } from './types.js';

// Default file-length ceiling, matching the TS `max-lines` (200).
export const DEFAULT_MAX_FILE_LINES = 200;

function lineCount(content: string): number {
if (content.length === 0) return 0;
const body = content.endsWith('\n') ? content.slice(0, -1) : content;
return body.split('\n').length;
}

function readLineCount(file: string): number | null {
try {
return lineCount(readFileSync(file, 'utf8'));
} catch {
return null;
}
}

function oversizedIssue(file: string, count: number, maxLines: number): Issue {
return {
smell: 'oversized-file',
details: {
file,
line: maxLines + 1,
column: 1,
message: `File has ${String(count)} lines; the maximum is ${String(maxLines)}.`,
source: 'line-count:max-module-lines',
},
};
}

function oversizedIssues(files: string[], maxLines: number): Issue[] {
const issues: Issue[] = [];
for (const file of files) {
const count = readLineCount(file);
if (count !== null && count > maxLines) issues.push(oversizedIssue(file, count, maxLines));
}
return issues;
}

// A language-agnostic leaf sensor that emits `oversized-file` for any discovered
// file whose physical line count exceeds the threshold. `oversized-file` is a
// pure line count and needs no AST, so this covers languages (Python) that have
// no tool rule for it.
export function lineCountSensor(maxLines: number): Sensor {
return {
id: 'line-count',
produces: ['oversized-file'],
run: (ctx) => Promise.resolve(oversizedIssues(ctx.files, maxLines)),
};
}
11 changes: 6 additions & 5 deletions src/sensors/python-preset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,18 @@ describe('python preset', () => {
rmSync(dir, { recursive: true, force: true });
});

it('registers ruff, jscpd, and deptry sensors with their smell keys', () => {
const sensors = buildPythonPresetSensors({ notices: [] });
expect(sensors.map((s) => s.id)).toEqual(['ruff', 'jscpd', 'deptry']);
it('registers ruff, jscpd, deptry, and line-count sensors with their smell keys', () => {
const sensors = buildPythonPresetSensors({ notices: [], 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']);
expect(sensors[3]?.produces).toEqual(['oversized-file']);
});

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: [] })[0];
const ruff = buildPythonPresetSensors({ notices: [], cwd: dir })[0];
if (ruff === undefined) throw new Error('expected ruff sensor');

const issues = await ruff.run({ files: [file], cwd: dir, deps: [] });
Expand All @@ -49,7 +50,7 @@ describe('python preset', () => {

it('emits a stderr notice and zero issues when ruff is not on PATH', async () => {
const notices: string[] = [];
const ruff = buildPythonPresetSensors({ notices })[0];
const ruff = buildPythonPresetSensors({ notices, cwd: dir })[0];
if (ruff === undefined) throw new Error('expected ruff sensor');
const file = join(dir, 'a.py');
writeFileSync(file, 'x = 1\n');
Expand Down
23 changes: 22 additions & 1 deletion src/sensors/python-preset.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
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 { declarativeSensor, type DeclarativeSensorSpec } from './adapter.js';
import { checkLeafSensor } from './preset.js';
import { deptrySensor } from './deptry-sensor.js';
import { DEFAULT_MAX_FILE_LINES, lineCountSensor } from './line-count-sensor.js';
import type { Sensor } from './types.js';

// The Python preset: ruff (declarative adapter) + jscpd on .py + deptry
Expand All @@ -25,13 +29,30 @@ const RUFF_SPEC: DeclarativeSensorSpec = {

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

// `oversized-file` has no ruff rule (ruff 0.15 has no C0302 port and rejects an
// unknown `max-module-lines` key under `[tool.ruff]`), so the threshold is read
// from the consumer's config text by the same no-TOML-parser approach the init
// drift-check uses; defaults to 200, matching the TS `max-lines`.
function readMaxModuleLines(cwd: string): number {
const sources = [...TOOL_CONFIG_FILENAMES.ruff, 'pyproject.toml'];
const text = sources.map((name) => readTextOrEmpty(join(cwd, name))).join('\n');
const match = text.match(/max-module-lines\s*=\s*(\d+)/);
return match ? Number(match[1]) : DEFAULT_MAX_FILE_LINES;
}

function readTextOrEmpty(path: string): string {
return existsSync(path) ? readFileSync(path, 'utf8') : '';
}

export function buildPythonPresetSensors(input: PythonPresetInput): Sensor[] {
const { notices } = input;
const { notices, cwd } = input;
return [
declarativeSensor(RUFF_SPEC, notices),
checkLeafSensor({ check: jscpdWrap, produces: ['duplicated-code'], notices }),
deptrySensor(notices),
lineCountSensor(readMaxModuleLines(cwd)),
];
}