Skip to content

Loading Rules and Plugins

Rules, rule sets, and plugins are all loaded through the same mechanism. This page is the normative reference for that mechanism: which files it will load, how it tells a package specifier from a local path, and what it deliberately does not check.

TypeScript is the default authoring path. A .ts rule, rule set entry point, or plugin loads with no build step and no Node flags — Thymian transpiles it on the fly.

The loadable set is closed:

  • .ts — loaded via jiti.
  • .js / .mjs / .cjs — loaded via native import().

.mts and .cts are not supported. .d.ts is never loadable, whatever it contains — a declaration file is not runnable source, regardless of its extension matching otherwise.

Every specifier you pass to --rule-set, --plugin, a ruleSets: entry, or a plugin path: is exactly one of two kinds, decided syntactically:

  • Bare specifier — an installed package. It resolves through your project first, then Thymian’s own install, using Node’s own resolver. The resolved file must be built JavaScript (.js / .mjs / .cjs). A package that ships unbuilt TypeScript is declined — it must publish built output. A Node.js builtin id (http, url, …) is also declined; builtins are not loadable user modules.
  • Local specifier — a relative (./, ../) or absolute path with an explicit extension. There is no extension guessing, no index/directory resolution, and no <cwd>/<specifier> fallback — a bare specifier never falls back to a local file. If you meant a local file, write it as a path.

A ruleSets: config entry follows this same contract — a package (bare specifier) or a local path (see the configuration schema). A glob is not a valid entry; to select many local files at once, bundle them into a rule set that globs them via its pattern: field.

The explicit-extension rule applies to the top-level specifier only. Imports inside a loaded module keep working exactly as they do in any Node/TypeScript project — a .ts rule that does import './helper.js' for a file actually named helper.ts resolves normally through jiti/NodeNext, because that resolution never goes through this loading contract.

Globs are not a third specifier kind. The value you pass to --rule-set or a ruleSets: entry is always one of the two kinds above — it resolves to a single module. That module may then select its own member rule files with a glob, but the glob lives in the rule set’s pattern: field, not in the specifier you pass. See Globs.

Extension matching is case-sensitive against the on-disk casing. A mis-cased extension (e.g. requesting .TS for a file that is actually .ts) is declined, naming the casing mismatch rather than silently loading or silently skipping it.

Loading does not type-check. jiti strips TypeScript types to transpile a .ts file; it does not check them. If you want type errors caught, run tsc --noEmit — typically in CI, since it is a separate step from loading.

erasableSyntaxOnly is not required of your code. That restriction belongs to Node’s own experimental type-stripping feature, which Thymian does not use. Because loading goes through jiti instead, enum, namespace, parameter properties, and decorators all load normally.

tsconfig.json paths aliases are not honoured. This is deliberate, so module resolution stays predictable regardless of which tsconfig.json (if any) happens to be nearby. Do not configure paths and expect Thymian to resolve through them.

A rule set’s glob pattern selects individual rule files. A rule set cannot contain another rule set — if a glob match’s default export is itself a rule set, that is an error, not a nested load.

A rule set selects its member rule files with a glob — the pattern: field on the rule set module it exports (not the --rule-set/ruleSets: value itself, which resolves to that module as a bare or local specifier). Glob matching has a few fixed behaviours:

  • node_modules is excluded by default.
  • A rule set’s own file is excluded from its own matches (it can’t self-select).
  • A match that resolves to a non-loadable kind (wrong extension, .d.ts, a broken symlink, …) is skipped, with a stated reason — it does not fail the set by itself.
  • A matched file that fails while loading (a syntax error, a throwing top-level statement) fails the whole set.
  • A glob that matched files but produced zero loadable rules throws — it never silently resolves to an empty rule set.
  • Load order is deterministic: matches are sorted before loading.

jiti’s filesystem transpile cache is pinned to ~/.cache/thymian/jiti — per-user, and deliberately not the shared OS temp directory (a world-writable cache location would let another local user plant a poisoned entry).

If you’re updating from before this loading contract, these are the breaking changes:

  • Extensionless top-level specifiers no longer resolve. Write the extension explicitly: --rule-set my-rule--rule-set ./my-rule.ts.
  • The <cwd>/<specifier> bare-to-local fallback is gone. A local file must be written as a path (./ or ../ or absolute) — a bare-looking specifier no longer falls back to resolving against the working directory.
  • .mts / .cts are no longer loadable for rules, rule sets, or plugins. Use .ts.
  • TypeScript-source packages in node_modules no longer load. A dependency must ship built JavaScript; publishing .ts source and relying on Thymian to transpile it is no longer supported.