npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tortastudios/kragg-ts

v0.2.0

Published

Opinionated guardrails framework and CLI for AI-assisted TypeScript projects.

Readme

kragg

An opinionated guardrails framework for AI-assisted TypeScript projects.

kragg is agent-first: coding agents (Claude Code, Codex, Cursor, Gemini CLI) are the primary users, running check → fix loops. It gives them one command, structured results, meaningful exit codes, and copy-pasteable fixes.

This is the JS/TS sibling of the Python kragg. The two share no code. They share a contract — the same JSON report schema, the same exit codes, the same .kragg/history.jsonl journal, the same hook protocol — so a CI job or an agent can consume either without knowing which ran. See docs/spec-conformance.md.

Why

Agents write a lot of code quickly. The bottleneck is not generation, it is knowing whether what was generated is safe to keep.

Three properties matter more than the gate list:

  • One invocation reveals every failure. All fast gates run even after one fails, so an agent never re-runs to discover problem #2. Slow gates skip once a fast gate has failed, since their results would be invalidated by the fix.
  • The output is honest. A gate that could not run is exit 3, not a pass. A gate with nothing configured is reported as skipped, with the exact command to configure it — never omitted, never silently green.
  • The output is small. Violations are deduplicated, capped per gate, and reported as file:line pointers with fix hints instead of raw tool dumps. Every byte competes with the agent's context window.

Install

The package on npm is called @tortastudios/kragg-ts. The command it installs is kragg. It carries the org scope because the TypeScript sibling of a Python tool that is already called kragg on PyPI cannot use that bare name too, and the two are not the same package.

pnpm add -D @tortastudios/kragg-ts
pnpm exec kragg check

npm install --save-dev @tortastudios/kragg-ts and yarn add --dev @tortastudios/kragg-ts install the same tarball, but only pnpm installs are exercised by CI — see KNOWN_LIMITATIONS.md. From a checkout instead:

pnpm install --ignore-scripts
pnpm run build
node dist/cli.js check

Supported runtimes and platforms

package.json#engines.node says >=20. A claim about a runtime is worth exactly as much as the matrix that runs it, so here is the matrix — and the line between what is asserted and what is merely claimed.

.github/workflows/compat.yml builds the package, runs pnpm pack, installs the tarball into a throwaway project, and then, on that row's Node and operating system, runs kragg --version, kragg --help, the node_modules/.bin shim, a real kragg check whose tsc and lint gates have to spawn that project's own binaries, and a TypeScript consumer compiled against the packed .d.ts and then executed.

| Runtime | Linux x64 | Windows x64 | macOS | | --- | --- | --- | --- | | Node 20 (the floor) | asserted | asserted | not in CI | | Node 22 | asserted | asserted | not in CI | | Node 24 (the dev runtime) | asserted | asserted | not in CI |

Reproduce any row locally, on any Node you have installed:

pnpm install --ignore-scripts && pnpm run build
node scripts/compat.ts packaged --node /path/to/node20/bin/node

Windows is in the matrix for a concrete reason. A node_modules/.bin entry is a .cmd batch shim there, and since the CVE-2024-27980 fix Node refuses to spawn a batch file without a shell — which kragg will not use, because a shell would interpret the filenames gates pass as arguments. src/engine/runner.ts reads the shim and runs the script it names directly instead. Unit tests prove the argv it builds is right, on every host; only a Windows row proves CreateProcess accepts it.

Scaffolds. Each kragg new --kind output (cli, api, and mcp with both SDKs — --mcp-sdk fastmcp, the default, and --mcp-sdk official) is generated, installed with --ignore-scripts, and made to pass its own pnpm exec kragg check on every pull request and every push to main — node scripts/compat.ts scaffolds locally. The lane installs the @tortastudios/kragg-ts the scaffold pins from the tarball it just packed, so it proves the scaffold and not the registry; the scaffolding section of KNOWN_LIMITATIONS.md says what that leaves unasserted.

External tools. kragg ships none of the programs it drives. Whether seven of them — vitest, node --test, bun, oxlint, biome, ESLint and secretlint — still produce output its adapters can read is checked weekly by .github/workflows/external-tools.yml, deliberately not as a required check — see Development for why, and for how to run it.

What is not asserted anywhere is listed in KNOWN_LIMITATIONS.md.

Commands

kragg check                    # all gates, one consolidated report
kragg check --changed          # only files changed vs HEAD (cheap inner loop)
kragg check --since main       # changed vs merge-base with a ref
kragg check --file src/a.ts    # scope to specific files (repeatable)
kragg check --fast-only        # the static tier only; never runs the slow one
kragg check --format json      # stable machine-readable schema
kragg check --package @app/web # one workspace member as its own run (repeatable)
kragg security                 # the security subset, cheap enough for every push
kragg fix                      # format and safely auto-fix lint findings

kragg map                      # exported symbols — what already exists
kragg map --path src/analysis  # ...just this directory or file (repeatable)
kragg map --symbol runCommand  # ...just this exported name, or module#name
kragg map --changed            # ...just what changed against HEAD
kragg map --limit 0            # the full export (--all is the same thing)
kragg map --format json        # entries plus total / shown / truncated
kragg spec                     # the test suite rendered as a documentation tree
kragg spec --symbol coverage   # ...cases whose title or describe says this
kragg brief                    # a reviewable digest of the change set
kragg brief --path src/gates   # ...restricted to one area of the change set
kragg status                   # what failed last run, without re-running
kragg policy show              # the effective policy, resolved
kragg doctor                   # environment diagnostics with exact fixes

kragg coverage                 # uncovered lines in critical functions, ranked
kragg criticality --write      # call-graph risk -> CRITICALITY.md + .kragg/
kragg mutation                 # mutation-test critical files with Stryker
kragg flaky                    # gates that flipped on an unchanged commit
kragg audit                    # dead code and dependency drift

