UNPKG

@aurelienbbn/agentlint

Version:

Stateless, deterministic CLI that bridges traditional linters and AI-assisted code review

1,306 lines (1,304 loc) 45.3 kB
#!/usr/bin/env node 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