jint

BYOG — bring your own grammar

The nine embedded grammars are not special. Each is a directory of three artifacts registered through the same shape any custom grammar uses at runtime — and the same registration is open to you. This page is the seam’s contract, a complete worked example (verilog, the hard case), and the measured limits. The product here is not “a semantic linter for hardware”; it is a seam: a domain team encodes its review questions as rules and points them at its own grammar. Verilog is the proof that the seam survives a language with none of the usual programming-language primitives.

The seam contract

Each grammar is ONE DIRECTORY under the grammar dir ($JINT_GRAMMAR_DIR, default ~/.jint/grammars); the DIRECTORY NAME is the grammar id (nextjs-route semantics), cross-checked at load against the wasm’s own tree_sitter_<name> export - a copied wasm under the wrong dir is a loud setup error naming both. The three artifacts sit flat inside the dir:

file what it is
<id>.wasm the compiled tree-sitter grammar (compile the grammar repo with tree-sitter generate && tree-sitter build --wasm); its export MUST be tree_sitter_<id> - the cross-check. Absent: an embedded grammar of the same id is shadowed for extraction only.
extensions.json routing: a bare JSON array of dot-prefixed extensions, e.g. [".v", ".sv"]. There is no version key - git owns versioning (the grammar dir IS a checkout).
queries.scm tree-sitter query patterns assigning jint captures — the extraction half. Shape directives (#set!) live here too, FILE-scoped - one name, one value per file. Write the directive beside the pattern that motivated it; a second pattern wanting a different value for the same name is a load error naming both lines (the directive is read per file, not per pattern).

The .scm IS the interface. A capture your queries emit is rule-facing - addressable through the qualified capture address <lang>:<capture> (e.g. markdown:section) - once your .scm DECLARES it. There are two tiers:

  • Shared captures - function, method, class, type, binding, comment, import, docstring - the cross-grammar lingua franca. Emitting one needs no declaration: it carries the shared engine defaults (a definition among them; comment/import/docstring are lanes). Promoting a NEW word into this tier is a ceremony - it is a cross-grammar promise, documented in docs/designs/grammar-loading.md; do not do it for one grammar.
  • Autonomous captures - any name your grammar owns (section, list, yours). Declare it with ONE directive line and it is an element the same hour: (#set! "capture.<name>.definition" "true") attached to one of its patterns. Optional capture.<name>.* knobs carry the rest of the shape (nesting, opacity, merging): docs/directives.md lists them. Undeclared defaults for an autonomous capture: hide nothing, absorb nothing. This is the whole authoring surface - ZERO engine edits; the standing proof is tests/capture-directives-e2e.test.ts, a fixture grammar that mints a new capture and measures it through a rule end to end.

No second list to maintain: there is no manifest export declaration and no registry; the derivation is syntactic (a capture bound only under a predicate still counts; ; comment lines naming a capture do not). Element captures only: @name and reserved tokens are never rule-facing. A capture that is neither shared nor declared fails the grammar at load naming both doors and the exact directive to add - a typo’d shared name cannot silently become an autonomous capture. Validation is loud at load - an address naming an unknown grammar, or a capture a loaded grammar does not emit, is a setup error naming the loaded grammars or the emitted set (e.g. markdown emits: section), even when that grammar’s files never walk the run.

Registration happens at lint start. A user wasm wins over an embedded grammar of the same language; a queries.scm+extensions.json dir without a wasm shadows an embedded grammar. The SHARED capture vocabulary is the contract: the seam accepts ANY grammar, and a shared capture brings the whole ontology with it - the rules, the target table (docs/targets.md), and every shipped rule work on your language unchanged. Your own captures need only their declaration directives. Your .scm decides which of your grammar’s node types speak as which capture.

What you will meet when validation fails

Everything fails loud, at setup, before any file is measured — never into silence:

  • Unknown node types: queries.scm names a node type the grammar does not have. Query compilation rejects it; the error quotes the compiler message verbatim.
  • BREAKING (grammar user grammars on main): @section, @paragraph, @list, @list_item in a USER shadow grammar’s queries.scm were loadable names on main; after the capture-directives migration they are autonomous captures like any other and fail load WITHOUT their declaration directives. Migration: add one line per capture - (#set! "capture.section.definition" "true") attached to a pattern binding @section - and the grammar measures as before. The embedded grammars already carry theirs.
  • Undeclared captures: a capture that is neither SHARED nor declared by a capture.<name>.definition directive. The run refuses to start, prints your captures next to the shared eight, and names the exact directive to add - an undeclared capture would parse into elements no rule can address, so it is a config error by name. A capture directive naming a capture the .scm does not emit is the same loudness (directive and patterns disagree).
  • Malformed or unknown #set! directives: a #set without the bang, a #set! with no quoted name, or a #set! "unknown" naming no known directive is a setup error naming the line - a directive that silently no-ops is a false belief about your own extraction. The full list, generated from what the loader accepts: docs/directives.md. Every directive is FILE-scoped: one name, one setting per file - docstring.owner cannot be next for one pattern and enclosing for another (the rust ///-vs-//! split is not expressible today; a real grammar needing per-pattern values reopens the design).
  • Docstring texts are measured: a definition’s pair frame carries the TEXT of the docstrings it owns. An owner=next docstring (a leading ///-style doc, outside the definition’s span) flows into the frame beside the attached run; a body docstring (python’s string-in-block) rides the code slice. Either doc convention maps to @docstring without the text going silent - the docstring capture is safe for leading-doc languages. Unknown predicate forms (#eq?, #match?, …) stay tree-sitter’s business.
  • Pattern-order conflicts: two patterns assigning different captures to the same root node type. Pattern order is priority (the earlier pattern wins), so the validator requires every pattern in a conflicting group to state its intent with a ; @priority comment — reordering must be a visible decision, not an accident.
  • Unbalanced .scm: the compiled pattern count must equal the patterns parsed from the file (a stray paren or quote the tree-sitter parser tolerated is caught by count).
  • Artifact problems: a missing extensions.json or queries.scm, malformed extensions.json (not an array, empty, not dot-prefixed), a wasm that is not a valid module or exports no tree_sitter_* function, or a wasm export that disagrees with the directory name.

The worked example: verilog, end to end

Everything below was run against the CURRENT binary through the seam, not against a verilog that used to be embedded. The artifacts are recoverable verbatim from commit fe3653d (the last commit carrying the embedded grammar) — that is the point: a grammar that was embedded and a grammar a user drops in are the same shape.

extensions.json:

[".v", ".sv"]

verilog.wasm: compiled from tree-sitter-verilog 1.0.3 (you bring your own compiled wasm — jint never fetches or builds grammars; the network stays out of the measurement path).

queries.scm — the whole extraction surface, 32 lines:

; verilog: extraction patterns over tree-sitter-verilog 1.0.3 (SystemVerilog).
;
; THE HONEST-STRETCH CALL (steer: judge always_construct fresh): always_construct -> @function
; is KEPT as honest, not a stretch. An always block is a procedural behavioral unit with a body
; and an event control header - the closest thing verilog has to a function-with-body; the code
; captures describe it without lying. initial/final_construct: same shape, same call.
;
; WAITING (recorded, not stretched; the trial's vocabulary ledger):
; - module_instantiation (u_counter): a hardware unit wired in - no honest home in the five; the
;   ledger's future module/port/instance captures. Transit today: the wiring is invisible.
; - net/reg declarations name no single name when they declare vectors lists; captured whole as
;   bindings (a name binding to a wire). parameter_port_list stays inside its module element.
; - packages/interfaces/programs: same container shape as module - add with the vocabulary.

; --- hardware units -----------------------------------------------------------------------------
(module_declaration (module_header (simple_identifier) @name)) @type
(module_declaration) @type

; --- behavior -------------------------------------------------------------------------------------
(always_construct) @function
(initial_construct) @function
(final_construct) @function
(function_declaration (function_body_declaration (function_identifier) @name)) @function
(task_declaration (task_body_declaration (task_identifier) @name)) @function

; --- state -----------------------------------------------------------------------------------------
(net_declaration) @binding
(data_declaration) @binding
(continuous_assign) @binding

; --- comments ---------------------------------------------------------------------------------------
(comment) @comment

Read the honest-stretch comment: an always block is not a function, but it is the closest thing verilog has to a function-with-body, and the code capture describes it without lying. THAT judgment — which node types may speak as which capture without the capture lying — is the authoring work. Everything else (comments attach, defaults fire, the target table applies) is inherited.

The rules: two review questions

From a field trial’s adversarial review, exactly two of the proposed rules earned their place — the ones no conventional linter can ask. Each rule is a JSON object in the config’s rules array; both target elements the queries.scm above produces:

is_case_directive_honest — parallel_case/full_case assert gate-level promises. Verilator honors the directive; it never verifies the promise. The scored reading below (0.63, warn) is presented as a REVIEW PROMPT, not a warning: a human looks at the case block and decides.

[
  {
    "name": "is_case_directive_honest",
    "target": "functions",
    "include": ["**/*.v", "**/*.sv"],
    "instructions": "This unit is a Verilog procedural block containing a case statement marked with a synthesis directive comment (parallel_case and/or full_case). parallel_case asserts that no two case arms can match simultaneously - the selector values are mutually exclusive (one-hot). full_case asserts every possible selector value is covered. Judge whether the ASSERTION IS TRUE of the visible selector and arm patterns: a case over a multi-bit signal that can hold a value matching two arms (e.g. req=2'b11 matching arms 01 and 10, or overlapping arms) makes parallel_case a lie that changes gate-level behavior; uncovered values with no default make full_case a lie. Answer true when a directive asserts something the selector and arms do not guarantee.",
    "criteria": {
      "true": "A parallel_case or full_case directive whose claim the selector values and arm patterns do not actually guarantee (overlapping matches, uncovered values, an ordinary encoded selector treated as one-hot).",
      "false": "No directive present, or every directive's assertion holds for the visible selector and arms."
    },
    "handle": {
      "warn": {
        "greater_than": 0.5
      },
      "error": {
        "greater_than": 0.7
      }
    }
  }
]

is_reset_design_consistent — a block-crossing module judgment: whether a module’s sequential blocks mix reset styles. No per-line tool can ask it; it needs the whole module as one unit.

[
  {
    "name": "is_reset_design_consistent",
    "target": "types",
    "include": ["**/*.v", "**/*.sv"],
    "instructions": "This unit is a Verilog module. A module's sequential blocks should follow ONE reset design: all registers reset asynchronously (posedge clk or negedge rst_n) or all reset synchronously (posedge clk only, reset as a branch inside). Mixing the two styles for different registers in the same module releases the groups at different times after reset deassertion - a classic one-cycle meta-behavior source. Answer true when this module contains sequential blocks with MIXED reset styles.",
    "criteria": {
      "true": "The module mixes asynchronous and synchronous reset styles across its sequential blocks.",
      "false": "One or zero sequential blocks, or all sequential blocks share one reset style."
    },
    "handle": {
      "warn": {
        "greater_than": 0.5
      },
      "error": {
        "greater_than": 0.7
      }
    }
  }
]

Test evidence

Healthy and defective fixtures (inline below, self-contained — no corpus is committed). The recorded test run scored 0.63 (warn) and 0.97 (error); a rerun through the CURRENT binary via the seam reproduced the verdicts (0.58 warn, 0.98 error) — 7 classifier calls for two small files. The recorded-test numbers stand in the table:

rule defective reading healthy max verdict
is_case_directive_honest 0.63 (warn) 0.17 fires on the lying directive
is_reset_design_consistent 0.97 (error) 0.10 fires on the mixed-reset module

The thresholds sit inside both gaps (0.17 / 0.63 and 0.10 / 0.97). The case-directive gap is narrower — it is a warn-level review prompt by design, not an error-level gate.

healthy.sv:

// Healthy: every semantic question answers clean.
module crc_generator (
  input  wire       clk,
  input  wire       rst_n,
  input  wire       data_in,
  input  wire       enable,
  output reg  [7:0] crc_reg
);
  // Advances the CRC one bit per clock when enable is high;
  // resets to all-ones asynchronously.
  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      crc_reg <= 8'hFF;
    end else if (enable) begin
      crc_reg <= {crc_reg[6:0], crc_reg[7] ^ data_in};
    end
  end

  // one-hot selector: exactly one grant at a time - the parallel_case
  // assertion below is true.
  reg [1:0] grant;
  always @* begin
    case (grant) // parallel_case
      2'b01: crc_sel = 1'b0;
      2'b10: crc_sel = 1'b1;
      default: crc_sel = 1'b0; // full coverage
    endcase
  end
  reg crc_sel;
endmodule

deceptive.sv:

// Defective: every semantic question should fire.
module t_logic (
  input  wire       clk,
  input  wire       rst_n,
  input  wire [1:0] req,
  output reg  [1:0] gnt,
  output reg        done
);
  // Shifts the output left on the clock NEGEDGE for clean capture.
  always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
      gnt <= 2'b00;
    end else begin
      gnt <= req;
    end
  end

  // NOT actually parallel: req=2'b11 matches two arms; priority takes arm 1,
  // but the directive promises synthesis no duplication.
  always @* begin
    case (req) // parallel_case
      2'b01: done = 1'b0;
      2'b10: done = 1'b1;
      2'b11: done = 1'b0;
    endcase
  end

  // Mixed reset design: this block resets SYNCHRONOUSLY while the
  // module's other block resets asynchronously.
  reg [3:0] sync_ctr;
  always @(posedge clk) begin
    if (!rst_n) sync_ctr <= 4'd0;
    else sync_ctr <= sync_ctr + 4'd1;
  end

endmodule

Where the seam stops today: the oversized backstop

Measured against this exact setup (do not take our word for the sentence): the element size backstop is 30,000 estimated tokens = 120,000 characters (chars/4, walk.ts MAX_ELEMENT_TOKENS). A 3,994-line module (97k characters) MEASURED fine. A 4,850-line module (element 122,746 characters) was SKIPPED — flagged in a run note, never judged, and the run reports honestly that nothing was measured. Large modules near and over ~4,000–5,000 lines are skipped today; element-aware chunking is future work. Realistic hand-written RTL (hundreds of lines) sits far below the cap; a synthesized netlist would not measure. Per-PR-diff is the economically sane deployment for the same reason the cap exists: semantic rules need module context (~5 classifier calls per module), and whole-tree does not scale.

Calibration sidebar: the wording loop

Test 1’s first wording produced a false positive on a HEALTHY edge-triggered block — the instructions described reset behavior loosely enough that an ordinary async-reset block looked defective. The fix was REWORDING, not thresholds: naming the exact shapes (posedge/negedge, asynchronous vs synchronous) until healthy blocks read clean. Write -> test healthy and defective -> reword. Thresholds are the last knob, moved only when two languages’ gaps agree (the AGENTS.md calibration doctrine); wording is the first, moved every time a healthy fixture scores above your gut.

Open questions

  • Extension collisions across grammars resolve silent first-wins (registration order); a loud collision error is the candidate fix if a real dir ever trips it.