Skip to content

novr/Rin

Repository files navigation

Rin (Rinter)

Rinter is a semantic policy CLI for Swift projects.
It reads Rinfile.swift, evaluates changed Swift code with deterministic SwiftSyntax AST checks, and fail-closes on runtime errors.

Problem

  • Missed required calls (authorization, logging, transaction cleanup) are hard to catch in review.
  • Rinter enforces these rules with static AST checks and fails CI when violations are found.

Installation

Requirements

  • macOS 13 or later (Apple Silicon and Intel)

Homebrew (tap)

brew tap novr/taps
brew trust --formula novr/taps/rinter
brew install novr/taps/rinter

Release tar.gz

Universal binary (Apple Silicon and Intel):

curl -LO https://github.com/novr/Rin/releases/download/v<version>/rinter_<version>_darwin.tar.gz
curl -LO https://github.com/novr/Rin/releases/download/v<version>/checksums.txt
shasum -a 256 -c checksums.txt
tar -xzf rinter_<version>_darwin.tar.gz
./rinter --help

From source

  • Build from source: Swift 6.2 or later
swift build -c release
./.build/release/rinter --help

Quick Start

  1. Create Rinfile.swift:
let policy = Rin.Policy {
    Target(
        include: ["App/**/*.swift", "Features/**/*.swift"],
        exclude: ["**/Generated/**", "**/*Mock*.swift"]
    )
    Rules {
        Rule(id: "viewmodel_load_tracks_screen") {
            MustCall(
                receiver: .symbol("Analytics"),
                method: "sendScreen",
                onPath: .namedFunctions("load", ifEmpty: .skip)
            )
        }
        .scope(include: ["**/*ViewModel.swift"])
        .message("ViewModel.load must send screen analytics.")
        .severity(.error)
    }
}
  1. Run:
rinter
  1. Example output:
❌ Semantic policy violation(s) found.
Features/Home/HomeViewModel.swift:42:13: [viewmodel_load_tracks_screen]
Required call `[symbol(Analytics), sendScreen]` was not found.
❌ Semantic policy violations detected.

Configuration

Same as Quick Start. Add exclude on Rule.scope to skip mocks and generated code:

.scope(
    include: ["**/*ViewModel.swift"],
    exclude: ["**/*Mock*.swift", "**/Generated/**"]
)

Narrow files with Rule.scope and functions with onPath on each predicate.

Scan Paths

Changed Swift files matched by Target(include:) are analyzed.
Use Target(exclude:) to skip paths.

Target(
    include: ["app/**/*.swift", "src/**/*.swift", "Modules/**/*.swift"],
    exclude: ["vendor/**", "tests/**"]
)

Rule Scope

Rule.scope limits which files a rule applies to (separate from onPath, which limits functions or catch units inside those files).

.scope(
    include: ["**/*ViewModel.swift"],
    exclude: ["**/*Mock*.swift"]
)

Required Calls

Rule(id: "authorization_single") {
    MustCall(
        receiver: .symbol("Authorizer"),
        method: "authorize",
        onPath: .namedFunctions("execute", ifEmpty: .skip)
    )
}

Rule(id: "authorization_any") {
    MustCallAnyOf([
        RuleCall(receiver: .symbol("Authorizer"), method: "authorize"),
        RuleCall(receiver: .symbol("Authorizer"), method: "can")
    ], onPath: .namedFunctions("execute", ifEmpty: .skip))
}

MustCall and MustCallAnyOf default to everyFunction(ifEmpty: .violate) when onPath is omitted — prefer namedFunctions or matchingFunctions with ifEmpty: .skip for named entry points.

Error Handling

Rule(id: "store_catch_cancellation") {
    MustHandleError(
        target: .case("cancelled"),
        as: .through,
        onPath: .everyCatch(ifEmpty: .violate)
    )
}
.scope(
    include: ["**/Domain/Store/*Store.swift"]
)
.message("Store catch blocks must ignore cancelled via control-flow exit.")
.severity(.error)

.through requires the target case branch to exit via return, break, or continue (not guard case … else { return } on the non-target side).

Typed Throws

Literal typed-throws type name only (e.g. throws(AppError) in source). MustThrow evaluates only functions with a literal typed-throws annotation in the signature:

