Skip to content

refactor(oxlint-guard): run the plugin on deno 2 outside the pnpm workspace - #115

Open
systemfsoftware-maker wants to merge 13 commits into
mainfrom
oxlint-plugin-deeffectify
Open

refactor(oxlint-guard): run the plugin on deno 2 outside the pnpm workspace#115
systemfsoftware-maker wants to merge 13 commits into
mainfrom
oxlint-plugin-deeffectify

Conversation

@systemfsoftware-maker

Copy link
Copy Markdown
Collaborator

Summary

oxlint-guard now runs as TypeScript source under Deno 2, from its own workspace under claude-plugins/. Installing it no longer drags in this repo's pnpm, Effect, tsdown or vitest toolchain, and there is no build step: the two hooks execute src/*.ts directly, so what ships is what runs. The 970 KB of committed dist/ bundles are gone.

Two enforcement defects found during review are fixed, and both predate this migration.

The enforcement fix is the load-bearing change

Claude Code blocks a PreToolUse tool call on exit code 2 alone; it treats every other non-zero code as a non-blocking error and lets the edit proceed (hooks reference: "If your hook is meant to enforce a policy, use exit 2").

The config guard exited 1 when its runtime was missing. That was documented as the fail-closed posture and it was the opposite: on a machine without the runtime, any oxlint rule could be turned off and the guard would not veto it. The guard's own crash handler already exited 2, so the guard failed closed on its own bugs and failed open on the one condition where it enforced nothing. Switching Bun for Deno widened that path, because Deno is less often preinstalled.

The config guard now maps every state in which it cannot reach a verdict onto exit 2 — runtime absent, a module that will not load, a permission it was not granted, a defect in the guard itself. The wrapper remaps any exit outside {0, 2}, so the failure does not have to be anticipated to be caught. This also closes the availability hole the migration itself introduced: deleting the self-contained bundle means a cold cache with no network can fail before main runs, and that now blocks instead of silently permitting.

The PostToolUse lint guard had the mirror-image hole: runEntryPoint had no top-level catch, so an unexpected throw exited 1 — and exit 2 is the only code that shows a PostToolUse hook's stderr to the agent. A lint violation could be computed and then lost with the process. Guard defects there now exit 2.

Behaviour changes, stated plainly

The plan scoped product behaviour as preserved verbatim. Two things changed and the claim no longer holds unqualified:

  1. A Deno workspace with no oxlint config is now linted with deno check + deno lint instead of hard-failing with an install hint. Without this the guard could not lint its own source: every edit under claude-plugins/ exited 2. An oxlint config still outranks a sibling deno.json, so this only fires where oxlint was entirely unconfigured — pinned by the integration scenario still uses oxlint when a Deno workspace also carries an oxlint config.
  2. The exit contract above. Consumers on a machine without Deno move from edit-proceeds to edit-blocked, which is the point.

Neither is covered by the characterization baseline, and neither can be: see below.

Design decisions

Decision Why
valibot for schemas Effect Schema had to go with Effect. @std/* has no schema library, and the branded-type + one-way-transform surface the guard needs is real work to hand-roll.
fast-check kept @std/testing has no property testing. The four property suites port across unchanged in spirit.
deno.lock committed The bundle fetched nothing; source fetches @std/*, valibot and fast-check from jsr/npm on first run. The lockfile pins integrity hashes for that fetch.
Windows .cmd handling moved into the pure core It was a branch in the shell that a POSIX host can never reach, so it had no test. As invocationFor it is pure and property-tested on any OS.
No --frozen on the hooks Deno verifies lockfile integrity by default; --frozen only additionally forbids lockfile updates, and on drift it would hard-block every edit in the repo. That belongs in a CI check, not in an edit-path guard.

Declared deviation from CONST-P2

The pure core's decisions were single Match.value(...).pipe(...) expressions, which hold at cyclomatic complexity 1. Without Effect they are if/switch chains, and the oxlint gate that measured CC=1 does not run on this tree. Recorded here per CONST-W3 rather than left implicit.

The harm CONST-P2 names is branches the mutator reaches that the suite does not. Compensating evidence: five hand-run mutations across all four pure modules (decideLintPlan arm order, classifyLintResult retry gate, replaceFirst first-vs-last occurrence, applyHunks sequential-vs-parallel) were each killed by an existing property, and two more (invocationFor predicate inverted, /c dropped) were killed by the properties added here.

Validation

  • deno task check — typecheck, lint, format check, 89 tests, 0 failures.
  • Characterization replay: 49 of 49 pre-migration observations reproduced byte-for-byte. Captured from the Bun implementation before it was deleted, per CONST-T5.
  • pnpm check:local — exit 0, after the last edit.
  • Live, through the real dispatcher: the PostToolUse hook caught a type error on a write during this work, and the new fail-closed PreToolUse wrapper blocked an edit when a half-applied change made the guard uninstantiable — the exact silent pass this PR removes, observed rather than argued.
  • Fail-closed paths exercised directly: runtime absent gives 2 (was 1), guard cannot load gives 2 (was 1), benign edit gives 0, rule-disabling edit gives 2.

What the replay does and does not certify. The fake binaries in the characterization harness dispatch on filename, so the replay certifies control flow and the exit/stderr contract, not that the real linter is still invoked correctly. That is pinned separately by the integration suite, which asserts the exact argv including -c <configPath>. The two are not interchangeable, and the 49-of-49 figure alone should not be read as invocation fidelity.

Residual risks

  • --allow-run cannot be scoped: the oxlint path resolves at runtime under node_modules at unknown depth. --allow-env is now an explicit 12-key allowlist and the config guard's --allow-env was dropped entirely (it never read the environment).
  • The plugin has no CI gate yet; deno task check now runs the tests so a contributor cannot get a green signal that excludes them.
  • About 12 of the 49 fixtures share one exit-2 stderr tuple. They exercise distinct triggers, so a per-case regression still surfaces, but the honest metric is distinct observables, not case count.
  • The member deno.json carries a JSR-publishable shape; nothing publishes it today.

No changeset: scripts/check-changeset.mjs gates on packages/**, and this branch touches none.


Compound Engineering
HARNESS

ce-babysit-pr drives its watch loop through gh: its poller reads checks and the PR, both of which OMP serves natively. The guard refuses that mechanism at the shell and the skill at load, naming run_watch and pr:// in the refusal.

The rule generalises to every shell call with a native counterpart - gh reads, an HTTP client pointed at a URL, cat/ls/find/grep/sed -i, a backgrounded service, a debugger. Two carve-outs keep the shell what it is better at: a pipeline ending in a count, checksum or set difference, and a stage fed by a pipe.

Hook tests are dropped with the suite.

Declared bypass: the pre-commit hook was skipped. It dies in pnpm dep
verification against a concurrent agent npm/ tree, not on these files.
lint-staged maps these three paths to dprint alone (no oxlint.config.ts
above .claude/), and dprint check ran green on exactly them
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.

1 participant