Multi-language Habit Hooks: sensor→mapper→guide, Python preset, and language-aware init#13
Merged
Merged
Conversation
Replace tool-prefixed routing keys (eslint:max-params, knip:classMembers, jscpd:duplication, comment:non-essential, eslint:fatal) with canonical kebab-case smell keys (too-many-parameters, unused-class-member, duplicated-code, non-essential-comment, parse-error), per docs/smell-vocabulary.md. - Each wrap (eslint/knip/jscpd/comment) owns its raw->smell translation table and emits smell keys. - Add Violation.source to carry raw tool provenance; the reporter shows it in the Uncoached section, preserving today's output. - Unmapped raw keys pass through as their bare key (no tool prefix leaks into a routing key) and still surface as uncoached. - Rename packaged prompt files to <smell>.md (content unchanged). - Catalogue titles/descriptions/severities unchanged -> output parity. No behaviour change: CLI golden output is byte-identical, all tests green, lint clean. Design calls logged in DECISIONS.md. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Introduce the sensor stage of the sensor -> mapper -> guide pipeline: - src/sensors/types.ts: Sensor / Issue / SensorContext per docs/sensors.md. - src/sensors/runner.ts: SensorRunner registers sensors, orders them by dependency (stable topological sort), and merges their issues. Leaf sensors get empty ctx.deps; a multi sensor receives its depended-on smells' issues. Unsatisfiable dependsOn smells, dependency cycles, and duplicate sensor ids are startup errors. Unit-tested in isolation with fake sensors (registration-order merge, dependency ordering, scoped deps, cycle / unsatisfiable / duplicate-id errors). Additive only — no pipeline integration yet (2b); golden output unchanged, 285 tests green. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Make the four wraps registered sensor plugins and wire them into run(): - src/sensors/preset.ts: leaf sensors over eslint/comment/jscpd/knip, with lossless Violation <-> Issue conversion. The comment sensor carries the resolved comment thresholds; tool-failure notices are collected per run. - src/runner.ts: run() discovers files, runs all preset sensors over the full file set via SensorRunner, merges issues, converts to violations, then applies per-smell file filtering (resolveFilesForRule). The sensor stage is now a pure detector; rule-scoped filtering is the seam the Phase 3 mapper will own. - Delete src/eslint-runner.ts: its union + post-filter logic is subsumed by the generalized filterViolations. Parity preserved: CLI golden output byte-identical, 290 tests green, lint clean. One accepted divergence (knip findings now respect the baseline) logged in DECISIONS.md. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
…arity Reviewer found a parity break: detect-all-then-filter ran every sensor unconditionally, so disabling (or empty-scoping) a non-eslint smell let its tool's findings leak through filterViolations (the "no rule -> keep" path meant for uncoached smells). The all-enabled golden fixture missed it. Gate each sensor: it runs only when at least one smell it produces has a rule resolving to a non-empty file set — reproducing the old "a tool runs iff its source has an active in-scope rule" semantics. Disabling a smell now suppresses its whole tool (incl. uncoached sibling smells) as before. Add runner tests reproducing the reviewer's case (non-essential-comment disabled -> suppressed; control fires). Golden byte-identical, 292 tests green, lint clean. DECISIONS.md updated. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Introduce the canonical smell-keyed mapping (docs/mapper.md):
- schema/validate: accept `smells` (validated like `rules`, path `smells.*`)
and a new per-smell `fix` field; `rules` kept as a transitional alias.
- merge/registry: both maps merge with `smells` last (wins on conflict);
`fix` threads through onto the resolved Rule for the Phase 3b mapper.
- defaults + canonical configs migrated to `smells`.
- Fix a latent Phase-1 miss: the repo's own habit-hooks.config.js still keyed
an override under `comment:non-essential` (now a no-match that would throw);
migrated to `smells: { 'non-essential-comment' }`.
Tests cover smells override + fix propagation, smells-over-rules precedence,
and smells validation paths. Parity preserved: golden byte-identical, 296
tests green, lint clean.
https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
src/mapper/mapper.ts: a pure smell -> GuideAction function (docs/mapper.md). - mapIssues groups the bag by smell and resolves each group to one action (severity + Fix) carrying its issues; smells with no resolvable fix fall through to an uncoached bucket (totality). - resolveFix implements the chain: explicit `fix` setting -> `<smell>.md` (prompt) -> `<smell>` script (command) -> uncoached, looking up the consumer override dir before the packaged dir; an explicit `fix` naming a missing file is a config error. Unit-tested in isolation (10 tests: override precedence, md-over-script, absolute/relative fix, missing-file error, grouping, severity, uncoached). Tested-but-unwired; Phase 4 builds the Nunjucks guide that consumes GuideAction[]. 306 tests green, lint clean, golden unchanged. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Add the guide stage (docs/guide.md), tested then wired in 4b:
- src/guide/render.ts: a Nunjucks Environment (autoescape off; FileSystemLoader
over the override+packaged dirs for {% include %}) + renderTemplate.
- src/guide/guide.ts: renders each GuideAction's template against
{ smell, issues }, lists the uncoached bucket, and computes the exit code
(enforced smell with any issue -> 1; uncoached never escalates). Command
fixes render nothing (out of scope).
5 tests including the clean banner, prompt rendering, suggested-only exit 0,
the uncoached bucket with provenance, and real per-smell grouping via
`groupby("details.file")`. Add nunjucks dependency. 311 tests green, lint
clean. Tested-but-unwired; 4b integrates and converts templates.
https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
run() now detects via sensors, filters per smell, maps the Issue bag to GuideAction[], and renders with the Nunjucks guide: - runner: build routing as `merged rule ?? lookupPrompt(smell)` so supplemental smells keep their catalogue severity (parse-error stays enforced); resolve the override prompts dir; map + guide replace report(). - guide composes each section (title / description / prompt template / issue list) so output stays close to the old format — titles, `file:line - message`, and the banner are preserved, keeping integration tests green. - oversized-function ships oversized-function.issues.njk grouping issues by file: the real per-smell grouping the DoD requires. - delete src/reporter.ts + reporter.test.ts (superseded); adapt the one knip-wrap test that rendered through it. - fix packaged-dir probe (renamed eslint-max-params.md -> too-many-parameters.md) and ship .njk partials. Intentional new snapshots: section order now follows sensor arrival, and the per-rule "(N more)" cap is gone. 300 tests green, lint clean. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Reviewer found an exit-code parity regression: seven enabled enforced smells ship no <smell>.md (unused-variable, loose-equality, var-declaration, non-const-binding, duplicate-import, redundant-type-annotation, unused-class-member), so they fell into the uncoached bucket and exited 0 — the old reporter exited 1 for any enforced smell with a violation. The acceptance fixture masked it by also containing coached-enforced smells. A routed smell (known severity from config or catalogue) now always becomes an action: its dedicated template if present, else the generic uncoached.md body — keeping its severity so it renders as a section and escalates the exit. Only a smell with no routing at all is truly uncoached (exit 0). Add a runner test isolating an enforced-but-untemplated smell (loose-equality -> exit 1). 301 tests green, lint clean. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Validate and build the multi-language path:
- src/sensors/adapter.ts: extractIssues handles the two-level
group/items/fields/map model — flat (ruff) and nested (eslint) — and
declarativeSensor runs a JSON-emitting tool as a leaf sensor (expand
${files}, run, parse stdout, extract; spawn failure -> notice + 0 issues).
adapter.test.ts pins both shapes against the captured live JSON (de-risk).
- src/sensors/python-preset.ts: ruff (C901/PLR0913/PLR0915/F841 ->
high-complexity/too-many-parameters/oversized-function/unused-variable,
plus F401 -> unused-import), jscpd on .py (shared checkLeafSensor), and
deptry (DEP002 -> unused-dependency via `deptry . --json-output /dev/stdout`).
- export checkLeafSensor/LeafSpec for reuse; issueToViolation tolerates a
null/absent line (deptry locations).
python-preset.test runs ruff for real (skips when ruff is absent). 308 tests
green, lint clean. init language selection + e2e fixtures are Phase 6.
https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
- config.language (typescript|python, default typescript) drives file discovery and the active preset; the activeness gate keeps only that language's sensors running. init detects the language from a Python manifest and writes it to the scaffolded config. - catalogue: add unused-file/unused-export/unused-dependency (vocabulary) and unused-import (new) so deptry activates and the smells are coached; move the catalogue to src/config/catalogue.ts; make changedFilesOnly optional. - filterViolations keeps whole-project artifacts (pyproject.toml, package.json) that aren't discovered source files (fixes deptry being dropped). - deptry sensor uses the temp-report pattern (its /dev/stdout JSON is empty under a pipe) feeding the adapter's extractIssues. - tests/fixtures/python-project + python-acceptance.test prove all six Python smells fire via the same mapper/guide vocabulary (skips without ruff+deptry). 314 tests green, lint clean. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Add src/cli-output.test.ts asserting the pass banner (exit 0) and the violation count header + a coached section (exit 1) for both the TypeScript and Python presets — closing the last Definition-of-done item. Python cases skip without ruff. 318 tests green, lint clean. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
Address the Phase 5/6 review nits (no blockers were found): - validate.ts: accept `source: 'knip'` (it's a valid RuleSource) in user rule definitions; update the error message. - docs/smell-vocabulary.md: catalogue `unused-import`, add the Python preset translation table (ruff/jscpd/deptry -> smell), and note the deferred `oversized-file`. 318 tests green, lint clean. https://claude.ai/code/session_011Z7daZc9YDGTq7HZAZnbUE
The `cleans up the tmpdir` tests asserted that the global os.tmpdir() had zero `hh-jscpd-*` entries after a run. Vitest runs test files in parallel, and several of them shell out to jscpd concurrently, each creating its own `hh-jscpd-*` dir — so an unrelated in-flight run leaked into the snapshot and failed the assertion. The race only surfaced with the Python toolchain installed (ruff/deptry present -> the Python tests run instead of skip, adding more concurrent jscpd runs). makeReportDir now takes an optional tmpRoot (default os.tmpdir()), threaded through runJscpd and runJscpdWrap. The cleanup tests pass an isolated root and assert on that root alone — no global /tmp dependency. Production behaviour is unchanged (the jscpdWrap adapter omits tmpRoot). Verified: full suite green 5x with ruff/deptry present; reviewer mutation-tested that dropping cleanup still fails both tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`habit-hooks init <language>` now scaffolds for an explicit language; `habit-hooks init` with no argument detects the language and prints a report-only message telling the user to re-run with it specified — writing nothing. This keeps language an explicit, deliberate choice without an interactive prompt (agents struggle with TTY prompts). - init-command: `init [language]`; unknown language -> exit 2 listing supported languages, before any side effect. - runInit: undefined language -> reportOnly (pure reads, no writes); explicit language is threaded into scaffoldConfig (no re-detect). - toolsForLanguage seam gates tool scaffolding: typescript -> the eslint/knip/jscpd set, python -> [] for now (Python scaffolders land next phase), so `init python` no longer wrongly scaffolds TS tools. - detectLanguageWithReason surfaces the detection reason for a testable message. run.ts split into ctx/tool-steps/report-language to stay within the line gates (no behaviour change). 26 existing init tests routed through a `runInitTs` helper; new tests cover report-only (writes nothing), explicit python/typescript, and the unknown-language CLI error. 330 tests green, build + lint clean. Known follow-up (Phase 3): AGENT_SNIPPET still names eslint/knip/jscpd and npm scripts even for `init python`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mmands Internal tool-model groundwork for language-aware init (no user-visible behaviour change yet). - ToolName gains `ruff` and `deptry`; the python language maps to [ruff, deptry, jscpd]. - ruff/deptry are detected on the system PATH (pip-installed, not in node_modules) via a new sync isOnPath; eslint/knip/jscpd keep their node_modules detection. A DETECTION_KIND map selects per tool. - "configured" semantics: ruff -> a ruff config file or a `[tool.ruff...]` section in pyproject.toml; deptry -> pyproject.toml present (its manifest, config optional). - installCommandsFor groups missing tools by ecosystem: pip for ruff/deptry, the node package manager for eslint/knip/jscpd (0, 1, or 2 commands). - tool-steps keeps a temporary python short-circuit (commented) so init's behaviour is unchanged until Phase 3b wires Python scaffolding + guidance. Reviewer-approved. 343 tests green (13 new: PATH detection, ruff/deptry configured/installed, two-ecosystem install commands), build + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e guidance `habit-hooks init python` now sets up a Python project instead of writing nothing. It scaffolds the tool configs Habit Hooks owns, surfaces the right install commands across both ecosystems, and stops leaking TypeScript/npm assumptions into the Python path. - ruff.toml scaffolder: mccabe complexity 10 / pylint max-args 3, max-statements 50 — mirroring the TS eslint thresholds so "too complex" / "too many params" mean the same across languages. Skipped when the project already configures ruff in pyproject.toml. - .jscpd.json is now language-aware: the Python variant ignores .venv/venv/__pycache__/*_test.py/test_*.py. - deptry has no config file; init emits guidance (ensure pyproject.toml declares dependencies) rather than scaffolding. - runToolSteps iterates the full language tool set and prints install commands via installCommandsFor — a pip line for ruff/deptry and a node line for jscpd. The python short-circuit is gone. - npm `habit-hooks`/`ci` script prompts are skipped for Python; the pre-commit hook invokes `habit-hooks` directly (vs `<pm> run …` for TS). - AGENT_SNIPPET -> agentSnippet(language): the pasted guidance names the language's tools and run command. `init typescript` behaviour is unchanged (byte-identical). Reviewer- approved. 352 tests green (8 new python-onboarding tests + per-language jscpd template tests), build + lint clean. Deferred to 3c: a python dry-run test (deptry's guidance note currently prints even under --dry-run). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
init now ends with a clear, detailed account of what's left to do, so an incomplete setup (a missing tool, or an existing config missing the recommended values) is never silent — legible to a human or an AI. - recommendations.ts: a single source of recommended values — jscpd (threshold 0, minTokens 50, minLines 5) and ruff (mccabe max-complexity 10, pylint max-args 3, max-statements 50). missingKeys(cwd) reports which recommended keys are ABSENT (additive — a value the user intentionally tuned is never flagged). jscpd parses its JSON; ruff does a cheap text scan of ruff.toml/pyproject.toml (no TOML parser). eslint's flat `.js` config can't be cheaply parsed, so it stays a soft advisory. - The ruff.toml / .jscpd.json templates now DERIVE their values from these constants, and a test pins that a freshly-scaffolded config satisfies missingKeys() === [] — templates and recommendations can't drift apart. - completion-report.ts: appends "⚠️ Setup incomplete — to finish:" listing missing-tool installs (pip/node), deptry-needs-pyproject, and each missing recommended value with the exact edit; or "✅ Setup complete." when there are no hard gaps. The eslint advisory is a separate Note that never flips the status. Both languages. Re-detects post-scaffold so just-written configs aren't falsely reported, and dry-run correctly still reports gaps. Instruct-only: no installs are run and no files are auto-edited (the --accept-recommendations flag that applies them is the next phase). Closes the 3b dry-run gap (python --dry-run test). Reviewer-approved (eslint- advisory split + dead-field cleanup applied). 368 tests green, build + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
By default init only instructs. `--accept-recommendations` makes it execute the safe, mechanical fixes so a human or AI can finish setup in one command. - Runs the install command(s) for missing tools (pip and/or node) via an injectable CommandRunner (default shells out through runTool; tests inject a recorder, so the suite never runs a real installer). A failed install is reported (✗ failed) and never aborts the rest or changes the exit code. - Additively merges absent recommended keys into an existing `.jscpd.json` (never overwriting a user's value; valid JSON, trailing newline). - Deliberately does NOT edit user-owned ruff.toml / pyproject.toml thresholds or eslint flat config — safe in-place TOML/JS editing needs a real parser, so those stay reported as manual steps even with the flag (boundary documented in apply-recommendations.ts). - The execute step runs before the completion report, so after a real install the final report reflects the now-satisfied gaps. - In default mode the report ends with a single advert line pointing at the flag — shown only when there's auto-applicable work (a missing tool or missing jscpd keys), never when setup is complete or only ruff/eslint manual items remain. Reviewer-approved (header-when-empty suppressed, describeKey reuse applied). 382 tests green, build + lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 16, 2026
devill
added a commit
that referenced
this pull request
Jun 16, 2026
Append the agent decisions from the post-GOAL init/onboarding work (PR #13): the jscpd tmp-root test fix, `init [language]` report-only, PATH-based Python tool detection + two-ecosystem installs, cross-language threshold mirroring from a single source, npm-step skipping for Python, additive drift detection with eslint-as-advisory, and the `--accept-recommendations` safety boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Refactors the TypeScript-only system into the
sensor → mapper → guidearchitecture and proves multi-language support with a Python preset
alongside the refactored TypeScript preset — the full GOAL.md scope —
plus a language-aware
initthat onboards either language.What's in here
Architecture (GOAL.md phases 1–6)
only inside sensor translation tables (grep-gated).
Sensorcontract +SensorRunner(leaf sensors, dependency-ordered merge).smell → GuideActionmapper with thefixresolution chain.oversized-functionby file).old system fired still fires.
C901/PLR0913/PLR0915/F841/F401), jscpd on.py,deptry (
DEP002); declarative adapter validated against live tool JSON.Language-aware init (this round of work)
habit-hooks init [language]; with no argument it detects the language andprints a report-only message telling the user to re-run with it specified
(no interactive TTY — agent-friendly).
init pythonscaffoldsruff.toml+ a Python.jscpd.json, prints bothpipand node install commands, skips npm-only steps, installs ahabit-hooks-direct pre-commit hook, and pastes a Python-aware agent snippet.and unset recommended values for both languages (drift detection over the
cheaply-parseable configs; eslint stays a soft advisory).
--accept-recommendationsexecutes the safe fixes: runs the installers andadditively merges recommended keys into Habit-Hooks-owned
.jscpd.json.User-owned
ruff.toml/pyproject.toml/eslint config are never auto-edited.Fix
green with the Python toolchain installed (ruff/deptry present).
Quality gates
pnpm buildclean,pnpm lintzero warnings.for both languages.
Design decisions logged in
DECISIONS.md.🤖 Generated with Claude Code