Rule(id: "api_run_throws_app_error") {
    MustThrow(type: "AppError", onPath: .namedFunctions("run", ifEmpty: .skip))
}
.scope(include: ["**/API/**/*.swift"])
.message("API run() must declare throws(AppError).")
.severity(.error)

Functions with plain throws, no throws clause, throws(any Error), or typealias-dependent types are skipped (not violations). A violation is reported only when a literal typed throw is present and its type name does not match.

Paired Calls

Rule(id: "transaction_pair") {
    WhenCalls(receiver: .symbol("DB"), method: "beginTransaction")
        .mustAlsoCallAnyOf([
            RuleCall(receiver: .symbol("DB"), method: "commit"),
            RuleCall(receiver: .symbol("DB"), method: "rollback"),
        ])
}
.scope(include: ["**/Database*.swift"])
.message("Transactions must end with commit() or rollback().")
.severity(.error)

By default, WhenCalls checks follow-ups in the same function as the trigger.

Adopting to an existing project

Install both skills (Cursor, global). Run one command per skill (do not combine multiple -s flags):

npx skills add novr/Rin -s rin-dsl-rinfile -g -a cursor -y
npx skills add novr/Rin -s rin-dsl-rule-extraction -g -a cursor -y
  • rin-dsl-rinfile: applied automatically when writing or editing Rinfile.swift
  • Invoke rule extraction: /rin-dsl-rule-extraction

Skill references:

CLI

rinter [--config <path>] [--rule <id>] [-a|--all-files] [--fail-on-empty] [--format text|json] [--check-config] [--verbose]
  • -c, --config: path to Rinfile.swift (default: Rinfile.swift)
  • -r, --rule: evaluate only the specified rule id
  • -a, --all-files: evaluate all Swift files under project root (not just git diff)
  • --fail-on-empty: exit 1 when no Swift files are in scope after include/exclude filters (default: exit 0 with an info message, or [] in JSON mode)
  • --format: output format — text (default) prints human-readable messages; json prints a violations array to stdout ([] on pass)
  • --check-config: validate Rinfile syntax, unique rule IDs, non-empty rule bodies, and rule clauses without evaluating source files; configuration failures exit 2
  • -v, --verbose: verbose logs during evaluation
  • --version: show rinter version
  • -h, --help: show help

Alternative execution:

swift package rinter --help
mint run novr/rin@<tag> rinter --help
swift run rinter --help

CI Integration

name: Rinter
on: [push, pull_request]
jobs:
  check:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - name: Download release binary
        env:
          RINTER_VERSION: "v0.0.9"
        run: |
          curl -LO "https://github.com/novr/Rin/releases/download/${RINTER_VERSION}/rinter_${RINTER_VERSION#v}_darwin.tar.gz"
          curl -LO "https://github.com/novr/Rin/releases/download/${RINTER_VERSION}/checksums.txt"
          shasum -a 256 -c checksums.txt
          tar -xzf "rinter_${RINTER_VERSION#v}_darwin.tar.gz"
          chmod +x ./rinter
      - run: ./rinter --config Rinfile.swift --fail-on-empty

How It Works

  • Parse Rinfile.swift with SwiftSyntax.
  • Collect changed Swift files from git diff (or all files with --all-files), then apply Target include/exclude filters. When no files remain in scope, exit 0 by default; use --fail-on-empty to exit 1 instead.
  • Evaluate each rule per unit (function, catch, or trigger call) using onPath scopes.
  • Predicates: MustCall, MustCallAnyOf, MustHandleError, WhenCalls, MustDeclare, MustThrow, WhenCalls(name:).
  • Exit non-zero on violations or runtime errors (0 pass, 1 violations, 2 errors).

Limitations

  • File-local AST evaluation only
  • Receiver matching is syntax-based (.symbol("name") matches base identifier exactly)
  • Co-occurrence checks do not guarantee call order or 1:1 pairing
  • Helper delegation (calls moved to private helpers) is not detected
  • deinit and subscript are not attached to function scope metadata

Details: skills/rin-dsl-rinfile/SKILL.md

Special Thanks

Special thanks to fuwasegu/guardrail.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages