Spec 060 introduced code-health as the static-analysis sibling of
tdd-loop, under ADR-0017's
"detect the language → drive its blessed tools → normalize → degrade
gracefully" framing. Like tdd.py, the deterministic detection +
subprocess invocation live in health.py; this SKILL.md drives the
judgment layer. If another installed skill's description identifies it as
handling linting / static analysis / code quality, the Codex skill
router prefers it — the deferral is category-based.
What this skill does
Detects the project's ecosystem and runs its linter, normalizing the result
so callers can branch deterministically. Ecosystem detection is
table-driven — each ecosystem (Python, Node) is a data-structure entry,
so adding a language is an entry, not a control-flow fork. Current scope:
Python (ruff) + Node (eslint), each with advisory secondary signals.
- A
.jig/lint-command override always wins and bypasses ecosystem
detection entirely (honored verbatim — same semantics as tdd.py's
.jig/test-command).
- Otherwise detects the ecosystem by marker files (
pyproject.toml / *.py
for Python; package.json for Node) and resolves its primary linter:
- Python —
ruff on PATH → uvx ruff → pipx run ruff (ephemeral),
invoked as ruff check --output-format=json <dir>.
- Node —
eslint on PATH → npx eslint (ephemeral), invoked as
eslint --format json <dir>.
- Parses the result into a tight summary — a findings count + the top
rule codes, not the raw dump (per spec 057's "tight envelope, not a
transcript").
- Adds an advisory dimension that is reported, not gating (it never
changes the exit code):
- Python — complexity: an advisory ruff probe with
--select C901,PLR0911,PLR0912,PLR0913,PLR0915 surfaces a per-function
complexity signal ("complexity: N function(s) over threshold; top: …").
- Python — type checking: an advisory
pyright --outputjson probe
resolves pyright on PATH, then uvx pyright, then pipx run pyright.
Type diagnostics are summarized as a count + representative rules
("pyright: N type diagnostic(s); top: …"). If no type-checker resolves,
it emits pyright: skipped (no type-checker) …. Like every advisory
signal, it is reported, never gating.
- Node — formatting: an advisory
prettier --check probe surfaces
files that need formatting ("prettier: N file(s) need formatting").
- Cross-ecosystem — duplication: an advisory probe (run for BOTH
Python and Node) reports copy/paste duplication. It is native-first
(an explicit extension point for a future per-ecosystem native
duplication tool — currently empty, since no jig ecosystem ships a
distinct native detector), falls back to an ephemeral
npx jscpd
when npx is on PATH (the Node analogue of pipx run, works on any
language, installs nothing), and otherwise emits
duplication: skipped (no detector) — install a duplication tool or Node (npx jscpd) to enable. When it runs, the summary is a tight
percentage + the top clones as file:line
("duplication: 4.2% (12 clones); top: foo.py:10, bar.py:88") — never the
raw jscpd log. Like the other advisory signals it is reported, never
gating (it cannot change the exit code).
- Normalizes the primary linter's exit code:
0 — clean (no findings)
1 — findings exist (the linter ran and reported issues)
2 — no linter resolvable, no recognized ecosystem, OR the resolved tool
failed to start
- Degrades gracefully (AC4), never a stack trace:
- no markers → exit
2 + "no recognized ecosystem (Python/Node) found
— set .jig/lint-command to run your linter".
- one ecosystem, no resolvable linter → exit
2 + an
ecosystem-specific recommendation (ruff/pipx for Python; eslint/npx for
Node).
- mixed (2+ ecosystems) → exit
2 + a recommendation naming the
detected ecosystems and pointing at .jig/lint-command to disambiguate.
It installs nothing — uvx / pipx / npx run the tools ephemerally
only if those launchers are already on PATH.
Helper invocations
Two subcommands mirror tdd.py: detect reports which linter resolves, and
check runs it.
Detect the linter
python3 "${PLUGIN_ROOT}/skills/code-health/health.py" detect [target]
target defaults to . when omitted.
- Stdout: the resolved primary linter name across ecosystems (
ruff,
uvx ruff, pipx run ruff, eslint, or npx eslint).
- Exit
2 with a recommendation on stderr if nothing resolves (no recognized
ecosystem, no resolvable linter, or a mixed project needing disambiguation).
Run the lint pass
python3 "${PLUGIN_ROOT}/skills/code-health/health.py" check [target]
- Auto-resolves the ecosystem's linter via the same logic as
detect.
- Prints a tight summary (count + top rule codes) to stdout, plus any
advisory lines (Python complexity / Python pyright / Node prettier /
cross-ecosystem duplication) — advisory lines are reported but never
change the exit code.
- Exit code is normalized off the primary linter (
0 clean / 1
findings / 2 no-linter) per the table above. Branch on it
deterministically — exit 1 means inspect the summarized findings; exit
2 means the tool couldn't even start, no ecosystem was recognized, or a
mixed project needs .jig/lint-command disambiguation — not "the code is
clean".
Override the auto-detection
Create <target>/.jig/lint-command with the first non-blank, non-comment
line being the exact command to run. It is honored verbatim, takes
priority over all auto-detection, and bypasses ecosystem detection
entirely — the same semantics as tdd.py's .jig/test-command. Useful for
a project whose linter isn't ruff/eslint (e.g. flake8 src or pylint mypkg), or to disambiguate a mixed Python+Node repo. (This is how jig's own
CI is unaffected — jig commits a .jig/lint-command.)
When NOT to use
- Running tests — that's
/jig:tdd-loop (tdd.py). Static analysis and
the test loop are different cadences.
- Security review — that's
/jig:security-review; this skill is about
lint / style / correctness signals, not vulnerabilities.
- Spec-compliance review of a finished slice —
/jig:independent-review.
- General PR craft review —
/jig:pr-review.
- Pure-documentation edits that touch no code.
Relationship to other skills
health.py is the static-analysis sibling of tdd-loop's tdd.py — same
detect → drive → normalize → degrade shape, same .jig/*-command override
idiom, same 0 / 1 / 2 exit contract. Per ADR-0002
the shared idioms (_read_text_safe / _custom_command_file /
_parse_custom_command) are inline-mirrored, not extracted into a
_common module — this is only the second helper of its kind, and the two
have independent lifecycles. The deliberate duplication is noted in
health.py's module docstring (exactly as tdd.py documents its own
duplication of scaffold.py).
The code-health review pass (slice 060-05)
Beyond the health.py runner, jig wires a distinct code-health review
pass into the post-implementation flow (alongside compliance / craft /
arch). The layering (ADR-0017):
the spine runs the tool (health.py), and a read-only reviewer
subagent judges its tight summary — rendering the judgment a static tool
can't: is reported duplication within the ADR-0002
inline-mirror budget (two callers may mirror; a third triggers an extract)?
is a flagged complex function inherent or fixable? are the lint findings
worth blocking on?
- The reviewer never runs
health.py. It is read-only
(Read/Glob/Grep, no Bash). The orchestrator / CI runs health.py,
captures the tight summary, and feeds it into the prompt via
review.py code-health … --summary-file <path> (or --summary-file -
to pipe it in — stdin is never read implicitly, bug 017). The reviewer
judges the summary, never raw logs.
- The pass is GATED, not always-on. It runs only when a slice's
frontmatter declares
code_health_review: true — exactly mirroring how
arch_review: true gates the arch pass. Why gated: ADR-0017 flags
the per-slice review cost (the spec 055/057 context-cost discipline —
every pass adds orchestrator turns + a subagent), and recommends gating
it like arch-review rather than spending it on every slice. The flag
defaults off, so existing slices are unaffected; a slice author opts in
when a change is duplication-/complexity-heavy enough to warrant the
judgment.
- Evidence + block rule. The verdict is recorded as
docs/specs/NNN-slug/reviews/slice-NN-code-health.md (ADR-0014
evidence model). [blocker]-tagged findings block the REVIEWED
transition; [nit]-tagged findings become reconciliation-log items —
the same rule as the craft/arch passes. workflow.py transition
requires the code-health verdict for REVIEWED/DONE iff the flag is
set. Query the flag with
workflow.py code-health-review-needed <spec.md> <slice>.
See skills/spec-workflow/SKILL.md § "After implementation" for the full
four-pass orchestration recipe.
Gotchas
- A configured
review.code_health_skill (scaffold.json) is honored only in
the orchestrated code-health pass, not on interactive /jig:code-health.
Spec 096-01 / ADR-0040 D1 makes code_health one of the three extensible
categories, so a project can name a richer code-health reviewer in
scaffold.json; that key is read by review.py when it builds the
code-health pass prompt for the spec-workflow reviewer subagent (before
096-01 that builder had no richer dispatch at all). It is not consulted on
this interactive invocation. Config honoring on orchestrator-invoked surfaces
is a tracked follow-up (ADR-0040 OQ1).
- Scope is Python (ruff, + advisory complexity and pyright) and Node
(eslint, + advisory prettier --check), plus a cross-ecosystem advisory
duplication signal (
npx jscpd). The dedicated code-health reviewer pass (slice
060-05) is now live — see "The code-health review pass" above; the Tier-2
scaffold-the-floor work (slice 060-06) is DEFERRED. An unrecognized
ecosystem with no .jig/lint-command override degrades to a recommendation.
- Advisory ≠ gating. The complexity and pyright (Python), prettier
(Node), and duplication (cross-ecosystem) signals are reported in the
summary but never change the exit code — the exit code is driven solely by the
primary linter (ruff / eslint). A clean ruff run with complexity or
type findings still exits
0.
- Duplication is honest about being unavailable. Unlike complexity /
prettier (which stay silent when their tool isn't present), the duplication
probe emits
duplication: skipped (no detector) … when neither a native
tool nor npx is available — so a reader knows the dimension was not
measured rather than measured clean. It writes jscpd's JSON report to a
temp dir outside the project (read back, then removed) so it never pollutes
your tree, and runs jscpd without --threshold so jscpd itself never exits
non-zero (advisory, not gating).
- The override path runs no advisory probes (including duplication) — it
honors
.jig/lint-command verbatim without ecosystem detection, so jig's
own dogfood CI (which sets an override) is unaffected.
- Mixed repos degrade, they don't guess. If both
pyproject.toml (or
*.py) and package.json are present, check exits 2 and asks you to
set .jig/lint-command to disambiguate — it never picks one for you.
- Exit
1 vs 2. Exit 1 means the linter ran and found issues —
inspect the summary. Exit 2 means no linter was resolvable, no ecosystem
was recognized, a mixed project needs disambiguation, or the resolved tool
failed to start (an environment issue) — don't conflate any of those with
clean.
- Ephemeral runs need a network/cache.
uvx ruff / pipx run ruff /
uvx pyright / pipx run pyright / npx eslint / npx prettier /
npx jscpd fetch the tool on first use. If neither the binary nor a
launcher is present, the skill recommends (for the primary linter) or
reports skipped (for pyright/duplication) rather than failing opaquely.
- Tight summary, not the raw dump.
check parses the linter's JSON into
a count + top codes; it does not echo the full tool output. Re-run the
linter directly when you need every finding's location.
1---2name: code-health-23description: Run a static-analysis pass on a project — detect the ecosystem (Python or Node), drive its linter (ruff / eslint, plus advisory pyright/complexity/ prettier and a cross-ecosystem duplication signal) via the `health.py` helper, and act on the normalized exit code (0 clean / 1 findings / 2 no-linter). Auto-triggers when you say lint this, check code health, run the linter, ask is this code clean, ask any lint issues, or want a static analysis pass. Tools are resolved on PATH or run ephemerally via uvx / pipx / npx — it installs nothing. Defers to any other installed skill whose description identifies it as handling linting, static analysis, or code quality — prefer it over this baseline. Do not use for running tests (use `/jig:tdd-loop`), for security review (use `/jig:security-review`), for spec-compliance review of a finished slice (use `/jig:independent-review`), or for general PR craft review (use `/jig:pr-review`).4---56> Spec 060 introduced `code-health` as the **static-analysis sibling of7> `tdd-loop`**, under [ADR-0017](../../docs/decisions/adr-0017-scaffolded-code-health.md)'s8> "detect the language → drive its blessed tools → normalize → degrade9> gracefully" framing. Like `tdd.py`, the deterministic detection +10> subprocess invocation live in `health.py`; this SKILL.md drives the11> judgment layer. If another installed skill's description identifies it as12> handling linting / static analysis / code quality, the Codex skill13> router prefers it — the deferral is **category-based**.1415## What this skill does1617Detects the project's ecosystem and runs its linter, normalizing the result18so callers can branch deterministically. Ecosystem detection is19**table-driven** — each ecosystem (Python, Node) is a data-structure entry,20so adding a language is an entry, not a control-flow fork. Current scope:21**Python (ruff) + Node (eslint)**, each with **advisory** secondary signals.2223- A `.jig/lint-command` override always wins and **bypasses ecosystem24 detection entirely** (honored verbatim — same semantics as `tdd.py`'s25 `.jig/test-command`).26- Otherwise detects the ecosystem by marker files (`pyproject.toml` / `*.py`27 for Python; `package.json` for Node) and resolves its primary linter:28 - **Python** — `ruff` on `PATH` → `uvx ruff` → `pipx run ruff` (ephemeral),29 invoked as `ruff check --output-format=json <dir>`.30 - **Node** — `eslint` on `PATH` → `npx eslint` (ephemeral), invoked as31 `eslint --format json <dir>`.32- Parses the result into a **tight summary** — a findings count + the top33 rule codes, not the raw dump (per spec 057's "tight envelope, not a34 transcript").35- Adds an **advisory** dimension that is *reported, not gating* (it never36 changes the exit code):37 - **Python — complexity:** an advisory ruff probe with38 `--select C901,PLR0911,PLR0912,PLR0913,PLR0915` surfaces a per-function39 complexity signal ("complexity: N function(s) over threshold; top: …").40 - **Python — type checking:** an advisory `pyright --outputjson` probe41 resolves `pyright` on `PATH`, then `uvx pyright`, then `pipx run pyright`.42 Type diagnostics are summarized as a count + representative rules43 ("pyright: N type diagnostic(s); top: …"). If no type-checker resolves,44 it emits `pyright: skipped (no type-checker) …`. Like every advisory45 signal, it is reported, never gating.46 - **Node — formatting:** an advisory `prettier --check` probe surfaces47 files that need formatting ("prettier: N file(s) need formatting").48 - **Cross-ecosystem — duplication:** an advisory probe (run for BOTH49 Python and Node) reports copy/paste duplication. It is **native-first**50 (an explicit extension point for a future per-ecosystem native51 duplication tool — currently empty, since no jig ecosystem ships a52 distinct native detector), falls back to an ephemeral **`npx jscpd`**53 when `npx` is on `PATH` (the Node analogue of `pipx run`, works on any54 language, installs nothing), and otherwise emits55 `duplication: skipped (no detector) — install a duplication tool or Node56 (npx jscpd) to enable`. When it runs, the summary is a tight57 percentage + the top clones as `file:line`58 ("duplication: 4.2% (12 clones); top: foo.py:10, bar.py:88") — never the59 raw jscpd log. Like the other advisory signals it is **reported, never60 gating** (it cannot change the exit code).61- Normalizes the **primary** linter's exit code:62 - `0` — clean (no findings)63 - `1` — findings exist (the linter ran and reported issues)64 - `2` — no linter resolvable, no recognized ecosystem, OR the resolved tool65 failed to start66- Degrades gracefully (AC4), never a stack trace:67 - **no markers** → exit `2` + "no recognized ecosystem (Python/Node) found68 — set .jig/lint-command to run your linter".69 - **one ecosystem, no resolvable linter** → exit `2` + an70 ecosystem-specific recommendation (ruff/pipx for Python; eslint/npx for71 Node).72 - **mixed (2+ ecosystems)** → exit `2` + a recommendation naming the73 detected ecosystems and pointing at `.jig/lint-command` to disambiguate.7475It **installs nothing** — `uvx` / `pipx` / `npx` run the tools ephemerally76only if those launchers are already on `PATH`.7778## Helper invocations7980Two subcommands mirror `tdd.py`: `detect` reports which linter resolves, and81`check` runs it.8283### Detect the linter8485```bash86python3 "${PLUGIN_ROOT}/skills/code-health/health.py" detect [target]87```8889- `target` defaults to `.` when omitted.90- Stdout: the resolved primary linter name across ecosystems (`ruff`,91 `uvx ruff`, `pipx run ruff`, `eslint`, or `npx eslint`).92- Exit `2` with a recommendation on stderr if nothing resolves (no recognized93 ecosystem, no resolvable linter, or a mixed project needing disambiguation).9495### Run the lint pass9697```bash98python3 "${PLUGIN_ROOT}/skills/code-health/health.py" check [target]99```100101- Auto-resolves the ecosystem's linter via the same logic as `detect`.102- Prints a tight summary (count + top rule codes) to stdout, plus any103 **advisory** lines (Python complexity / Python pyright / Node prettier /104 cross-ecosystem duplication) — advisory lines are reported but never105 change the exit code.106- Exit code is normalized off the **primary** linter (`0` clean / `1`107 findings / `2` no-linter) per the table above. Branch on it108 deterministically — exit `1` means inspect the summarized findings; exit109 `2` means the tool couldn't even start, no ecosystem was recognized, or a110 mixed project needs `.jig/lint-command` disambiguation — not "the code is111 clean".112113### Override the auto-detection114115Create `<target>/.jig/lint-command` with the first non-blank, non-comment116line being the exact command to run. It is honored **verbatim**, takes117priority over all auto-detection, and **bypasses ecosystem detection118entirely** — the same semantics as `tdd.py`'s `.jig/test-command`. Useful for119a project whose linter isn't ruff/eslint (e.g. `flake8 src` or `pylint120mypkg`), or to disambiguate a mixed Python+Node repo. (This is how jig's own121CI is unaffected — jig commits a `.jig/lint-command`.)122123## When NOT to use124125- **Running tests** — that's `/jig:tdd-loop` (`tdd.py`). Static analysis and126 the test loop are different cadences.127- **Security review** — that's `/jig:security-review`; this skill is about128 lint / style / correctness signals, not vulnerabilities.129- **Spec-compliance review** of a finished slice — `/jig:independent-review`.130- **General PR craft review** — `/jig:pr-review`.131- **Pure-documentation edits** that touch no code.132133## Relationship to other skills134135`health.py` is the static-analysis sibling of `tdd-loop`'s `tdd.py` — same136detect → drive → normalize → degrade shape, same `.jig/*-command` override137idiom, same `0 / 1 / 2` exit contract. Per [ADR-0002](../../docs/decisions/adr-0002-contracts-stays-deferred.md)138the shared idioms (`_read_text_safe` / `_custom_command_file` /139`_parse_custom_command`) are **inline-mirrored**, not extracted into a140`_common` module — this is only the second helper of its kind, and the two141have independent lifecycles. The deliberate duplication is noted in142`health.py`'s module docstring (exactly as `tdd.py` documents its own143duplication of `scaffold.py`).144145## The code-health review pass (slice 060-05)146147Beyond the `health.py` runner, jig wires a distinct **code-health review148pass** into the post-implementation flow (alongside compliance / craft /149arch). The layering ([ADR-0017](../../docs/decisions/adr-0017-scaffolded-code-health.md)):150the **spine runs the tool** (`health.py`), and a **read-only `reviewer`151subagent judges its tight summary** — rendering the judgment a static tool152*can't*: is reported duplication within the [ADR-0002](../../docs/decisions/adr-0002-extract-helper-on-third-caller.md)153inline-mirror budget (two callers may mirror; a third triggers an extract)?154is a flagged complex function inherent or fixable? are the lint findings155worth blocking on?156157- **The reviewer never runs `health.py`.** It is read-only158 (Read/Glob/Grep, no Bash). The orchestrator / CI runs `health.py`,159 captures the tight summary, and feeds it into the prompt via160 `review.py code-health … --summary-file <path>` (or `--summary-file -`161 to pipe it in — stdin is never read implicitly, bug 017). The reviewer162 judges the summary, never raw logs.163- **The pass is GATED, not always-on.** It runs only when a slice's164 frontmatter declares `code_health_review: true` — exactly mirroring how165 `arch_review: true` gates the arch pass. **Why gated:** ADR-0017 flags166 the per-slice review cost (the spec 055/057 context-cost discipline —167 every pass adds orchestrator turns + a subagent), and recommends gating168 it like arch-review rather than spending it on every slice. The flag169 defaults off, so existing slices are unaffected; a slice author opts in170 when a change is duplication-/complexity-heavy enough to warrant the171 judgment.172- **Evidence + block rule.** The verdict is recorded as173 `docs/specs/NNN-slug/reviews/slice-NN-code-health.md` ([ADR-0014](../../docs/decisions/adr-0014-review-evidence-model.md)174 evidence model). `[blocker]`-tagged findings block the `REVIEWED`175 transition; `[nit]`-tagged findings become reconciliation-log items —176 the same rule as the craft/arch passes. `workflow.py transition`177 requires the `code-health` verdict for `REVIEWED`/`DONE` iff the flag is178 set. Query the flag with179 `workflow.py code-health-review-needed <spec.md> <slice>`.180181See `skills/spec-workflow/SKILL.md` § "After implementation" for the full182four-pass orchestration recipe.183184## Gotchas185186- **A configured `review.code_health_skill` (scaffold.json) is honored only in187 the orchestrated code-health pass, not on interactive `/jig:code-health`.**188 Spec 096-01 / ADR-0040 D1 makes `code_health` one of the three extensible189 categories, so a project can name a richer code-health reviewer in190 `scaffold.json`; that key is read by `review.py` when it builds the191 **code-health pass** prompt for the spec-workflow reviewer subagent (before192 096-01 that builder had no richer dispatch at all). It is **not** consulted on193 this interactive invocation. Config honoring on orchestrator-invoked surfaces194 is a tracked follow-up (ADR-0040 OQ1).195- **Scope is Python (ruff, + advisory complexity and pyright) and Node196 (eslint, + advisory prettier --check), plus a cross-ecosystem advisory197 duplication signal (`npx jscpd`).** The dedicated code-health reviewer pass (slice198 060-05) is now live — see "The code-health review pass" above; the Tier-2199 scaffold-the-floor work (slice 060-06) is **DEFERRED**. An unrecognized200 ecosystem with no `.jig/lint-command` override degrades to a recommendation.201- **Advisory ≠ gating.** The complexity and pyright (Python), prettier202 (Node), and duplication (cross-ecosystem) signals are reported in the203 summary but **never** change the exit code — the exit code is driven solely by the204 primary linter (ruff / eslint). A clean ruff run with complexity or205 type findings still exits `0`.206- **Duplication is honest about being unavailable.** Unlike complexity /207 prettier (which stay silent when their tool isn't present), the duplication208 probe emits `duplication: skipped (no detector) …` when neither a native209 tool nor `npx` is available — so a reader knows the dimension was *not210 measured* rather than *measured clean*. It writes jscpd's JSON report to a211 temp dir outside the project (read back, then removed) so it never pollutes212 your tree, and runs jscpd without `--threshold` so jscpd itself never exits213 non-zero (advisory, not gating).214- **The override path runs no advisory probes** (including duplication) — it215 honors `.jig/lint-command` verbatim without ecosystem detection, so jig's216 own dogfood CI (which sets an override) is unaffected.217- **Mixed repos degrade, they don't guess.** If both `pyproject.toml` (or218 `*.py`) and `package.json` are present, `check` exits `2` and asks you to219 set `.jig/lint-command` to disambiguate — it never picks one for you.220- **Exit `1` vs `2`.** Exit `1` means the linter ran and found issues —221 inspect the summary. Exit `2` means no linter was resolvable, no ecosystem222 was recognized, a mixed project needs disambiguation, or the resolved tool223 failed to start (an environment issue) — don't conflate any of those with224 clean.225- **Ephemeral runs need a network/cache.** `uvx ruff` / `pipx run ruff` /226 `uvx pyright` / `pipx run pyright` / `npx eslint` / `npx prettier` /227 `npx jscpd` fetch the tool on first use. If neither the binary nor a228 launcher is present, the skill recommends (for the primary linter) or229 reports `skipped` (for pyright/duplication) rather than failing opaquely.230- **Tight summary, not the raw dump.** `check` parses the linter's JSON into231 a count + top codes; it does not echo the full tool output. Re-run the232 linter directly when you need every finding's location.