kragg new my-app --kind cli    # scaffold (cli | api | mcp); installs nothing
kragg new my-mcp --kind mcp --mcp-sdk official   # the reference MCP SDK, not fastmcp
kragg gen module payments      # service/domain/test slots in the layout
kragg init                     # add guardrails to an existing project
kragg init --dry-run           # ...or just print what that would change
kragg hook claude              # harness hook adapter (reads hook JSON on stdin)

kragg fix runs two passes, not one

Linting and formatting are two independent tool choices in this ecosystem, so kragg fix makes two independent detections and runs whichever halves the project actually has:

| Half | What runs | Chosen when | | --- | --- | --- | | lint fix | oxlint --fix, eslint --fix, or biome's combined check --write | the same lint_tool detection the lint gate uses | | format | prettier --write, or biome format --write | the project both installed the formatter and configured it |

biome is the one tool that covers both roles: when it is the project's linter, check --write has already formatted and no second formatter is detected. Everywhere else the two are unrelated — oxlint for lint plus Prettier for format is an ordinary pairing, and it is the pairing this repository would use if it formatted at all.

A formatter is never imposed. Prettier arrives as a transitive dependency of plenty of toolchains, and a formatter run with no config rewrites every file it is pointed at to its own defaults — on disk, in one command. So a merely installed formatter is not enough: kragg looks for .prettierrc*, prettier.config.*, package.json#prettier or biome.json in the project root, and when it finds none it says so and formats nothing. There is no format_tool policy key; the config file is the declaration.

Both halves are resolved from the project's own node_modules/.bin, and either one being absent is a visible skip naming the exact install command — the note: … does not format line is printed exactly when the run really did not format. A lint_tool that names a linter the project has not installed is still exit 3, and then nothing runs at all, formatter included.

Exit codes

Branchable without parsing a single line of prose.

| Code | Meaning | | --- | --- | | 0 | all gates passed (a skipped gate is not a failure) | | 1 | gates ran and found violations — fix the code | | 2 | usage error, or unusable config — fix the command line | | 3 | environment broken — a gate could not run; findings are unreliable |

Exit 3 outranks exit 1. A misconfigured kragg.json is exit 2, not 3: it is the same class of mistake as a misspelled flag, and the fix is one character in a file, not reinstalling a tool.

Gates

Fast — always all run, so one loop iteration surfaces everything:

| Gate | What it checks | | --- | --- | | lint | oxlint, biome or eslint — whichever the project configured | | tsc | the project's own tsc --noEmit — always the whole project through its own tsconfig.json, with --changed/--file files reported first and nothing dropped | | typing-strictness | that tsconfig.json actually meets the strict floor, has no escape hatches (@ts-ignore, as any, double casts), and covers every source file | | complexity | cyclomatic complexity per function (radon's grade bands) | | maintainability | maintainability index per file | | halstead | Halstead effort, difficulty and estimated bugs | | type-complexity | annotation nesting and length — data shapes should get names | | boundaries | the layered import contract from layers | | structure | file-length and public-symbol budgets | | forbidden-calls | project-banned APIs, resolved through the type checker | | nullable-default | \|\| mis-coalescing a legitimate 0 / "" / false | | critical-tests | a changed critical function needs a changed test that the type checker binds to it or to its module — an unrelated test edit does not count | | test-quality | no assertion-free tests; every critical function is bound by an identifier in a running test (never a comment, a string or a skipped test) | | secret-default | secrets given silent fallbacks — a blank key must fail at startup, not sign | | detect-secrets | gitleaks or secretlint — autodetected under "auto", required when secret_scanner names one |

Slow — skipped while fast gates fail:

| Gate | What it checks | | --- | --- | | test-coverage | the project's test suite, plus the coverage floor | | critical-coverage | public critical functions must be measured and have no uncovered lines | | audit | dependency vulnerabilities via the project's package manager |

Running the fast tier alone. --fast-only, on check and security both, assembles the static gates and stops there — for the agent loop that wants to clear lint, type and metric findings without paying for the suite and the advisory database on every pass. The slow gates are not in the pipeline at all, so they are absent from gates[] rather than present with skipped: true; the exit code is the fast gates' verdict alone; and stderr names what did not run, because a shorter gate list on its own reads like an ordinary run. It selects a tier, never a file set, so it composes with --file / --changed / --since and with --fail-fast, which still halts at the first failure. It is refused, exit 2, with --all — whose whole meaning is "run the slow tier anyway" — and with --update-baseline, which records a full run and would otherwise drop critical-coverage's accepted entries from the baseline file.

kragg bundles none of these tools. Every external tool is resolved from the project's own node_modules/.bin — never a global install, never kragg's own tree — so a gate always runs the version the project declared. A missing tool is a visible skip with the exact install command, not a silent pass — or, when the policy names that tool (lint_tool, test_runner, secret_scanner) or no gate can stand in for it (tsc), an error and exit 3.

Scope: what a run actually looks at

There are three scopes, resolved once per invocation (src/commands/scope.ts) and shared by check and security so the two cannot disagree:

| Mode | From | Per-file tools get | Path-aware gates get | | --- | --- | --- | --- | | full | the default | source_paths | the whole project | | changed | --changed / --since | the changed source files that still exist | the same files | | file | --file | the paths as typed (a directory included) | those paths expanded to the source files under them |

A configuration edit is a change, and it changes everything. --changed after editing only kragg.json, tsconfig*.json, package.json, a lockfile, pnpm-workspace.yaml, a linter config (.oxlintrc.*, oxlint.config.*, biome.json(c), eslint.config.*, .eslintrc*), a test-runner config (vitest.config.*, vitest.workspace.*, bunfig.toml) or the configured secret_baseline runs a full check — those files decide what every gate concludes about every file, and the incremental run used to exit 0 without running one. The report says mode: "full", and the reason is printed on stderr so the promotion is never a surprise.

A deletion is a change too. A deleted file is never handed to a per-file tool — there is no file — but a change set whose only source change is a removal also runs a full check, because deleting the module half the tree imports is the change most likely to break the build. A rename needs no promotion: its new path is in the selection and tsc compiles the whole project anyway.

An empty selection is not a failed one. A clean tree, or a commit that touched only a README.md, is exit 0 and the ordinary report with no gates. Git being unable to answer — not a repository, an unknown --since ref, no commit to diff against — is exit 3 carrying git's own message, never an empty file list. A --file that names a path which is not there is exit 2 naming it.

Which tsconfig, and which package

One tsconfig per run, named by the policy. Every type-aware surface — the shared ts.Program, the tsc gate's --project, the typing-strictness audit, the boundaries/structure alias table and the criticality freshness stamp — reads the file the tsconfig setting names (default tsconfig.json), resolved once by projectTsconfig in src/environment/project.ts. A project configured by tsconfig.base.json + tsconfig.app.json sets "tsconfig": "tsconfig.app.json" and is checked where it is configured. A configured file that does not exist is exit 2 naming it; a missing default tsconfig.json is what the gates report (tsconfig-missing, a tsc error), because nobody wrote that setting wrongly.

A solution-style tsconfig is refused, not passed over. tsc -p on a file that has references and no inputs of its own — the Vite template's root tsconfig.json — prints nothing and exits 0, and a program built from it holds no files. Before this, the tsc gate reported [PASS] over such a project while typing-strictness judged the solution file's empty compilerOptions as four violations. Now tsc, typing-strictness and every program-backed gate are error: true (exit 3) with one message naming the referenced projects and the fix: point tsconfig at the project that has inputs. The references are deliberately not expanded into N runs over one source tree — a run analyzes one project, and says which.

A workspace member is its own run. check --package <name-or-path> and security --package … (repeatable) check a member instead of the root, with the member's root, its own policy (kragg.json / package.json#kragg if it has one, else the root's policy, never the defaults), its own tsconfig setting, its own compiler (resolveTypeScript from the member — a workspace mixing TypeScript 5.9 and 6.0 uses each where it is installed, and the report says which), exactly one lazy program, and its own .kragg/history.jsonl. Members are never merged into one report: text output has one section per member plus a == workspace: … == summary; --format json prints an array of ordinary per-member payloads (each the unchanged schema, with its own targets); the exit code is the worst member's (3 over 1 over 0). An unknown --package, a member whose configured tsconfig is missing, or a malformed member policy is exit 2 before any gate runs — no member runs half a workspace. --package cannot be combined with --file, --changed or --since (a member run is the whole member). The legacy-debt baseline is the member's too: a member run reads — and, under --update-baseline, writes — the file its effective policy names, at its own root, so a member that inherits the workspace's "baseline": ".kragg/baseline.json" keeps its accepted debt in packages/a/.kragg/baseline.json and can never absorb another package's findings. A member whose effective policy names no baseline refuses --update-baseline with exit 2 before any gate runs anywhere, exactly as a single-package project does.

A root run says what it did not check. In a workspace root, check and security without --package check the root package and print on stderr which members were not checked — or why the member list could not be read. Members come from pnpm-workspace.yaml#packages (pnpm) or package.json#workspaces (npm/yarn/bun), expanded against the directories that hold a package.json. Both readers are deliberately small and fail-closed: a YAML shape beyond a block sequence of scalars, or a glob beyond literal segments, * within a segment, ** and a leading !, empties the list and names the line or pattern — never a partial list that a root run would then report as complete. A directory path still works as --package when names cannot be resolved.

Which scope each gate honours

Narrowing a gate that reasons about the whole program would make it lie, so several deliberately ignore the selection. That is documented, not accidental:

| Gate | Scope it honours | Why | | --- | --- | --- | | lint | the targets, verbatim | per-file, and the linter takes directories | | tsc | whole project, always | the selection only orders diagnostics; the error a change causes is usually in a file that did not change | | typing-strictness | the file list, for the source scan | the audit of the selected tsconfig always runs — a loosened floor must not ride in on an unrelated commit | | complexity, maintainability, halstead, structure | whole project | per-file budgets, but cheap enough to keep whole so a run cannot report a budget it never measured | | type-complexity | the file list | per-file annotation budgets | | boundaries | whole project | a layering violation is a property of the import graph, not of one file | | forbidden-calls, nullable-default, secret-default | the file list | per-file, type-aware | | detect-secrets | the file list, else the whole project | credentials hide in .env files and fixtures, not only in src/ | | critical-tests | whole project, plus its own --since diff | it compares critical functions against the test changes that bind them, across the tree | | test-quality | whole project | it answers "does a running test bind this critical function", which no selection bounds | | test-coverage, critical-coverage, audit | whole project, and they skip in incremental mode | a suite or an advisory scan means nothing partially run |

Test depth

