Configuration
One config file per run, two tiers. Discovery prefers jint.config.ts and falls back to
jint.config.json; --config overrides discovery; a run with no config file fails and lists the rule domains (jint
runs only the rules a config names; capture needs none). Both tiers share one validation boundary
(validateConfig) and resolve to the same ResolvedConfig shape.
Tier 1: jint.config.json — declarative
Plain JSON (no comments, no code): data only, the engine owns every behavior, so nothing here
executes and no import gate is needed. Loaded by loadDeclarativeConfig, validated against
CONFIG_SCHEMA — the same schema emitted verbatim as the checked-in jint.schema.json
for editor autocomplete via $schema.
| key | value |
|---|---|
input |
path positional (a file or directory) |
rules |
{ include?, exclude?, use }: use holds rule names or full rule objects (a repeated name is an error unless override: true); include/exclude are the default scope of every rule; absent = error |
disable |
rule names removed after composition |
concurrency / rps_budget / timeout |
run knobs (in-flight ceiling, request-start budget, per-call timeout) |
url |
the Decisions endpoint asked (must end in /decisions); overrides JINT_API_URL |
apiKey |
the key for this run; overrides JINT_API_KEY and OPENAI_API_KEY |
url and apiKey choose where a run asks. Without them, the key you export picks the endpoint:
OPENAI_API_KEY asks the default url, and JINT_API_KEY asks JINT_API_URL
(the README has the full table). Keep apiKey out of a committed config.
A rule object is a default-rule-shaped spec: name, instructions, criteria
({true, false} for noul, an ordered rubric for kind: "score", named options for
kind: "choice"), target, handle, plus
per-rule include/exclude globs (exclude always wins; a rule whose exclude cancels its
include is a warning, never an error).
Choosing a kind. A rule is noul (a yes/no probability), score (an ordered rubric), or
choice (named options). A score answer is the expected level, and the handle rounds it to the
nearest level. Use score only when the levels sit on one axis, each worse than the one before.
Unordered categories break that read: a reading split evenly between level 0 and level 8 rounds to
level 4, a category the classifier did not choose, and the finding carries level 4’s message. For
unordered categories, use choice: the classifier spreads probability over the options, and the
handle maps each option label to a tier ({ "choices": { "<label>": "pass" | "warn" | "error" | { level, message } } }).
The tier holding the most probability is the verdict; its most probable option supplies the message.
An optional undecided: { margin, level, message? } fires when the top tier’s probability leads
the runner-up tier’s by less than margin: an undecided reading, acted on like any other tier (docs/confidence.md). A choice reports one option per unit. To
judge independent defects that can co-occur, write one noul rule per defect. Each rule is its own question with its own probability, threshold, and
message, and one unit can fail several at once. The rules on one unit batch into one classifier
call, so separate questions add no calls.
The rules scope. rules: { include, exclude, use } - include and exclude answer two
questions: which files the walk reads, and which files a rule measures when it declares no include
of its own. A rule’s own include/exclude overrides them for that rule. Top-level include/exclude
keys and a bare rules array no longer exist: a config that still has them fails with the move named.
Walk-union discovery: scopes and admission. A file is walked when rules.include
OR any rule’s include matches. A rule claims a file when its include names it AND its own
exclude does not subtract it: the claim carries across rules.exclude (the more specific
scope wins, gitignore style). Consequence: a BROAD rule include makes rules.exclude soft
for what it names — only the denylist (and gitignore) stay walls; narrow the rule include
where the exclude must win. Each rule then measures only files its own scope admits: unscoped
rules (no include of their own) follow the config scope and never measure a file only a rule
include claimed. run.input.admitted attributes every included file to its scope (config or
rule:<name>) — for walked discovery AND named operands alike.
Named files and a path list mean the same thing. jint run <file> and jint run paths -
(a piped list) are both scope-checked: each path is tested against the one config-scope test
(include whitelist AND not excluded). A path inside the config scope is measured by unscoped
rules exactly as a walked file is. A path the scope excludes is skipped by every unscoped rule,
with one note naming the file and the way out; a rule include claims it across the config
exclude, as in the walk. A path nothing claims measures nothing - a note, never silence. The run
header states the scope in effect and which of these applied.
--unscoped measures them as given. The flag drops the config scope for named files and a
path list: you named the files, so those are the files. A rule’s own include/exclude still
scopes that rule, because it records where the rule’s claim holds, and a calibrated rule does not
judge files it was never calibrated on. A directory walk is always scoped; --unscoped on one is
a usage error. The SDK option is unscoped.
Resolve failures and the denylist. A named file that fails to RESOLVE (missing path, no
grammar) throws its hard error: naming a broken file is explicit consent to hear about the break.
A path-list entry that is missing or that no grammar claims becomes a note and the run goes on.
The secret denylist is not a scope - it is an always-win exclusion and applies to named paths
too (naming a secret-shaped path does not hand its contents to the classifier; pattern-level -
the PEM content sniff stays walk-only, an innocent-named operand is explicit consent like piped
source). The str noun has no real path, so no scope applies; an element-scope run pins its
selector file, which scope-checks like any operand. Capture’s explicit FILE target bypasses the
walk scoping (it prints structure, no classifier) - but its --rule preview marker applies the
same scope predicate. jint run paths - is how to measure exactly a changeset
(git diff --name-only | jint run paths -); the pre-commit hook does.
Tier 2: jint.config.ts — imperative composition
A TS module default-exporting defineConfig({ ... }) from "jint:sdk", loaded by loadTsConfig
through the import gate (imports scanned before the module ever runs). A TS config takes the same
keys a config file takes, minus customRules — there is no customRules key anywhere.
Rule sources importable from the config:
| source | what it is | versioning |
|---|---|---|
jint:sdk |
defineConfig — the typed config entry |
virtual module, compiled-in |
jint:rules/<domain> |
one domain’s declarative specs (rules + each rule under its own name) |
virtual module; its version IS the CLI’s |
./, ../, absolute |
your own files | — |
Bare npm specifiers and node:* builtins are rejected in the config file before it runs. The
gate is a portability fence for the entry file, not a sandbox: it scans that file’s static
imports only, so a relative helper may import anything the runtime offers. The domains are
comments, complexity, errors and naming; rules.md lists every rule in each.
jint runs only the rules the config names: a config with no rules fails with the domain list.
A rule name that appears twice is an error unless the later entry sets override: true.
Inline custom rules
A rules entry that carries a callback — a classify function, a function target, or a
function handle — is an inline custom rule: it runs on the callback engine (element
inventories, free state composition) while declarative entries around it compile through the same
seam as the JSON tier. This is the repo-root pattern:
import { rules as naming } from "jint:rules/naming";
import { defineConfig, type RuleSchema } from "jint:sdk";
const is_literal_shared: RuleSchema = {/* a full declarative spec, authored in-file */};
export default defineConfig({
rules: {
include: ["**/*.ts", "**/*.css"],
use: [
...naming,
is_literal_shared,
{
name: "is_comment_paired_with_code",
target: "comments",
classify: (self, ctx) => ({/* free state composition */}),
handle: { warn: { less_than: 0.4 }, error: { less_than: 0.15 } },
},
],
},
});
The pattern above is the complete shape: a callback rule is a config rules entry whose
classify(self, ctx) returns the question for that element (instructions, criteria, and a
free-form state object the rule composes - the pairing IS the state); the repo root’s own
jint.config.ts mixes the shipped domains with an inline declarative rule the same way.
The handle may instead be a (results, ctx) => decisions function, called once after
classification with one decision per reading: { severity: "error" | "warn" | null, message? }.
null severity is silent (measurement only). message is an instruction to the caller fixing the finding; severity says whether, message says how - it renders beneath the finding line and rides the report as finding.message. Tiered decisions are natural: the handle sees the raw readings, so it can branch on them (e.g. a deep miss vs a marginal one) into different remediation instructions.
Composition rules
- Composition is in-file: one module, one default export. No
extends, no cross-file merge — rejected by design; a config that needs variants is two configs (--configpicks one). - A rule name that appears twice is an error naming both entries; replace a rule by spreading it and
setting
override: true:
runs oneexport default defineConfig({ rules: { use: [ is_greppable, { ...is_greppable, include: ["src/**"], handle: { warn: { less_than: 0.8 } }, override: true, }, ], }, });is_greppable, the tighter one. - Names are global across the two rule forms: a callback rule re-declaring a declarative entry’s
name (or vice versa) is a fatal
RULE_INVALID, not an override. disableremoves by name after composition;rules: { use: [] }alone isCONFIG_NO_RULES(a run that would measure nothing is a config error).- Scoping is per rule: each entry’s own
include/excludeglobs (exclude always wins) replace the rules scope for that rule. A ruleincludeis also a DISCOVERY scope (walk-union, above) - a rule wanting a file typerules.includeexcludes admits it by naming it; no global carve-out is ever needed.