Skip to content

RFC: Stack connectors, decouple the quality gate from JS/TS #6

Description

@SamuelDenani

Summary

Move everything ecosystem-specific in the gate behind a connector contract, so the core (ratchet, baseline, evaluation, reporting, PR comment) never knows which language it is gating. The current JS/TS support becomes the first connector, extracted with zero behavior change. A second connector for a different ecosystem proves the seam.

Motivation

Loopwright currently refuses to install without a package.json and assumes a Node toolchain throughout. I want to use it on non-JS repos (Go, and others later) without forking the engine per language.

The good news from reading the code: the seam is half there. Each collector already resolves an adapter from a registry (.loopwright/scripts/adapters/, contract { name, collector, defaultCommand, collect(ctx) }, writing normalized JSON into reports/), and evaluate.mjs works on a flat metric map. The coupling is concentrated in a few places:

Coupling point Where Why it is JS-bound
Source analysis (shape, size, integrity) lib/analyze-source.mjs Built on the TypeScript compiler API. Complexity, function length, nesting, params, file LOC and every integrity.* metric come from it. Biggest coupling.
Coverage lib/collect-metrics.mjs Reads coverage/coverage-summary.json (istanbul shape).
Collector set and labels config.json collectors, shell.mjs REPORT_FILES typecheck is labeled "TypeScript errors"; report files are named after JS tools.
Stack detection detect-stack.mjs Only reads package.json and JS marker dirs.
Install install.sh Exits when there is no package.json.
CI .github/workflows/quality-gate.yml Sets up Node and installs host deps with npm/pnpm/yarn.

Goals / Non-goals

Goals:

  • A documented, versioned connector contract covering: detection, collector adapters, source analysis output, coverage normalization, and the CI toolchain setup step.
  • connectors/js extracted from the current code with no change in behavior: the existing 148 tests and this repo's own baseline pass unchanged.
  • One non-JS connector working end to end on a real repo.
  • Installer detects the connector, asks to confirm, and allows a manual override.
  • Toolchain installation behind a provider contract, with mise as the default provider and no host forced to use it.
  • A host with no mise installed gets it bundled and confined to .loopwright/, with zero setup.

Non-goals:

  • Supporting every language. Two connectors are enough to prove the contract.
  • Changing gate semantics (ratchet, tolerances, hard floors, grandfathering).
  • Rewriting the engine in another language. The engine is the harness runtime; whether the host needs Node in CI is an open question, not a goal.

Proposed approach

Core keeps: evaluate, baseline and ratchet, report, sticky-comment, the metric vocabulary and its semantics, quality-gate.mjs as the single blocking step.

A connector provides:

  1. detect(hostRoot) returning a confidence and a proposed sources/collectors config.
  2. Adapters per collector, writing to a normalized report schema owned by core (not named after a tool).
  3. A source analyzer emitting the contract core already consumes:
    { functions: [{ file, line, name, complexity, lines, depth, params }], files: [{ file, codeLines }], integrity: { <kind>: [{ file, line, snippet }] } }
  4. Coverage normalized to the core shape (lines/branches/functions/statements, per file).
  5. A declaration of the tools it needs (e.g. node@22, go@1.23, golangci-lint), in a neutral form. A connector never installs anything itself.

Toolchain providers (mise by default): installing what connectors declare is a separate pluggable layer, the same way a collector picks an adapter. mise is the default provider, the way eslint is the default linter that framework starters ship with: recommended and wired out of the box, never required. The host does not need to have mise installed to get it.

Provider What it does
mise (default) Installs declared tools with mise. Uses the host's own mise when present, otherwise a bundled copy (below). Same definition locally, in CI and in the runtime image.
native Uses what is already on the machine, and the ecosystem's own setup actions in CI (actions/setup-node, actions/setup-go). For hosts that do not want any mise, or machines that block downloading binaries.
custom Runs a user-supplied install command. The escape hatch for nix, asdf, a company base image, anything else.