Green checkmarks are easy to fake, so kragg looks past them with layered, mostly-deterministic signals:

  • what's run — kragg coverage surfaces uncovered lines in critical functions ranked by fan-in, instead of a gameable global percentage. The critical-coverage gate fails on any uncovered line in a critical function, and on a critical function it could not measure: a file no test ever imported has no entry in any runner's report, and that is reported as critical-unmeasured with the cause (the test run never loaded src/x.ts) rather than passed. Works under vitest (istanbul JSON), node --test and bun test (lcov). Attribution comes from the source, keyed the way criticality.json spells a name, so Reader.close and Writer.close each answer for their own lines. This is line coverage: if (broken) fix(); on one line counts as covered once the if ran; no branch verdict is implied anywhere — but a branch threshold the RUNNER enforces from its own config still fails the run, reported as the runner's (see "The runner's own coverage thresholds are reported, not absorbed" below). Both gates believe only this invocation's evidence: the runner writes into a private .kragg/runs/ directory that did not exist before the run, so a runner that crashes, times out or leaves a partial report is an error (exit 3) — never a re-read of an older report, and never the other runner's format after a switch — and critical-coverage consumes the coverage test-coverage just measured rather than any file on disk. Two kragg checks in one project cannot read each other's artifacts. The test-coverage percentage is the project's: only files under source_paths count, and every source file the run never loaded counts with all of its statement lines uncovered (kragg reads the count off the source and names the files in the gate's output), so a suite that imports three of forty modules cannot report 100%. Once read, the coverage artifact is published to coverage_report_path (istanbul, vitest) or the lcov.info beside it (node, bun); kragg coverage reads exactly the one the project's runner writes, on demand, and treats a file that is present but unusable as an error rather than as "no data".
  • what's defended — kragg mutation runs Stryker over critical files and reports surviving mutants as file:line. Accept equivalent mutants with --update-baseline; that baseline is the one .kragg/ file deliberately git-tracked, because equivalent mutants are a reviewed, shared property.
  • what's claimed — kragg spec renders describe/it strings as a documentation tree and flags critical functions with only example-based tests (property-based tests, via fast-check, kill more mutants). The property summary is a text signal: a function counts as property-tested when its name occurs in the text of a recognised fc.*/test.prop test — title, comment or code — so it says a property test names the function, not that the property exercises it or would catch a wrong answer.
  • what's linked — critical-tests and test-quality tie a critical function to test code through the type checker: an identifier in a test file, outside any skipped or todo test, that binds to the function (through an alias, a re-export, a shared helper or a describe-level fixture — no direct call is required) or, for critical-tests, to anything in its module. A name in a comment, a string or a same-named unrelated symbol is not a reference, and an edit to an unrelated test file does not vouch for a critical change. What a bound reference proves is that the test exercises the function; it is not proof of behavioural coverage — a test can bind the name and assert nothing useful. Coverage of its lines is critical-coverage's question and the strength of the assertions is kragg mutation's. Test files outside the tsconfig.json program have no checker view and so give no evidence; the finding names them.
  • what's trustworthy — kragg flaky mines the run journal for gates that flipped on an unchanged commit; --rerun N re-runs the suite N times under the same test_runner, test_command and test_paths as check's test gate — the same adapter call, so the two cannot drift — and tallies every test. A test whose outcome varies is flaky (exit 1); one that fails every time is a stable failure, reported as such (exit 1). A run that did not complete the intended suite — no runner, a crash, a timeout, zero tests discovered — is exit 3 naming what happened, never "no flaky tests".

Mutation and active flaky runs are deliberately outside kragg check: they are on-demand and CI surfaces, not inner-loop gates.

Security contracts

No scanner can infer a repository's unstated security requirements — that this codebase never shells out directly, that request bodies must be bounded. What it can do is enforce them mechanically once a human states them. That is the forbidden-calls gate's job: build the safe wrapper, then ban the raw API repo-wide with a hint naming the wrapper.

{
  "forbidden_calls": {
    "node:child_process": "spawn subprocesses through runCommand in src/engine/runner.ts"
  }
}

Resolution goes through ts.TypeChecker, so it catches what an import-based banned-API linter cannot: subclass overrides, receivers with no annotation, re-export chains, awaited dynamic imports. The wrapper's own call site is the one legitimate use, marked with a trailing // kragg: ignore -- <reason> so the exemption is visible in review rather than invisible in a config allowlist.

kragg dogfoods this: node:child_process is banned in this very repository, and src/engine/runner.ts carries the single exemption.

secret-default closes the inverse hole to a secret scanner — not a credential present in the repo, but one absent at runtime and silently defaulted.

Agent-native design

Agents drift where they have freedom, so the scaffold removes the freedom. Every kragg new kind ships one layered layout (entrypoints/ → services/ → domain/) with the boundaries gate enforcing dependency direction from the first commit, and kragg gen module creates new code in the one place it belongs.

The tool holds the memory the agent lacks: kragg map is the inventory of what exists, .kragg/history.jsonl remembers runs, CRITICALITY.md remembers risk, and kragg brief renders the change set legible to a human reviewer.

Memory the agent cannot afford to read is memory it does not have, so the inventories are focused and bounded. map, spec and brief take --path (repeatable file or directory prefixes), map and spec also take --symbol (an exported name, Class.method, or the exact <module>#<name> for map; a case-insensitive substring of a test or describe title for spec) and --changed (files changed against HEAD, the same detection check --changed uses). All three take --limit <n>, which defaults to 100 entries — --limit 0 or --all restores the complete export. map and spec also take --format json, which carries total, shown and truncated beside the entries. Ordering is deterministic: by path, then by name for map, and by path, then source order for spec, so the JSON entry order is the text order and two runs over one tree are byte-identical.

A display budget is never a scope. A truncated text render ends with showing N of M … — pass --limit 0 for everything, so a bounded view cannot be read as a complete one; map still derives the whole project's criticality graph however narrow the printed map, so no gate goes quieter; and map --write always writes the complete .kragg/map.md, refusing --path, --symbol and --changed outright — a scoped map injected at session start does not read as "part of the map", it reads as "nothing else exists". --limit is allowed with --write and trims only the terminal.

kragg brief never runs a gate. Its ## Last gate run section is a summary of .kragg/history.jsonl, says so on its own line, and states when the recorded verdict was reached at another commit or on a dirty tree — so a stale PASS cannot be read as "this change set was checked".

Scaffolding emits AGENTS.md as the canonical agent contract — read by Codex, Cursor and Gemini CLI, and by Claude Code via a CLAUDE.md pointer — plus hooks that run kragg check --changed after edits.

Exemptions are reviewed, never accumulated

Two ways exist to not fail on a finding, and both leave a trail a reviewer can read.

Suppressing one site. The native gates honour // kragg: ignore -- <reason> (or /* kragg: ignore -- <reason> */) on any line the flagged node spans. The reason is not optional: a bare // kragg: ignore suppresses nothing, and the gate reports the finding it was written over with a note naming the bare marker. The content of the reason is not judged — that is what review is for — and kragg brief lists every marker the change set added or removed, with its reason, under ## Suppressions.

Adopting legacy debt. An existing project has findings nobody will clear in one sitting. Name a baseline file in the policy and record them:

# kragg.json: { "baseline": ".kragg/baseline.json" }
kragg check --update-baseline   # full runs only; add --all to reach the slow tier

Every finding recorded there is reported as a baselined: advisory of its gate instead of failing the run, and every finding not in it fails exactly as before, so a new regression cannot hide behind old debt. Nothing on the wire changes shape: the accepted findings ride in the existing advisories list and advisory_count. The file is git-tracked — commit it, and keep .gitignore at .kragg/* followed by !.kragg/baseline.json — and only --update-baseline ever writes it, replacing the previous one so a fixed finding shows up as a deletion in review.

What a baseline can never hold, enforced in code: detect-secrets, secret-default, forbidden-calls, tsc, typing-strictness, test-coverage, critical-tests and audit findings, every errored gate and every skip. Only the metric, structure and test-quality gates (lint, complexity, maintainability, halstead, type-complexity, boundaries, structure, nullable-default, test-quality, critical-coverage) are eligible; --update-baseline names what it refused, and a hand-edited entry for any other gate is a config error. A baseline never loosens a threshold or widens an exclusion — the gates run exactly as configured and the subtraction happens afterwards, one recorded finding at a time.

An entry is identified by gate, file, code, message and a fingerprint of the flagged line's text — no line number — so it survives edits above it and goes stale when the line, the message (a metric that grew) or the file name changes. A stale entry is reported as an advisory on its gate, never dropped and never re-matched: a renamed file is a re-review, with the old entries stale and the findings at the new path failing as new. kragg brief lists the entries a change set added, removed or left stale under ## Baseline.

kragg hook claude checks what the equivalent command would check: a Stop runs the whole project exactly as kragg check does — every source_paths entry, not the first one — a post-edit run is check --file on the edited file, and a tool that edited no single file is check --changed. All three go through the one resolver in src/commands/scope.ts, so the hook and the command cannot disagree about a project's scope.

Hooks still fail open — any internal failure exits 0 and blocks nothing, because a broken guardrail must not become a broken editing session — but they no longer fail invisibly. A failure writes a line to stderr and an entry to .kragg/hook-errors.jsonl (timestamp, event, message — never the stdin payload), and the next SessionStart opens with N kragg hook failures recorded since the last session. A hook whose config broke three days ago used to be indistinguishable from a hook with nothing to say.

Configuration

Config is data, not code. There is no kragg.config.ts and there will not be one: a config file that executes arbitrary TypeScript at load time means the tool meant to guard your project runs untrusted project code before any gate has looked at it.

Settings go in kragg.json, or under a "kragg" key in package.json. A standalone kragg.json wins outright; the two are never merged. Keys are snake_case, matching the Python implementation.

Because a kragg.json wins outright, kragg init will not create one in a project that already states a policy — in either place. Writing one would not add to that policy, it would replace it, silently retiring every threshold the project had tightened. For the same reason init never adds type, engines, packageManager or private to a package.json that already exists: each answers a question the project has already answered, and adding "type": "module" to a CommonJS project breaks it outright. Run kragg init --dry-run to see the exact set of files and keys before anything is written.

{
  "$schema": "./node_modules/@tortastudios/kragg-ts/kragg.schema.json",
  "source_paths": ["src"],
  "test_paths": ["test"],
  "tsconfig": "tsconfig.json",
  "layers": ["src/cli", "src/commands", "src/gates", "src/engine"],
  "max_file_lines": 500,
  "max_public_symbols": 20,
  "structure_exclude": ["src/index.ts"],
  "coverage_fail_under": 80,
  "lint_tool": "auto",
  "test_runner": "auto",
  "secret_scanner": "auto",
  "forbidden_calls": {
    "node:child_process": "use runCommand in src/engine/runner.ts"
  },
  "critical_functions": {
    "src/auth/login#verifyPassword": "authorization entrypoint"
  }
}

tsconfig names the ONE project file every type-aware surface reads, relative to the root (default tsconfig.json); see Which tsconfig, and which package. It is TypeScript-only — Python has no equivalent knob — and it is a path, so "" is rejected like any other malformed value. In a workspace, a member with its own kragg.json reads its own tsconfig; a member without one inherits the root policy's.

Reviewed critical functions. Centrality finds what other code leans on; it says nothing about consequence. An authorization check called from one route handler, or a payment capture called once at the end of a checkout, has fan-in 1 and no betweenness — bottom of the ranked table, and invisible to every criticality-driven gate. critical_functions is where a reviewer says otherwise: each key names a function the way the call graph does (<module>#<name>, where the module is the repo-root-relative path without its extension and the name is the function or Class.method), and each value is the reason, which is required. A declaration is additive — it never demotes a function the graph selected — and from there it flows into critical-tests, test-quality, critical-coverage, kragg coverage and mutation targeting exactly like an automatically critical one. CRITICALITY.md and the terminal table grow a Why column saying which is which (declared: authorization entrypoint against fan-in 7, betweenness 0.3000), and a gate that names a declared function quotes the reason in the violation.

Rename the function and leave the entry behind, and kragg does not go quiet: kragg criticality exits 3 naming the stale entry (with the nearest matching function, when a rename is obvious) and writes nothing, and the three gates that consume the data report error: true. Silently returning such a function to "not critical" would retire a protection nobody asked to retire.

Where the tests are, and how they are run. A test_paths entry is either a directory ("test") or a pattern ("src/**/*.test.ts"), so a colocated suite is expressible: ** matches zero or more path segments, * and ? stay inside one, {a,b} alternates, and [a-z] is a class. The same entries decide what the runner is told to discover, which files test-quality and kragg spec read, and which changed files critical-tests examines for evidence — one answer, not three. (Whether an examined test actually vouches for a changed function is the checker's question, never the path's.) A directory entry still contributes everything under it (a shared test/helpers.ts is part of the suite); a pattern contributes exactly what it matches, so pointing at src/**/*.test.ts does not pull src/ into the test corpus.

Runner detection reads package.json#scripts.test to learn which runner a project uses. It does not learn the project's command, and it never runs that script: kragg builds its own argv, so a script of node --import tsx --test "src/**/*.test.ts" yields "node" and nothing else — the loader and the file selection are gone. When the suite needs a loader, a setup file or a config flag, state the invocation instead:

{
  "test_command": ["node", "--import", "tsx", "--test"],
  "test_paths": ["src/**/*.test.ts"]
}

test_command is an argv array, never a shell string. kragg spawns with shell: false, so a string would name one program with spaces in it; a string value is rejected with exit 2 and a message saying so. kragg appends the reporter and coverage flags it has to parse, plus the test_paths patterns, and does not duplicate the runner's own run token if you include it. Element 0 is resolved exactly like every other tool — the project's node_modules/.bin, or node / bun as runtimes — never from PATH, never a global install, and never a path; when it is not vitest, node or bun, test_runner must name the runner whose report format it produces. Whatever ran, the gate's output states the argv and where it came from, so kragg's reconstruction is never mistaken for the project's own script.

The runner's own coverage thresholds are reported, not absorbed. kragg passes no threshold to any runner and enforces only its own line-coverage floor — but --coverage leaves the project's own configuration in force, so coverage.thresholds in a vitest.config.ts (or --test-coverage-lines in a test_command) is still checked by the runner, over dimensions kragg does not compute, and signalled with the same exit code a failing test uses. When the runner's own report says every test passed, a complete coverage artifact came back, and the process still exited non-zero, kragg reports that as its own violation — runner-reported-failure, quoting the runner's own threshold line — and kragg check fails with it. It is deliberately a second finding beside coverage-below-threshold rather than the same one: kragg's floor and the runner's thresholds are independent checks, and merging them would produce one ambiguous number. A project with no runner-native thresholds sees no change: the runner exits 0 and nothing is reported.

A run that discovered no tests is an error, not a pass. Zero failures out of zero tests is arithmetic, not evidence, so test-coverage reports error: true and exit 3, naming the argv, the patterns it searched and the three settings that change the answer — test_paths, test_command, and "test_runner": "off" for a project that means to skip the gate.

Editor validation. The package ships kragg.schema.json, a JSON Schema that mirrors exactly the keys, types and ranges the loader enforces (a test keeps the two in lockstep; nothing is validated by a dependency). Point your editor at it with the "$schema" line above — $schema is the one key that is not a setting — and typos, wrong types and out-of-range values are flagged as you type, before kragg ever runs.

Malformed config is rejected, never defaulted. Every setting is in one of three states:

| | | | --- | --- | | absent | the default applies | | configured | honoured exactly — including deliberate opt-outs such as "layers": [], "forbidden_calls": {}, "coverage_fail_under": 0, "max_violations_per_gate": 0 (no cap), "secret_baseline": null and "lint_tool": "off" | | invalid | exit 2, no report, and a message naming the file and the setting: kragg.json#forbidden_calls[1] must be a string (got 7) |

Invalid means a wrong type ("max_file_lines": "100"), an out-of-range value (a negative count, a coverage floor above 100), a wrong shape (a package.json#kragg that is not an object), an element of the wrong type inside a list ("layers": ["src/cli", 3]), a forbidden_calls hint that is not a string, or a key kragg does not know (forbiden_calls is not a kragg setting (did you mean forbidden_calls?)). None of these ever falls back to a default: a ban list that silently reads as no bans, or a misspelled key that silently configures nothing, is a project that believes it is protected and is not. A malformed hint is rejected rather than repaired for the same reason — the ban is never dropped, and the project learns about the mistake at the one moment it can fix it.

Differences from the Python sibling

Deliberate, and documented at each site:

| | | | --- | --- | | forbidden-calls | Built on ts.TypeChecker. Resolves subclasses, unannotated receivers and re-export chains — closes most of Python's documented limitations for this gate. | | nullable-default | A redesign, not a port. .get(k, default) has no JS analogue; it targets \|\| mis-coalescing instead. | | complexity | A switch scores +1 total, not +1 per case — measured evidence, see KNOWN_LIMITATIONS.md. | | structure | Counts real export declarations, and enumerates export *, rather than Python's leading-underscore convention. | | detect-secrets | Bundles no scanner. Under secret_scanner: "auto": gitleaks, else secretlint, else a visible skip. A scanner named in the policy is required — unavailable, or installed and crashing, is error: true and exit 3, never a skip. | | audit | knip, which covers both vulture (dead code) and deptry (dependency hygiene). | | criticality | Fingerprinted by a sidecar stamp — a content hash of the analyzed sources plus kragg.json, package.json#kragg, the selected tsconfig and the resolved compiler — so stale call-graph data is re-derived rather than trusted. criticality.json itself stays byte-compatible with Python's reader. | | tsconfig setting | TypeScript-only. Names the one project file every type-aware surface reads; a solution-style file (references, no inputs) is refused as a gate error rather than type-checked vacuously. Python has one pyproject.toml and no equivalent. | | --package | TypeScript-only. A workspace member checked as its own run — own root, policy, tsconfig, compiler, program and journal — with one report per member and, in JSON, an array of them. Python has no workspace notion. | | criticality (top-20) | Python's top_n=20 truncates the analysis, so its criticality.json — the input the criticality gates enforce on — never names more than twenty functions. Here twenty is a display limit on CRITICALITY.md and the terminal table only; the sidecar carries every ranked function, so the gates enforce on the whole eligible population. Same record shape, same ranking, more rows. | | criticality --path | Scopes the printed table only. Combined with --write it is a usage error, where Python persists the scoped result — a partial criticality.json reads downstream as "everything else is uncritical". | | critical_functions | Reviewed declarations make a low-fan-in function critical in addition to the graph's own selection. Python has no such setting; the sidecar keeps its six-key record shape either way, and the reason is re-derived from the policy rather than stored. | | check --file with --changed/--since | A usage error. Python silently prefers git's file set and discards the explicit list. | | check --file <missing> | A usage error naming the path. Python runs the pipeline over a selection that matches nothing, which reads as a clean pass. | | --changed after a config-only edit | Runs a full check and reports mode: "full". Python resolves an empty Python-file selection and exits 0 without running a gate, even though the edit changed what every gate would conclude. | | --changed when the only source change is a deletion | Runs a full check for the same reason. Python drops deletions from the selection and exits 0 with nothing checked. | | --changed in a repository with no commit | Exit 3 with git's message. Both implementations ask git diff HEAD; Python (and kragg-ts before this) took the failure as an empty diff and silently checked only untracked files. | | non-ASCII changed paths | Every git plumbing call is -z, so a path like src/café.ts survives. Python's core.quotePath output escapes it, the escaped name matches nothing on disk, and the file leaves the selection silently. | | secret_name_suffixes | Includes ServiceKey, which Python's default list lacks. | | pipeline halting | A skip never halts the slow tier or --fail-fast; only a gate that ran and did not pass does. Python branches on not result.passed, which counts a visible skip as a failure. | | check/security --fast-only | Runs the FAST tier alone, with the slow gates absent from gates[] rather than skipped in it. Python has no way to ask for a tier: --fail-fast stops at the first failure and the slow tier runs whenever the fast one is clean. No key moves — the run is simply a shorter pipeline — and the fact is stated on stderr. | | a gate that throws | Reported as that gate's error: true — the rest of the pipeline still runs and the consolidated report survives. Python lets the exception kill the process. | | config validation | Python degrades a mismatched value to its default and ignores unknown keys; kragg-ts rejects both with exit 2, naming the setting. Strictly narrower: every config Python accepts and reads as written loads identically here. | | criticality-dependent gates | Derived on demand when the data is missing or stale, so critical-tests and test-quality run; Python skips them visibly instead. | | test evidence for critical functions | critical-tests accepts a changed test only when the checker binds it (or a test-tree module it imports) to the changed function or its module; Python passes on any change under tests/. test-quality's critical-untested requires an identifier bound to the function outside a skipped test; Python's is a substring search of the test text. A private/protected member of an exported class is the one thing a test file may not name, so for that member — and only for that demand — the evidence is a path instead: it is accepted when the call graph puts it downstream of something a running test binds, and reported when it is not. It stays in the population critical-tests and critical-coverage enforce on. Both fail as error: true when the program cannot be built, and a test file outside the program is named as unresolvable rather than text-matched. | | SessionStart hook | Emits the hookSpecificOutput envelope, which is what injects additionalContext; Python prints plain-text context lines. | | hook output | Capped at 9000 characters with an in-band marker, because the harness spills longer output to a file the model never sees. Python does not cap. The cap applies to a block reason and to a SessionStart additionalContext alike, and truncates the text, never the JSON envelope. | | Stop hook scope | The same full check kragg check runs — every source_paths entry — resolved by the same src/commands/scope.ts. Python's _stop passes source_paths[0], so in a project with more than one source directory the hook's per-file tools never open the rest and a turn can end green over them. Post-edit runs go through the same resolver as check --file and check --changed. | | hook internal failures | Still fail open — exit 0, nothing blocked — but recorded: a stderr line, an entry in .kragg/hook-errors.jsonl (timestamp, event, message; never the stdin payload), and a first line in the next SessionStart context saying how many failures happened since the last session. Python leaves no trace, so a hook that has stopped working looks exactly like one with nothing to say. | | test discovery | A test_paths entry may be a pattern (src/**/*.test.ts) as well as a directory, and one rule answers "is this file part of the suite" for the runner, the test-depth gates and critical-tests alike. Python's test_paths are directories, and its pytest invocation does not pass them at all — pytest discovers by its own rootdir convention. | | test invocation | test_command states the exact argv, as an array; a shell string is rejected. Python has no counterpart and needs none: it builds one pytest command, and pytest reads Python without a loader flag. Runner detection here concludes only which runner, never an equivalent command. | | a zero-test run | error: true and exit 3, naming the argv and the settings that change it: a completed run that discovered nothing verifies nothing. Python passes --cov-fail-under to pytest and reads its exit code, so the case is not distinguished as its own outcome. | | test evidence | Python reads .kragg/coverage.json from a fixed path. kragg-ts gives every invocation its own .kragg/runs/ directory, refuses anything incomplete, and hands critical-coverage the coverage in memory. Same gates, same wire format; only the provenance rule differs. | | unmeasured critical functions | Python's critical-coverage passes a critical function the report never mentions (measured=False), reasoning that a missing entry is a measurement-key mismatch. kragg-ts hands the gate the document its own run wrote, so a missing file was never loaded: the function fails under the additive code critical-unmeasured, with the cause in the message. | | the runner's own thresholds | Python passes --cov-fail-under to pytest, so pytest's exit code IS kragg's floor and there is no second threshold to lose. The JavaScript runners are given no threshold but still read the project's own config, so kragg-ts reports a non-zero exit over a passing report with complete coverage as the additive code runner-reported-failure, beside coverage-below-threshold and never merged with it. | | coverage denominator | Python's pytest --cov=src instruments every file under src, loaded or not. The JavaScript runners report only what the run loaded, so kragg-ts reconciles the number against source_paths itself: unloaded files count as uncovered by their statement lines, and files outside the source paths do not count. | | // kragg: ignore | Requires a reason: // kragg: ignore -- <reason>. A bare marker is not honoured and is reported on the finding it tried to hide. Python's # kragg: ignore needs none. | | legacy-debt baseline | kragg.json#baseline plus check --update-baseline records accepted findings of the metric, structure and test-quality gates; they become baselined: advisories and new findings still fail. Python has no equivalent; no wire key is added. brief gains ## Suppressions and ## Baseline. |

Each row is pinned by a fixture or a unit test, and the full list — with the spec/SPEC.md row it corresponds to — is in docs/spec-conformance.md.

Supply chain

One runtime dependency (typescript. You cannot parse TypeScript without the TypeScript compiler) and two dev dependencies (@types/node, and oxlint for kragg-ts's own self-check), all pinned to exact versions. No bundler, no test framework: tsc emits and node:test runs. The installed tree is larger than those three names, and the policy says exactly how much: @types/node brings undici-types, and oxlint is a prebuilt native binary that declares one optional package per platform, of which pnpm installs the host's — 23 lockfile entries, five on any one machine. See docs/dependency-policy.md for the inventory and the full reasoning.

Installs run with dependency lifecycle scripts disabled, no package may run a build script, and a 30-day minimum release age is enforced mechanically — so a compromised version caught and unpublished within the usual window is never installable here.

pnpm v11 reads only auth and registry settings from .npmrc. Every behavioural setting must live in pnpm-workspace.yaml or it is silently ignored — which is worse than absent, because it creates false confidence.

The standing policy, and the list of deliberately-deferred candidate dependencies, is in docs/dependency-policy.md. Read it before touching package.json.

Honest scope

KNOWN_LIMITATIONS.md states what each gate does not catch, in detail. It is worth reading before trusting a green run.

The metric gates' thresholds are radon's and Python kragg's, ported onto a language they were not drawn against. docs/calibration.md records what they actually do on real TypeScript — violation rates, distributions, a per-finding precision assessment and remediation cost, for a CLI, a Next.js app, a pnpm workspace and the three scaffolds — and scripts/calibrate.ts re-derives the numbers on your own projects. Nothing in that document changes a threshold; the proposals it makes are marked as not applied, because moving one of these numbers is a policy decision shared with the Python implementation.

The short version: kragg checks the properties a machine can check cheaply and deterministically. It says nothing about whether the code does the right thing, whether the architecture suits the problem, or whether the tests test the behaviour that matters. A green kragg check means the floor held. It is not a review.

Development

Requires Node 24 (see .node-version) and pnpm, pinned via corepack from package.json#packageManager. The published package targets Node 20+; the dev loop needs 24 because tests import .ts sources directly through Node's native type stripping, with no build step.

pnpm install --ignore-scripts
pnpm run typecheck
pnpm run build
pnpm run test
pnpm run conformance             # the cross-language contract fixtures
pnpm run regressions             # the end-to-end fixture projects (needs a build)
pnpm run selfcheck               # kragg checks itself, and lists what did not run
node dist/cli.js check --all     # kragg checks itself

# measure the metric gates against real projects (see docs/calibration.md)
node scripts/calibrate.ts kragg-ts=. 'app=../some-app:src,lib'

# compatibility: the PACKED package, the scaffolds, the real external tools
node scripts/compat.ts packaged --node "$(command -v node)"
node scripts/compat.ts scaffolds
node scripts/compat.ts tools --only vitest+biome

scripts/compat.ts is the whole of the compatibility evidence, and CI runs exactly these commands — so a red matrix row is reproduced locally by copying the command out of its log. Each run prints the Node it tested, the platform it tested on and the tarball it installed, because a compatibility report whose log does not name what it ran is a report nobody can check.

The three lanes are governed differently on purpose:

  • packaged and scaffolds are blocking (compat.yml). They assert things this repository controls.
  • tools is advisory (external-tools.yml): weekly and on demand, continue-on-error, never a required check. It installs the real vitest / node / bun / oxlint / biome / eslint / secretlint at pinned versions and asserts the adapters parse their current output. Everything it checks depends on software this repository does not control, so a break there is a maintenance ticket — bump the pin in VERSIONS in scripts/compat/toolFixture.ts, fix the parser — and must not turn an unrelated pull request red. ci.yml keeps the conformance job separate from check for the same reason.

A lane never reports a skip as a pass: a row whose tool is missing prints SKIP with the reason, and --strict (how the advisory workflow runs) turns that into a failure.

pnpm run conformance is also part of pnpm test. The other half of the contract — this repository's dist/cli.js run against the Python sibling's own fixtures at a pinned commit — is in docs/spec-conformance.md and in CI.

The release gate

ci.yml's release-gate job asks two questions the unit suite cannot, and it asks them of dist/cli.js — the built artifact, never src/cli.ts.

pnpm run regressions drives one real fixture project per closed false-green defect (test/fixtures/regressions/, test/regressions.test.ts) through the packaged CLI: a project whose linter is missing must not silence the slow tier; an unchanged caller's type error must survive --changed; a crashed runner must not pass on last week's coverage report; twenty-five critical functions must all be enforced when twenty are printed; a malformed policy value must be rejected rather than defaulted; and so on. Each case asserts the process exit status, the fields of the JSON payload and the files the run left in .kragg/ and coverage/. No case records a snapshot: a golden proves output has not changed since it was captured, which is not the claim that a defect is still fixed, and its usual failure mode is to be re-recorded.

pnpm run selfcheck runs check --all on this repository and refuses the summary line as evidence. 17 passed, 0 failed, 1 skipped and 12 passed, 0 failed, 6 skipped both read as green, so the job prints every check that did not run, with its reason, on every run — and fails unless each one matches a reviewed entry in scripts/selfcheck/expectations.ts. An entry pins the gate name and a substring of the skip reason, so detect-secrets skipping because no scanner is installed is accepted while the same gate skipping because someone set secret_scanner: "off" is not. A gate that could not run (error: true) always fails the build. Today there is exactly one entry: a stock CI runner has no gitleaks and kragg bundles no scanner. Installing one in CI was considered and deliberately not done — it would trade a visible, enumerated skip for a third-party binary fetched on every run.

kragg-ts passes its own check. That is the point: a guardrails framework whose own gates are red has no claim on anyone else's code.

AGENTS.md is the contract for agents working in this repository, including the hard rules that are not open to interpretation.

Releases

A release is a git tag of the form vX.Y.Z that matches package.json#version. Pushing the tag runs .github/workflows/release.yml: typecheck, build, test, the local conformance fixtures, then npm publish and a GitHub release with generated notes. There is no manual publish step and no separate version bump commit; the tag and the version are the same fact.

To cut a release:

# bump package.json#version first, commit it, then:
git tag v0.1.0
git push origin v0.1.0

CHANGELOG.md follows Keep a Changelog. Move the ## [Unreleased] bullets into a new ## [X.Y.Z] - YYYY-MM-DD section as part of the version bump commit, so the tagged commit and the changelog agree about what shipped.

License

MIT © Torta Studios, LLC