@tortastudios/kragg-ts
v0.2.0
Published
Opinionated guardrails framework and CLI for AI-assisted TypeScript projects.
Maintainers
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:linepointers 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 checknpm 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 checkSupported 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/nodeWindows 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 coveragesurfaces uncovered lines in critical functions ranked by fan-in, instead of a gameable global percentage. Thecritical-coveragegate 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 ascritical-unmeasuredwith the cause (the test run never loaded src/x.ts) rather than passed. Works under vitest (istanbul JSON),node --testandbun test(lcov). Attribution comes from the source, keyed the waycriticality.jsonspells a name, soReader.closeandWriter.closeeach answer for their own lines. This is line coverage:if (broken) fix();on one line counts as covered once theifran; 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 — andcritical-coverageconsumes the coveragetest-coveragejust measured rather than any file on disk. Twokragg checks in one project cannot read each other's artifacts. Thetest-coveragepercentage is the project's: only files undersource_pathscount, 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 tocoverage_report_path(istanbul, vitest) or thelcov.infobeside it (node, bun);kragg coveragereads 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 mutationruns Stryker over critical files and reports surviving mutants asfile: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 specrendersdescribe/itstrings 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 recognisedfc.*/test.proptest — 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-testsandtest-qualitytie 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 adescribe-level fixture — no direct call is required) or, forcritical-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 iscritical-coverage's question and the strength of the assertions iskragg mutation's. Test files outside thetsconfig.jsonprogram have no checker view and so give no evidence; the finding names them. - what's trustworthy —
kragg flakymines the run journal for gates that flipped on an unchanged commit;--rerun Nre-runs the suite N times under the sametest_runner,test_commandandtest_pathsascheck'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 tierEvery 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 inpnpm-workspace.yamlor 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+biomescripts/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:
packagedandscaffoldsare blocking (compat.yml). They assert things this repository controls.toolsis 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 inVERSIONSinscripts/compat/toolFixture.ts, fix the parser — and must not turn an unrelated pull request red.ci.ymlkeeps the conformance job separate fromcheckfor 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.0CHANGELOG.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