Bundled mise: when the host has no mise, loopwright brings its own, fully contained in .loopwright/:

  • install.sh downloads the mise version pinned by loopwright for the host's OS and architecture, verifies its checksum, and places it in .loopwright/bin/. The binary is never committed: .loopwright/bin/ and the tool cache are added to .gitignore, the same way .loopwright/node_modules/ already is. The update path replaces the binary only when the pinned version changes.
  • mise's data, cache and config locations are redirected via its environment variables, so installed tools live in a gitignored cache under .loopwright/ and the tool declaration lives in .loopwright/mise.toml. No mise.toml appears at the host root, and a global mise the user might install later is never touched.
  • Loopwright invokes every tool through mise exec --, so no shell activation is needed by anyone.
  • Bumping the bundled mise version is a loopwright release decision, never something that changes on its own.

Resolution order: host mise (reads the host's existing mise.toml or .tool-versions) → bundled mise → whatever toolchain.provider the config names as an override. The CI workflow stops hardcoding Node setup, delegates to the provider, and caches the tool directory so each run does not re-download toolchains.

Migration order: define the contract and normalized schemas first, move JS code into connectors/js behind it, prove no behavior change, then build the second connector.

Detection: auto-detect plus confirm in the installer ("found go.mod, use the go connector?"), with the config file as the override. Polyglot repos map connectors to source roots.

Second connector: Go is the natural candidate because there is a real Go repo available to test on (fin-buddy), and Go's toolchain (go vet, go test -cover, golangci-lint) maps cleanly onto the collectors.

Alternatives considered

  • Generic analyzers instead of per-language ones (tree-sitter for integrity patterns, lizard for complexity across many languages). Could cut per-connector work a lot. Worth evaluating as the default analyzer inside connectors rather than a replacement for them.
  • Installer asks the language, no detection. Simpler, but friction on every install and wrong by default on polyglot repos.
  • Keep JS-only and fork per language. Rejected, that is the coupling this RFC removes.

Risks

  • Lowest common denominator. Some metrics do not exist in every language (Go has no separate typecheck step; "focused tests" is a JS idiom). Unsupported metrics must report unconfigured, never a clean zero, consistent with the existing rule.
  • Baseline compatibility. Existing hosts' metric keys must not change, or every baseline breaks.
  • Integrity detection quality. Suppression and skip patterns per language are easy to get subtly wrong; false negatives silently weaken the gate.
  • CI matrix growth as connectors multiply, now multiplied by providers. Supporting providers beyond mise, native and custom should need a real request, not speculation.
  • Bundled binary supply chain. Downloading an executable at install time needs a pinned version, a verified checksum and a clear failure (fall back to native with a notice, never run an unverified binary).
  • Host mise vs bundled mise. When the host has its own mise, its version may differ from the one loopwright pins and tests against. Needs a minimum supported version check.
  • Provider drift. Two hosts with the same connector but different providers may resolve slightly different tool versions; the gate must record which provider and versions produced a report.

Task breakdown (link sub-issues)

To be produced by /grill-rfc.

Open questions

  1. Connectors in-tree (.loopwright/connectors/<name>) or installable packages?
  2. Per-language analyzers, or tree-sitter/lizard as a shared default?
  3. One connector per repo, or per source root from day one?
  4. Should the host's CI still need Node to run the engine, or should the engine ship as a single binary/container later?
  5. How do metrics that do not exist in a language appear in the PR comment, so "not measured" never reads as "clean"?
  6. Neutral tool declaration format: mise's own syntax (since it is the default), or a loopwright format that each provider translates?

Generated by Claude Code


Boundary document

The core/detail boundary this RFC is argued against is #5, refined into
docs/loopwright/principles.md (task #11). Core opinions are cited as P<n>,
detail territory as T<n>.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    rfcRFC: top-level design and intent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions