Skip to content

Multi-language Habit Hooks: sensor→mapper→guide, Python preset, and language-aware init#13

Merged
devill merged 19 commits into
goal/multi-languagefrom
claude/optimistic-ritchie-9w94x7
Jun 16, 2026
Merged

Multi-language Habit Hooks: sensor→mapper→guide, Python preset, and language-aware init#13
devill merged 19 commits into
goal/multi-languagefrom
claude/optimistic-ritchie-9w94x7

Conversation

@devill

@devill devill commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refactors the TypeScript-only system into the sensor → mapper → guide
architecture and proves multi-language support with a Python preset
alongside the refactored TypeScript preset — the full GOAL.md scope —
plus a language-aware init that onboards either language.

What's in here

Architecture (GOAL.md phases 1–6)

  • Canonical kebab-case smell keys as routing keys; tool prefixes survive
    only inside sensor translation tables (grep-gated).
  • Sensor contract + SensorRunner (leaf sensors, dependency-ordered merge).
  • Pure smell → GuideAction mapper with the fix resolution chain.
  • Nunjucks guide with real per-smell grouping (oversized-function by file).
  • TypeScript preset (eslint/knip/jscpd/comment) at parity — every smell the
    old system fired still fires.
  • Python preset: ruff (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 and
    prints a report-only message telling the user to re-run with it specified
    (no interactive TTY — agent-friendly).
  • init python scaffolds ruff.toml + a Python .jscpd.json, prints both
    pip and node install commands, skips npm-only steps, installs a
    habit-hooks-direct pre-commit hook, and pastes a Python-aware agent snippet.
  • A "Setup incomplete — to finish:" completion report surfaces missing tools
    and unset recommended values for both languages (drift detection over the
    cheaply-parseable configs; eslint stays a soft advisory).
  • --accept-recommendations executes the safe fixes: runs the installers and
    additively merges recommended keys into Habit-Hooks-owned .jscpd.json.
    User-owned ruff.toml/pyproject.toml/eslint config are never auto-edited.

Fix

  • The jscpd cleanup test no longer races on the global tmpdir, so the suite is
    green with the Python toolchain installed (ruff/deptry present).

Quality gates

  • Full suite green (382 tests), pnpm build clean, pnpm lint zero warnings.
  • TS + Python e2e fixtures; CLI clean-run banner + violation output snapshots
    for both languages.

Design decisions logged in DECISIONS.md.

🤖 Generated with Claude Code

claude and others added 19 commits June 15, 2026 18:53
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>
@devill devill changed the title Phase 1–2: Smell-key decoupling and sensor contract Multi-language Habit Hooks: sensor→mapper→guide, Python preset, and language-aware init Jun 16, 2026
@devill
devill merged commit 2d7788e into goal/multi-language Jun 16, 2026
2 of 3 checks passed
@devill
devill deleted the claude/optimistic-ritchie-9w94x7 branch June 16, 2026 13:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants