@aurelienbbn/agentlint
Version:
Stateless, deterministic CLI that bridges traditional linters and AI-assisted code review
1,306 lines (1,304 loc) • 45.3 kB
JavaScript
import { n as wrapNode, r as FlagRecord } from "./node-yh9mLvnE.mjs";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Console, Context, Effect, FileSystem, HashMap, HashSet, Layer, Option, Path, Schema } from "effect";
import { Argument, Command, Flag } from "effect/unstable/cli";
import picomatch from "picomatch";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { Language, Parser } from "web-tree-sitter";
//#region src/config/env.ts
/**
* Centralised process / environment access.
*
* **This is the only module in the codebase that may touch `process.*`.**
*
* Every other module that needs the working directory, TTY state, colour
* preference, or exit-code control must depend on the `Env` service
* instead of reaching into `process` directly.
*
* The layer is built once at startup with `Layer.sync` (no external
* dependencies), so it can be provided before every other service layer.
*
* @module
* @since 0.1.0
*/
/**
* Read-only snapshot of the runtime environment.
*
* @since 0.1.0
* @category services
*/
var Env = class Env extends Context.Service()("agentlint/Env") {
/**
* Default layer — reads from `process` globals exactly once.
*
* @since 0.1.0
* @category layers
*/
static layer = Layer.sync(Env, () => {
const isTTY = process.stdout.isTTY ?? false;
return Env.of({
cwd: process.cwd(),
noColor: !!process.env["NO_COLOR"] || !isTTY,
isTTY,
setExitCode: (code) => {
process.exitCode = code;
}
});
});
};
//#endregion
//#region src/shared/infrastructure/config-loader.ts
/**
* Configuration file discovery and loading.
*
* Searches the current working directory for a config file, imports it
* via `jiti` (for TypeScript support without pre-compilation), and
* validates the exported shape.
*
* **Search order**: `agentlint.config.ts` → `.js` → `.mts` → `.mjs`.
* The first match wins.
*
* @module
* @since 0.1.0
*/
/**
* Raised when the config file is missing, malformed, or fails to import.
*
* @since 0.1.0
* @category errors
*/
var ConfigError = class extends Schema.TaggedErrorClass()("ConfigError", { message: Schema.String }) {};
/**
* Candidate config file names, checked in order.
*
* @since 0.1.0
* @category constants
*/
const CONFIG_NAMES = [
"agentlint.config.ts",
"agentlint.config.js",
"agentlint.config.mts",
"agentlint.config.mjs"
];
/**
* Discover the config file path by checking candidates in order.
*
* @since 0.1.0
* @category internals
*/
const discoverConfig = (fs, path, cwd) => Effect.gen(function* () {
for (const name of CONFIG_NAMES) {
const candidate = path.resolve(cwd, name);
if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return candidate;
}
return yield* new ConfigError({ message: `No agentlint config found. Create agentlint.config.ts in ${cwd}` });
});
/**
* Effect service that discovers and loads the agentlint config file.
*
* Uses `jiti` under the hood so TypeScript configs work without a
* separate compilation step.
*
* @example
* ```ts
* import { Console, Effect } from "effect"
* import { ConfigLoader } from "./infrastructure/config-loader.js"
*
* const program = Effect.gen(function* () {
* const loader = yield* ConfigLoader
* const config = yield* loader.load()
* yield* Console.log(Object.keys(config.rules))
* })
* ```
*
* @since 0.1.0
* @category services
*/
var ConfigLoader = class ConfigLoader extends Context.Service()("agentlint/ConfigLoader") {
static layer = Layer.effect(ConfigLoader, Effect.gen(function* () {
const env = yield* Env;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
return ConfigLoader.of({ load: () => Effect.gen(function* () {
const configPath = yield* discoverConfig(fs, path, env.cwd);
const config = yield* Effect.tryPromise({
try: async () => {
const { createJiti } = await import("jiti");
const loaded = await createJiti(import.meta.url, { interopDefault: true }).import(configPath);
return loaded.default ?? loaded;
},
catch: (error) => new ConfigError({ message: error instanceof Error ? error.message : String(error) })
});
if (!config || typeof config !== "object" || !("rules" in config)) return yield* new ConfigError({ message: `Invalid config at ${configPath}: must export an object with a "rules" field` });
return config;
}) });
}));
};
//#endregion
//#region src/shared/infrastructure/state-store.ts
/**
* Local state store for tracking reviewed flags.
*
* Manages a `.agentlint-state` file in the project root that stores
* hashes of flags that have been reviewed. This file is intended to
* be **gitignored** — it is per-developer scratch state for tracking
* progress during review sweeps.
*
* **Caveats**:
* - Hashes encode file path, line, column, and message. Editing code
* above a reviewed flag shifts its position and invalidates the hash.
* This is by design — changed context should be re-reviewed.
* - Stale hashes (from flags that no longer exist) accumulate harmlessly.
* Use `agentlint review --reset` to start fresh.
*
* @module
* @since 0.1.0
*/
/**
* The filename used for local review state.
*
* @since 0.1.0
* @category constants
*/
const STATE_FILENAME = ".agentlint-state";
/**
* Parse the state file into a set of hashes.
* Tolerates blank lines and `#`-prefixed comments.
*
* @since 0.1.0
* @category internals
*/
function parseStateFile(content) {
let hashes = HashSet.empty();
for (const raw of content.split("\n")) {
const line = raw.trim();
if (line.length > 0 && !line.startsWith("#")) hashes = HashSet.add(hashes, line);
}
return hashes;
}
/**
* Serialize a set of hashes into file content.
*
* @since 0.1.0
* @category internals
*/
function serializeHashes(hashes) {
return [...hashes].join("\n") + "\n";
}
/**
* Effect service for loading and persisting reviewed-flag state.
*
* @since 0.1.0
* @category services
*/
var StateStore = class StateStore extends Context.Service()("agentlint/StateStore") {
/**
* Default layer — resolves the state file path from `Env.cwd`.
*
* @since 0.1.0
* @category layers
*/
static layer = Layer.unwrap(Effect.gen(function* () {
const env = yield* Env;
const fs = yield* FileSystem.FileSystem;
const statePath = (yield* Path.Path).resolve(env.cwd, STATE_FILENAME);
return Layer.succeed(StateStore, StateStore.of({
load: () => fs.exists(statePath).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((exists) => exists ? fs.readFileString(statePath).pipe(Effect.map(parseStateFile), Effect.orElseSucceed(() => HashSet.empty())) : Effect.succeed(HashSet.empty()))),
append: (hashes) => fs.exists(statePath).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((exists) => exists ? fs.readFileString(statePath).pipe(Effect.map(parseStateFile), Effect.orElseSucceed(() => HashSet.empty())) : Effect.succeed(HashSet.empty())), Effect.map((existing) => hashes.reduce((acc, h) => HashSet.add(acc, h), existing)), Effect.flatMap((merged) => fs.writeFileString(statePath, serializeHashes(merged)).pipe(Effect.orElseSucceed(() => {})))),
reset: () => fs.exists(statePath).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((exists) => exists ? fs.remove(statePath).pipe(Effect.orElseSucceed(() => {})) : Effect.void))
}));
}));
};
//#endregion
//#region src/domain/hash.ts
/**
* FNV-1a hashing utility.
*
* Produces a 7-character hex digest used for stable, deterministic
* flag identification. The hash encodes rule name, file path, position,
* and message so that identical matches across runs share the same id.
*
* @see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
*
* @module
* @since 0.1.0
*/
/**
* FNV-1a 32-bit offset basis.
*
* @since 0.1.0
* @category constants
*/
const FNV_OFFSET_BASIS = 2166136261;
/**
* FNV-1a 32-bit prime multiplier.
*
* @since 0.1.0
* @category constants
*/
const FNV_PRIME = 16777619;
/**
* Compute a 7-character hex FNV-1a hash of `input`.
*
* The result is the first 7 hex characters of the unsigned 32-bit
* FNV-1a digest — short enough for display, long enough to avoid
* collisions in typical lint runs.
*
* @example
* ```ts
* import { fnv1a7 } from "./utils/hash.js"
*
* fnv1a7("my-rule:src/index.ts:10:1:message") // => "a3f4b2c"
* ```
*
* @since 0.1.0
* @category constructors
*/
function fnv1a7(input) {
let hash = FNV_OFFSET_BASIS;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, FNV_PRIME);
}
return (hash >>> 0).toString(16).padStart(8, "0").slice(0, 7);
}
//#endregion
//#region src/domain/rule-context.ts
/**
* Rule context — the interface rules use to interact with the runner.
*
* Provides file metadata, source access, and the {@link RuleContext.flag}
* method for recording matches.
*
* @module
* @since 0.1.0
*/
/**
* Internal implementation of {@link RuleContext}.
*
* Tracks the current file, accumulates flags, and provides source
* access helpers. The check command calls {@link setFile} before each
* file and {@link drainFlags} after the tree walk to collect results.
*
* @since 0.1.0
* @category internals
*/
var RuleContextImpl = class {
ruleName;
flags = [];
#filename = "";
#source = "";
constructor(ruleName) {
this.ruleName = ruleName;
}
/**
* Set the current file context. Called by the check command before
* each file is walked.
*/
setFile(filename, source) {
this.#filename = filename;
this.#source = source;
}
/**
* Remove and return all accumulated flags. Called after the tree
* walk for each file to collect results.
*/
drainFlags() {
return this.flags.splice(0);
}
getFilename() {
return this.#filename;
}
getSourceCode() {
return this.#source;
}
getLinesAround(line, radius = 10) {
const lines = this.#source.split("\n");
const start = Math.max(0, line - 1 - radius);
const end = Math.min(lines.length, line + radius);
return lines.slice(start, end).map((l, i) => `${String(start + i + 1).padStart(4)} | ${l}`).join("\n");
}
flag(options) {
const line = options.node.startPosition.row + 1;
const col = options.node.startPosition.column + 1;
const trimmed = (this.#source.split("\n")[line - 1] ?? "").trim();
const sourceSnippet = trimmed.length > 100 ? trimmed.slice(0, 97) + "..." : trimmed;
const hash = fnv1a7(`${this.ruleName}:${this.#filename}:${line}:${col}:${options.message}`);
this.flags.push(new FlagRecord({
ruleName: this.ruleName,
filename: this.#filename,
line,
col,
message: options.message,
sourceSnippet,
hash,
instruction: options.instruction,
suggest: options.suggest
}));
}
};
//#endregion
//#region src/shared/pipeline/file-resolver.ts
/**
* File resolution service.
*
* Determines which files to lint by applying the filter pipeline:
* 1. Candidate files (from git diff or all files)
* 2. Config include/ignore
*
* Per-rule filtering (languages, include, ignore) is done by the check command.
*
* @module
*/
/**
* Raised when file resolution fails — e.g. a git error bubbling up
* from the changed-files query.
*
* @since 0.1.0
* @category errors
*/
var FileResolverError = class extends Schema.TaggedErrorClass()("FileResolverError", { message: Schema.String }) {};
Schema.Struct({
all: Schema.Boolean,
baseRef: Schema.optional(Schema.String),
configInclude: Schema.optional(Schema.Array(Schema.String)),
configIgnore: Schema.optional(Schema.Array(Schema.String)),
positionalFiles: Schema.optional(Schema.Array(Schema.String))
});
/** Directories that are always skipped during recursive listing. */
const SKIP_DIRS = HashSet.make("node_modules", ".git", "dist");
/**
* Recursively list all files under `dir`, returning paths relative to `base`.
*
* Skips `node_modules`, `.git`, and `dist` directories. Errors (e.g.
* permission denied) are silently swallowed.
*
* Uses the Effect `FileSystem` and `Path` services for cross-platform
* file system access.
*
* @since 0.1.0
* @category internals
*/
function listAllFiles(dir, base, fs, path) {
return Effect.gen(function* () {
const entries = yield* fs.readDirectory(dir);
const results = [];
for (const name of entries) {
if (HashSet.has(SKIP_DIRS, name)) continue;
const fullPath = path.resolve(dir, name);
const info = yield* fs.stat(fullPath);
const relPath = path.relative(base, fullPath).replace(/\\/g, "/");
if (info.type === "Directory") results.push(...yield* listAllFiles(fullPath, base, fs, path));
else results.push(relPath);
}
return results;
}).pipe(Effect.catch(() => Effect.succeed([])));
}
/**
* Determine the final set of files to lint.
*
* Applies the multi-layer filter pipeline described in the module header,
* then sorts the result alphabetically for deterministic output.
*
* @since 0.1.0
* @category constructors
*/
function resolveFiles(options, gitService) {
return Effect.gen(function* () {
const env = yield* Env;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { cwd } = env;
let candidates;
if (options.positionalFiles && options.positionalFiles.length > 0) candidates = [...options.positionalFiles];
else if (options.all) candidates = yield* listAllFiles(cwd, cwd, fs, path);
else candidates = [...yield* Effect.mapError(gitService.changedFiles(options.baseRef), (e) => new FileResolverError({ message: `Git error: ${e}` }))];
const includeMatcher = options.configInclude?.length ? picomatch(options.configInclude) : void 0;
const ignoreMatcher = options.configIgnore?.length ? picomatch(options.configIgnore) : void 0;
return candidates.filter((f) => !includeMatcher || includeMatcher(f)).filter((f) => !ignoreMatcher || !ignoreMatcher(f)).filter((f) => path.extname(f).length > 0).toSorted();
});
}
//#endregion
//#region src/shared/infrastructure/git.ts
/**
* Git integration — default branch detection and changed file collection.
*
* @module
* @since 0.1.0
*/
/**
* Raised when a git operation fails — e.g. not a git repo,
* invalid ref, or `git` binary not found.
*
* @since 0.1.0
* @category errors
*/
var GitError = class extends Schema.TaggedErrorClass()("GitError", { message: Schema.String }) {};
/**
* Execute a git command and return trimmed stdout.
*
* Uses the array form of `ChildProcess.make` so that dynamic arguments
* are properly tokenized.
*
* @since 0.1.0
* @category internals
*/
const gitCmd = (args, cwd) => Effect.gen(function* () {
return (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make("git", args.split(/\s+/), { cwd }))).trim();
});
/**
* Detect the default branch by checking whether `main` or `master` exists.
* Falls back to `"main"` when neither can be verified.
*
* @since 0.1.0
* @category internals
*/
const detectDefault = (cwd) => gitCmd("rev-parse --verify main", cwd).pipe(Effect.map(() => "main"), Effect.catch(() => gitCmd("rev-parse --verify master", cwd).pipe(Effect.map(() => "master"), Effect.catch(() => Effect.succeed("main")))));
/**
* Collect all files that differ from `baseRef`.
*
* Gathers the union of committed diffs, uncommitted changes, and
* untracked files. Each source is caught so partial failures
* (e.g. empty repo, no merge-base) are silently skipped.
*
* @since 0.1.0
* @category internals
*/
const parseLines = (output) => output.split("\n").map((f) => f.trim()).filter((f) => f.length > 0);
const collectChangedFiles = (cwd, baseRef) => Effect.all([
gitCmd(`merge-base HEAD ${baseRef}`, cwd).pipe(Effect.flatMap((mergeBase) => gitCmd(`diff --name-only ${mergeBase}...HEAD`, cwd)), Effect.catch(() => Effect.succeed(""))),
gitCmd("diff --name-only HEAD", cwd).pipe(Effect.catch(() => Effect.succeed(""))),
gitCmd("ls-files --others --exclude-standard", cwd).pipe(Effect.catch(() => Effect.succeed("")))
]).pipe(Effect.map(([committed, uncommitted, untracked]) => [...HashSet.fromIterable([
...parseLines(committed),
...parseLines(uncommitted),
...parseLines(untracked)
])].toSorted()));
/**
* @example
* ```ts
* import { Console, Effect } from "effect"
* import { Git } from "./infrastructure/git.js"
*
* const program = Effect.gen(function* () {
* const git = yield* Git
* const branch = yield* git.detectDefaultBranch()
* const changed = yield* git.changedFiles(branch)
* yield* Console.log(`${changed.length} files changed since ${branch}`)
* })
* ```
*
* @since 0.1.0
*/
var Git = class Git extends Context.Service()("agentlint/Git") {
static layer = Layer.effect(Git, Effect.gen(function* () {
const env = yield* Env;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const provide = (effect) => Effect.provideService(effect, ChildProcessSpawner.ChildProcessSpawner, spawner);
return Git.of({
detectDefaultBranch: () => provide(detectDefault(env.cwd)).pipe(Effect.mapError((e) => new GitError({ message: String(e) }))),
changedFiles: (baseRef) => (baseRef ? Effect.succeed(baseRef) : provide(detectDefault(env.cwd))).pipe(Effect.flatMap((base) => provide(collectChangedFiles(env.cwd, base))), Effect.mapError((e) => new GitError({ message: String(e) })))
});
}));
};
//#endregion
//#region src/shared/infrastructure/parser.ts
/**
* Tree-sitter WASM parser.
*
* WASM init is lazy — the first `parse` call triggers initialization.
* Grammars are cached after first load.
*
* @module
* @since 0.1.0
*/
/**
* Raised when parsing fails — e.g. missing grammar, corrupt WASM, or
* tree-sitter returning a null tree.
*
* @since 0.1.0
* @category errors
*/
var ParserError = class extends Schema.TaggedErrorClass()("ParserError", { message: Schema.String }) {};
/**
* Maps grammar names to their corresponding `.wasm` filenames.
*
* @since 0.1.0
* @category constants
*/
const GRAMMAR_FILES = HashMap.make(["typescript", "tree-sitter-typescript.wasm"], ["tsx", "tree-sitter-tsx.wasm"], ["javascript", "tree-sitter-javascript.wasm"]);
function resolvePackagedWasmPath(path, dir, filename) {
return path.resolve(dir, "wasm", filename);
}
/**
* @example
* ```ts
* import { Console, Effect } from "effect"
* import { Parser } from "./infrastructure/parser.js"
*
* const program = Effect.gen(function* () {
* const parser = yield* Parser
* const tree = yield* parser.parse("const x = 1", "typescript")
* yield* Console.log(tree.rootNode.type) // "program"
* })
* ```
*
* @since 0.1.0
* @category services
*/
var Parser$1 = class Parser$1 extends Context.Service()("agentlint/Parser") {
/** Default layer — lazily initializes WASM and caches grammars. */
static layer = Layer.effect(Parser$1, Effect.gen(function* () {
const env = yield* Env;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const resolveWasmPath = (filename) => Effect.gen(function* () {
const distPath = resolvePackagedWasmPath(path, path.resolve(import.meta.dirname ?? "."), filename);
if (yield* fs.exists(distPath).pipe(Effect.orElseSucceed(() => false))) return distPath;
const nmBase = path.resolve(env.cwd, "node_modules");
if (filename === "tree-sitter.wasm") {
const p = path.resolve(nmBase, "web-tree-sitter", filename);
if (yield* fs.exists(p).pipe(Effect.orElseSucceed(() => false))) return p;
} else {
const p = path.resolve(nmBase, "tree-sitter-wasms", "out", filename);
if (yield* fs.exists(p).pipe(Effect.orElseSucceed(() => false))) return p;
}
return yield* new ParserError({ message: `WASM file not found: ${filename}` });
});
let parserInstance;
let languageCache = HashMap.empty();
return Parser$1.of({ parse: (source, grammar) => Effect.gen(function* () {
if (!parserInstance) {
const initPath = yield* resolveWasmPath("tree-sitter.wasm");
yield* Effect.tryPromise({
try: async () => {
await Parser.init({ locateFile: () => initPath });
parserInstance = new Parser();
},
catch: (error) => new ParserError({ message: error instanceof Error ? error.message : String(error) })
});
}
let lang = Option.getOrUndefined(HashMap.get(languageCache, grammar));
if (!lang) {
const file = Option.getOrUndefined(HashMap.get(GRAMMAR_FILES, grammar));
if (!file) return yield* new ParserError({ message: `Unknown grammar: ${grammar}` });
const wasmPath = yield* resolveWasmPath(file);
lang = yield* Effect.tryPromise({
try: () => Language.load(wasmPath),
catch: (error) => new ParserError({ message: error instanceof Error ? error.message : String(error) })
});
languageCache = HashMap.set(languageCache, grammar, lang);
}
parserInstance.setLanguage(lang);
const tree = parserInstance.parse(source);
if (!tree) return yield* new ParserError({ message: "Parser returned null tree" });
return tree;
}) });
}));
};
//#endregion
//#region src/shared/pipeline/tree-walker.ts
/**
* Single-pass multi-rule tree walker.
*
* Builds a dispatch table from all active rules' visitor methods,
* walks the tree once using tree-sitter's cursor API, and calls
* all matching handlers per node.
*
* @module
*/
/**
* Walk files with the given rules, collecting all flags.
*
* Call this once per file. The caller is responsible for:
* - Calling `context.setFile()` before this function
* - Calling `before()` and filtering out skipped rules
* - Calling `after()` after all files are processed
*
* @internal
*/
function walkFile(tree, rules) {
const dispatchTable = HashMap.mutate(HashMap.empty(), (m) => {
for (const entry of rules) for (const key of Object.keys(entry.visitors)) {
if (key === "before" || key === "after") continue;
const handler = entry.visitors[key];
if (typeof handler !== "function") continue;
const existing = Option.getOrUndefined(HashMap.get(m, key));
if (existing) existing.push({
ruleName: entry.ruleName,
handler
});
else HashMap.set(m, key, [{
ruleName: entry.ruleName,
handler
}]);
}
});
const cursor = tree.walk();
let reachedEnd = false;
while (!reachedEnd) {
const nodeType = cursor.nodeType;
const handlers = Option.getOrUndefined(HashMap.get(dispatchTable, nodeType));
if (handlers) {
const wrapped = wrapNode(cursor.currentNode);
for (const { handler } of handlers) handler(wrapped);
}
if (cursor.gotoFirstChild()) continue;
while (!cursor.gotoNextSibling()) if (!cursor.gotoParent()) {
reachedEnd = true;
break;
}
}
const allFlags = [];
for (const entry of rules) allFlags.push(...entry.context.drainFlags());
return allFlags;
}
//#endregion
//#region src/shared/pipeline/language-map.ts
/**
* File extension → tree-sitter grammar mapping.
*
* Maps every supported file extension to the grammar name used by
* the parser service. This is the single source of truth for which
* file types agentlint can analyze.
*
* Uses Effect `HashMap` for an immutable, structurally-equal lookup table.
*
* @module
* @since 0.1.0
*/
/**
* Maps file extensions (without leading dot) to their tree-sitter
* grammar name.
*
* @since 0.1.0
* @category constants
*/
const EXTENSION_TO_GRAMMAR = HashMap.make(["ts", "typescript"], ["tsx", "tsx"], ["js", "javascript"], ["jsx", "javascript"], ["mts", "typescript"], ["cts", "typescript"], ["mjs", "javascript"], ["cjs", "javascript"]);
/**
* Look up the tree-sitter grammar name for a file extension.
*
* Returns `undefined` for unsupported extensions — callers should
* skip those files.
*
* @since 0.1.0
* @category constructors
*/
function grammarForExtension(ext) {
return Option.getOrUndefined(HashMap.get(EXTENSION_TO_GRAMMAR, ext));
}
Schema.Struct({
flags: Schema.Array(FlagRecord),
noMatchingRules: Schema.Boolean
});
Schema.Struct({
all: Schema.Boolean,
rules: Schema.Array(Schema.String),
dryRun: Schema.Boolean,
base: Schema.UndefinedOr(Schema.String),
files: Schema.Array(Schema.String)
});
/** @since 0.1.0 */
const collectFlags = Effect.fn("collectFlags")(function* (options) {
const configLoader = yield* ConfigLoader;
const env = yield* Env;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const gitService = yield* Git;
const parserService = yield* Parser$1;
const config = yield* configLoader.load();
let activeRules = Object.entries(config.rules);
if (options.rules.length > 0) {
activeRules = activeRules.filter(([name]) => options.rules.includes(name));
if (activeRules.length === 0) return {
flags: [],
noMatchingRules: true
};
}
const includePatterns = config.include ? [...config.include] : void 0;
const ignorePatterns = config.ignore ? [...config.ignore] : void 0;
const files = yield* resolveFiles({
all: options.all,
baseRef: options.base,
configInclude: includePatterns,
configIgnore: ignorePatterns,
positionalFiles: options.files.length > 0 ? [...options.files] : void 0
}, gitService);
if (files.length === 0) return {
flags: [],
noMatchingRules: false
};
const ruleEntries = [];
for (const [name, rule] of activeRules) {
const context = new RuleContextImpl(name);
const visitors = rule.createOnce(context);
ruleEntries.push({
name,
rule,
context,
visitors
});
}
const allFlags = [];
for (const file of files) {
const ext = path.extname(file).slice(1);
const absPath = path.resolve(env.cwd, file);
const applicableRules = ruleEntries.filter((entry) => {
if (!entry.rule.meta.languages.includes(ext)) return false;
if (entry.rule.meta.include && entry.rule.meta.include.length > 0) {
if (!picomatch([...entry.rule.meta.include])(file)) return false;
}
if (entry.rule.meta.ignore && entry.rule.meta.ignore.length > 0) {
if (picomatch([...entry.rule.meta.ignore])(file)) return false;
}
return true;
});
if (applicableRules.length === 0) continue;
const sourceResult = yield* fs.readFileString(absPath).pipe(Effect.result);
if (sourceResult._tag === "Failure") continue;
const source = sourceResult.success;
const grammar = grammarForExtension(ext);
if (!grammar) continue;
const tree = yield* parserService.parse(source, grammar);
const rulesForFile = [];
for (const entry of applicableRules) {
entry.context.setFile(absPath, source);
if (entry.visitors.before?.(absPath) === false) continue;
rulesForFile.push({
ruleName: entry.name,
context: entry.context,
visitors: entry.visitors
});
}
if (rulesForFile.length === 0) continue;
const fileFlags = walkFile(tree, rulesForFile);
allFlags.push(...fileFlags);
}
for (const entry of ruleEntries) entry.visitors.after?.();
return {
flags: allFlags,
noMatchingRules: false
};
});
//#endregion
//#region src/features/check/request.ts
/**
* @module
* @since 0.1.0
*/
/**
* @since 0.1.0
* @category models
*/
var CheckCommand = class extends Schema.TaggedClass()("CheckCommand", {
all: Schema.Boolean,
rules: Schema.Array(Schema.String),
dryRun: Schema.Boolean,
base: Schema.UndefinedOr(Schema.String),
files: Schema.Array(Schema.String)
}) {};
/**
* @since 0.1.0
* @category models
*/
var CheckResult = class extends Schema.TaggedClass()("CheckResult", {
flags: Schema.Array(FlagRecord),
totalFlags: Schema.Number,
filteredCount: Schema.Number,
noMatchingRules: Schema.Boolean,
availableRules: Schema.Array(Schema.String)
}) {};
//#endregion
//#region src/features/check/handler.ts
/**
* @module
* @since 0.1.0
*/
/** @since 0.1.0 */
const checkHandler = Effect.fn("checkHandler")(function* (command) {
const configLoader = yield* ConfigLoader;
const stateStore = yield* StateStore;
const config = yield* configLoader.load();
const availableRules = Object.keys(config.rules);
const result = yield* collectFlags({
all: command.all,
rules: command.rules,
dryRun: command.dryRun,
base: command.base,
files: command.files
});
if (result.noMatchingRules) return new CheckResult({
flags: [],
totalFlags: 0,
filteredCount: 0,
noMatchingRules: true,
availableRules
});
const allFlags = result.flags;
if (allFlags.length === 0) return new CheckResult({
flags: [],
totalFlags: 0,
filteredCount: 0,
noMatchingRules: false,
availableRules
});
const reviewed = yield* stateStore.load();
const reviewedSize = HashSet.size(reviewed);
const filteredCount = reviewedSize > 0 ? allFlags.filter((f) => HashSet.has(reviewed, f.hash)).length : 0;
return new CheckResult({
flags: reviewedSize > 0 ? allFlags.filter((f) => !HashSet.has(reviewed, f.hash)) : allFlags,
totalFlags: allFlags.length,
filteredCount,
noMatchingRules: false,
availableRules
});
});
//#endregion
//#region src/features/init/request.ts
/**
* @module
* @since 0.1.0
*/
/**
* @since 0.1.0
* @category models
*/
var InitCommand = class extends Schema.TaggedClass()("InitCommand", {}) {};
/**
* @since 0.1.0
* @category models
*/
var InitResult = class extends Schema.TaggedClass()("InitResult", {
created: Schema.Boolean,
message: Schema.String
}) {};
//#endregion
//#region src/features/init/handler.ts
/**
* @module
* @since 0.1.0
*/
/**
* Minimal starter config written by `agentlint init`.
*
* @since 0.1.0
* @category constants
*/
const STARTER_CONFIG = `import { defineConfig } from "@aurelienbbn/agentlint"
export default defineConfig({
include: ["src/**/*.{ts,tsx}"],
rules: {},
})
`;
function packageManagerFromValue(value) {
if (!value) return void 0;
if (value.startsWith("pnpm@")) return "pnpm";
if (value.startsWith("yarn@")) return "yarn";
if (value.startsWith("bun@")) return "bun";
if (value.startsWith("npm@")) return "npm";
}
function commandsForPackageManager(packageManager) {
switch (packageManager) {
case "pnpm": return {
skillInstall: "pnpm dlx skills@latest add aurelienbobenrieth/agentlint",
intentInstall: "pnpm dlx @tanstack/intent install",
agentlintCheck: "pnpm agentlint check --all"
};
case "yarn": return {
skillInstall: "yarn dlx skills@latest add aurelienbobenrieth/agentlint",
intentInstall: "yarn dlx @tanstack/intent install",
agentlintCheck: "yarn agentlint check --all"
};
case "bun": return {
skillInstall: "bunx skills@latest add aurelienbobenrieth/agentlint",
intentInstall: "bunx @tanstack/intent install",
agentlintCheck: "bun run agentlint check --all"
};
case "npm": return {
skillInstall: "npx skills@latest add aurelienbobenrieth/agentlint",
intentInstall: "npx @tanstack/intent install",
agentlintCheck: "npm exec agentlint -- check --all"
};
}
}
const detectPackageManager = Effect.fn("detectPackageManager")(function* (cwd) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const packageJsonPath = path.resolve(cwd, "package.json");
if (yield* fs.exists(packageJsonPath)) {
const packageJson = yield* fs.readFileString(packageJsonPath).pipe(Effect.orElseSucceed(() => ""));
try {
const detected = packageManagerFromValue(JSON.parse(packageJson).packageManager);
if (detected) return detected;
} catch {}
}
for (const [lockfile, packageManager] of [
["pnpm-lock.yaml", "pnpm"],
["yarn.lock", "yarn"],
["package-lock.json", "npm"],
["bun.lock", "bun"],
["bun.lockb", "bun"]
]) if (yield* fs.exists(path.resolve(cwd, lockfile))) return packageManager;
return "npm";
});
/**
* Detect which skill installation method is most likely appropriate.
*
* Checks for TanStack Intent or existing AGENTS.md with intent block.
*/
const detectSkillMethod = Effect.fn("detectSkillMethod")(function* (cwd) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
if (yield* fs.exists(path.resolve(cwd, "node_modules/@tanstack/intent")).pipe(Effect.orElseSucceed(() => false))) return "intent";
const agentsPath = path.resolve(cwd, "AGENTS.md");
if (yield* fs.exists(agentsPath)) {
if ((yield* fs.readFileString(agentsPath)).includes("intent-skills:start")) return "intent";
}
return "skills";
});
/** @since 0.1.0 */
const initHandler = Effect.fn("initHandler")(function* (_command) {
const env = yield* Env;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const configPath = path.resolve(env.cwd, "agentlint.config.ts");
const gitignorePath = path.resolve(env.cwd, ".gitignore");
const configCreated = !(yield* fs.exists(configPath));
if (configCreated) yield* fs.writeFileString(configPath, STARTER_CONFIG);
let gitignoreUpdated = false;
if (yield* fs.exists(gitignorePath)) {
const content = yield* fs.readFileString(gitignorePath);
if (!content.includes(".agentlint-state")) {
const separator = content.endsWith("\n") ? "" : "\n";
yield* fs.writeFileString(gitignorePath, content + separator + "\n# agentlint local state\n.agentlint-state\n");
gitignoreUpdated = true;
}
} else {
yield* fs.writeFileString(gitignorePath, "# agentlint local state\n.agentlint-state\n");
gitignoreUpdated = true;
}
const lines = [];
if (configCreated) lines.push("✓ Created agentlint.config.ts");
else lines.push("· agentlint.config.ts already exists — skipped");
if (gitignoreUpdated) lines.push("✓ Added .agentlint-state to .gitignore");
const commands = commandsForPackageManager(yield* detectPackageManager(env.cwd));
const skillCmd = (yield* detectSkillMethod(env.cwd)) === "intent" ? commands.intentInstall : commands.skillInstall;
lines.push("", "Next steps:", " 1. Add rules to your config", ` 2. Install the agentlint skill for your AI agents:`, ` ${skillCmd}`, ` 3. Run: ${commands.agentlintCheck}`);
return new InitResult({
created: configCreated,
message: lines.join("\n")
});
});
//#endregion
//#region src/features/list/request.ts
/**
* @module
* @since 0.1.0
*/
/**
* @since 0.1.0
* @category models
*/
const RuleSummary = Schema.Struct({
name: Schema.String,
description: Schema.String,
languages: Schema.Array(Schema.String),
include: Schema.UndefinedOr(Schema.Array(Schema.String)),
ignore: Schema.UndefinedOr(Schema.Array(Schema.String))
});
/**
* @since 0.1.0
* @category models
*/
var ListCommand = class extends Schema.TaggedClass()("ListCommand", {}) {};
/**
* @since 0.1.0
* @category models
*/
var ListResult = class extends Schema.TaggedClass()("ListResult", { rules: Schema.Array(RuleSummary) }) {};
//#endregion
//#region src/features/list/handler.ts
/**
* @module
* @since 0.1.0
*/
/** @since 0.1.0 */
const listHandler = Effect.fn("listHandler")(function* (_command) {
const config = yield* (yield* ConfigLoader).load();
return new ListResult({ rules: Object.entries(config.rules).map(([name, rule]) => ({
name,
description: rule.meta.description,
languages: rule.meta.languages,
include: rule.meta.include,
ignore: rule.meta.ignore
})) });
});
//#endregion
//#region src/features/review/request.ts
/**
* @module
* @since 0.1.0
*/
/**
* @since 0.1.0
* @category models
*/
var ReviewCommand = class extends Schema.TaggedClass()("ReviewCommand", {
hashes: Schema.Array(Schema.String),
all: Schema.Boolean,
reset: Schema.Boolean
}) {};
/**
* @since 0.1.0
* @category models
*/
var ReviewResult = class extends Schema.TaggedClass()("ReviewResult", { message: Schema.String }) {};
//#endregion
//#region src/features/review/handler.ts
/**
* @module
* @since 0.1.0
*/
/** @since 0.1.0 */
const reviewHandler = Effect.fn("reviewHandler")(function* (command) {
const stateStore = yield* StateStore;
if (command.reset) {
yield* stateStore.reset();
return new ReviewResult({ message: "Cleared .agentlint-state" });
}
if (command.all) {
const allFlags = (yield* collectFlags({
all: true,
rules: [],
dryRun: false,
base: void 0,
files: []
})).flags;
if (allFlags.length === 0) return new ReviewResult({ message: "No flags to review." });
yield* stateStore.append(allFlags.map((f) => f.hash));
return new ReviewResult({ message: `Marked ${allFlags.length} flag(s) as reviewed.` });
}
if (command.hashes.length > 0) {
yield* stateStore.append([...command.hashes]);
return new ReviewResult({ message: `Marked ${command.hashes.length} hash(es) as reviewed.` });
}
return new ReviewResult({ message: [
"Usage:",
" agentlint review <hash...> Mark specific flags as reviewed",
" agentlint review --all Mark all current flags as reviewed",
" agentlint review --reset Wipe the state file"
].join("\n") });
});
//#endregion
//#region src/cli/reporter.ts
/**
* Terminal reporter — formats flag results into human-readable output.
*
* Respects `NO_COLOR` and non-TTY environments. Groups flags by rule,
* then by file, and appends instruction/hint blocks when available.
*
* @module
* @since 0.1.0
*/
/**
* Group an array by a key function, returning a HashMap of arrays.
*
* @since 0.1.0
* @category internals
*/
function groupBy(items, key) {
return items.reduce((acc, item) => {
const k = key(item);
const existing = Option.getOrUndefined(HashMap.get(acc, k));
return existing ? (existing.push(item), acc) : HashMap.set(acc, k, [item]);
}, HashMap.empty());
}
/**
* Build the minimal ANSI escape helpers. Each function is a no-op when
* `noColor` is `true`.
*
* @since 0.1.0
* @category internals
*/
function makeAnsi(noColor) {
return {
bold: (s) => noColor ? s : `\x1b[1m${s}\x1b[22m`,
dim: (s) => noColor ? s : `\x1b[2m${s}\x1b[22m`,
yellow: (s) => noColor ? s : `\x1b[33m${s}\x1b[39m`,
cyan: (s) => noColor ? s : `\x1b[36m${s}\x1b[39m`,
magenta: (s) => noColor ? s : `\x1b[35m${s}\x1b[39m`,
gray: (s) => noColor ? s : `\x1b[90m${s}\x1b[39m`,
underline: (s) => noColor ? s : `\x1b[4m${s}\x1b[24m`,
reset: noColor ? "" : "\x1B[0m"
};
}
Schema.Struct({
dryRun: Schema.Boolean,
version: Schema.String
});
/**
* Format flag results into a terminal-friendly report string.
*
* Groups flags by rule name, then by file path. Includes source
* snippets, per-match instructions/hints, and a summary line.
* Returns a single "no rules triggered" line when the flag list
* is empty.
*
* Uses the `Env` service for colour/cwd detection and the Effect
* `Path` service for cross-platform path resolution.
*
* @since 0.1.0
* @category constructors
*/
const formatReport = Effect.fn("formatReport")(function* (flags, rulesMeta, options) {
const env = yield* Env;
const path = yield* Path.Path;
const ansi = makeAnsi(env.noColor);
if (flags.length === 0) return `${ansi.bold("agentlint")} ${ansi.dim(`v${options.version}`)} ${ansi.dim("-")} no rules triggered.`;
const { cwd } = env;
const lines = [];
const grouped = groupBy(flags, (f) => f.ruleName);
const groupedSize = HashMap.size(grouped);
if (flags.length > 50) {
lines.push(ansi.yellow("⚠") + ` ${flags.length} matches across ${groupedSize} rules. ` + ansi.dim("Consider narrowing scope with --rule or targeting specific files."));
lines.push("");
}
for (const [ruleName, ruleFlags] of grouped) {
const meta = Option.getOrUndefined(HashMap.get(rulesMeta, ruleName));
lines.push(ansi.yellow(` x ${ruleName}`) + ansi.dim(meta ? `: ${meta.description}` : ""));
lines.push("");
const byFile = groupBy(ruleFlags, (f) => path.relative(cwd, f.filename).replace(/\\/g, "/"));
for (const [_filePath, fileFlags] of byFile) for (const flag of fileFlags) {
const loc = `${path.relative(cwd, flag.filename).replace(/\\/g, "/")}:${flag.line}:${flag.col}`;
const snippet = flag.sourceSnippet.length > 80 ? flag.sourceSnippet.slice(0, 77) + "..." : flag.sourceSnippet;
lines.push(` ${ansi.cyan(loc)} ${ansi.dim(`[${flag.hash}]`)} ${flag.message}`);
if (snippet && snippet !== flag.message) lines.push(` ${ansi.dim(snippet)}`);
lines.push("");
}
if (!options.dryRun && meta?.instruction) {
lines.push(ansi.dim(" ┌─ Instruction ─────────────────────────────────"));
for (const instrLine of meta.instruction.split("\n")) lines.push(ansi.dim(` │ ${instrLine}`));
lines.push(ansi.dim(" └───────────────────────────────────────────────"));
lines.push("");
}
const matchNotes = ruleFlags.filter((f) => f.instruction || f.suggest);
if (!options.dryRun && matchNotes.length > 0) {
for (const flag of matchNotes) {
const relPath = path.relative(cwd, flag.filename).replace(/\\/g, "/");
if (flag.instruction) lines.push(` ${ansi.magenta("note")} ${ansi.dim(`${relPath}:${flag.line}`)} ${flag.instruction}`);
if (flag.suggest) lines.push(` ${ansi.magenta("hint")} ${ansi.dim(`${relPath}:${flag.line}`)} ${flag.suggest}`);
}
lines.push("");
}
}
const ruleWord = groupedSize === 1 ? "rule" : "rules";
const matchWord = flags.length === 1 ? "match" : "matches";
lines.push(ansi.bold(ansi.yellow(`Found ${flags.length} ${matchWord}`)) + ansi.dim(` (${groupedSize} ${ruleWord})`));
return lines.join("\n");
});
//#endregion
//#region src/bin.ts
/**
* CLI entry point for `agentlint`.
*
* Thin adapter that translates CLI arguments into feature commands,
* dispatches to the appropriate handler, and formats the result
* for terminal output.
*
* @module
* @since 0.1.0
*/
/** The `check` subcommand — scans files and outputs a report. */
const check = Command.make("check", {
files: Argument.string("files").pipe(Argument.withDescription("Specific files or globs to scan"), Argument.variadic()),
all: Flag.boolean("all").pipe(Flag.withAlias("a"), Flag.withDescription("Scan all files (not just git diff)")),
rule: Flag.string("rule").pipe(Flag.withAlias("r"), Flag.withDescription("Run only this rule (comma-separated for multiple)"), Flag.optional),
dryRun: Flag.boolean("dry-run").pipe(Flag.withAlias("d"), Flag.withDescription("Show counts only, no instruction blocks")),
base: Flag.string("base").pipe(Flag.withDescription("Git ref to diff against"), Flag.optional)
}, (config) => {
const ruleFilter = Option.match(config.rule, {
onNone: () => [],
onSome: (r) => r.split(",").map((s) => s.trim())
});
const baseRef = Option.match(config.base, {
onNone: () => void 0,
onSome: (b) => b
});
return Effect.gen(function* () {
const env = yield* Env;
const result = yield* checkHandler(new CheckCommand({
all: config.all,
rules: ruleFilter,
dryRun: config.dryRun,
base: baseRef,
files: config.files
}));
if (result.noMatchingRules) {
yield* Console.log(`No matching rules found. Available: ${result.availableRules.join(", ")}`);
return;
}
if (result.totalFlags === 0) {
yield* Console.log(`agentlint v0.1.5 - no rules triggered.`);
return;
}
const cfg = yield* (yield* ConfigLoader).load();
const rulesMeta = HashMap.fromIterable(Object.entries(cfg.rules).map(([name, rule]) => [name, rule.meta]));
const output = yield* formatReport(result.flags, rulesMeta, {
dryRun: config.dryRun,
version: "0.1.5"
});
yield* Console.log(output);
if (result.filteredCount > 0) yield* Console.log(` (${result.filteredCount} reviewed flag(s) hidden — run agentlint review --reset to clear)`);
if (result.flags.length > 0) env.setExitCode(1);
});
}).pipe(Command.withDescription("Scan files and output report for AI agents"));
/** The `list` subcommand — prints all registered rules. */
const list = Command.make("list", {}, () => Effect.gen(function* () {
const result = yield* listHandler(new ListCommand({}));
if (result.rules.length === 0) {
yield* Console.log("No rules registered.");
return;
}
yield* Console.log(`${result.rules.length} rule(s) registered:\n`);
for (const rule of result.rules) {
const langs = rule.languages.join(", ");
yield* Console.log(` ${rule.name}`);
yield* Console.log(` ${rule.description}`);
yield* Console.log(` Languages: ${langs}`);
if (rule.include) yield* Console.log(` Include: ${rule.include.join(", ")}`);
if (rule.ignore) yield* Console.log(` Ignore: ${rule.ignore.join(", ")}`);
yield* Console.log();
}
})).pipe(Command.withDescription("List all registered rules"));
/** The `init` subcommand — scaffolds a starter config file. */
const init = Command.make("init", {}, () => Effect.gen(function* () {
const result = yield* initHandler(new InitCommand({}));
yield* Console.log(result.message);
})).pipe(Command.withDescription("Create agentlint.config.ts and set up agent skill discovery"));
/** The `review` subcommand — manage reviewed-flag state. */
const review = Command.make("review", {
hashes: Argument.string("hashes").pipe(Argument.withDescription("Flag hashes to mark as reviewed"), Argument.variadic()),
all: Flag.boolean("all").pipe(Flag.withAlias("a"), Flag.withDescription("Mark all current flags as reviewed")),
reset: Flag.boolean("reset").pipe(Flag.withDescription("Wipe the state file"))
}, (config) => Effect.gen(function* () {
const result = yield* reviewHandler(new ReviewCommand({
hashes: config.hashes,
all: config.all,
reset: config.reset
}));
yield* Console.log(result.message);
})).pipe(Command.withDescription("Mark flags as reviewed (filters them from check output)"));
const agentlint = Command.make("agentlint").pipe(Command.withDescription("Deterministic linting for AI agents"), Command.withSubcommands([
check,
list,
init,
review
]));
const AppLayer = Layer.mergeAll(ConfigLoader.layer, Parser$1.layer, Git.layer, StateStore.layer).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Env.layer));
const program = Command.run(agentlint, { version: "0.1.5" }).pipe(Effect.provide(AppLayer));
NodeRuntime.runMain(program);
//#endregion
export {};
//# sourceMappingURL=bin.mjs.map