UNPKG

@aurelienbbn/agentlint

Version:

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

1 lines 90.1 kB
{"version":3,"file":"bin.mjs","names":["#filename","#source","Parser","TSParser","Parser","Parser"],"sources":["../src/config/env.ts","../src/shared/infrastructure/config-loader.ts","../src/shared/infrastructure/state-store.ts","../src/domain/hash.ts","../src/domain/rule-context.ts","../src/shared/pipeline/file-resolver.ts","../src/shared/infrastructure/git.ts","../src/shared/infrastructure/parser.ts","../src/shared/pipeline/tree-walker.ts","../src/shared/pipeline/language-map.ts","../src/shared/pipeline/collect-flags.ts","../src/features/check/request.ts","../src/features/check/handler.ts","../src/features/init/request.ts","../src/features/init/handler.ts","../src/features/list/request.ts","../src/features/list/handler.ts","../src/features/review/request.ts","../src/features/review/handler.ts","../src/cli/reporter.ts","../src/bin.ts"],"sourcesContent":["/**\n * Centralised process / environment access.\n *\n * **This is the only module in the codebase that may touch `process.*`.**\n *\n * Every other module that needs the working directory, TTY state, colour\n * preference, or exit-code control must depend on the `Env` service\n * instead of reaching into `process` directly.\n *\n * The layer is built once at startup with `Layer.sync` (no external\n * dependencies), so it can be provided before every other service layer.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { Context, Layer } from \"effect\";\n\n/**\n * Read-only snapshot of the runtime environment.\n *\n * @since 0.1.0\n * @category services\n */\nexport class Env extends Context.Service<\n Env,\n {\n /** Current working directory, captured at startup. */\n readonly cwd: string;\n /** `true` when ANSI colour codes should be suppressed (`NO_COLOR` or non-TTY). */\n readonly noColor: boolean;\n /** `true` when stdout is an interactive terminal. */\n readonly isTTY: boolean;\n /** Set the process exit code (non-zero signals failure to the shell). */\n setExitCode(code: number): void;\n }\n>()(\"agentlint/Env\") {\n /**\n * Default layer — reads from `process` globals exactly once.\n *\n * @since 0.1.0\n * @category layers\n */\n static readonly layer: Layer.Layer<Env> = Layer.sync(Env, () => {\n /* eslint-disable n/no-process-env -- single authorised access point */\n const isTTY = process.stdout.isTTY ?? false;\n return Env.of({\n cwd: process.cwd(),\n noColor: !!process.env[\"NO_COLOR\"] || !isTTY,\n isTTY,\n setExitCode: (code) => {\n process.exitCode = code;\n },\n });\n /* eslint-enable n/no-process-env */\n });\n}\n","/**\n * Configuration file discovery and loading.\n *\n * Searches the current working directory for a config file, imports it\n * via `jiti` (for TypeScript support without pre-compilation), and\n * validates the exported shape.\n *\n * **Search order**: `agentlint.config.ts` → `.js` → `.mts` → `.mjs`.\n * The first match wins.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { Context, Effect, FileSystem, Layer, Path, Schema } from \"effect\";\nimport { Env } from \"../../config/env.js\";\nimport type { AgentReviewConfig } from \"../../domain/config.js\";\n\n/**\n * Raised when the config file is missing, malformed, or fails to import.\n *\n * @since 0.1.0\n * @category errors\n */\nexport class ConfigError extends Schema.TaggedErrorClass<ConfigError>()(\"ConfigError\", {\n message: Schema.String,\n}) {}\n\n/**\n * Candidate config file names, checked in order.\n *\n * @since 0.1.0\n * @category constants\n */\nconst CONFIG_NAMES = [\"agentlint.config.ts\", \"agentlint.config.js\", \"agentlint.config.mts\", \"agentlint.config.mjs\"];\n\n/**\n * Discover the config file path by checking candidates in order.\n *\n * @since 0.1.0\n * @category internals\n */\nconst discoverConfig = (fs: FileSystem.FileSystem, path: Path.Path, cwd: string): Effect.Effect<string, ConfigError> =>\n Effect.gen(function* () {\n for (const name of CONFIG_NAMES) {\n const candidate = path.resolve(cwd, name);\n if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {\n return candidate;\n }\n }\n return yield* new ConfigError({\n message: `No agentlint config found. Create agentlint.config.ts in ${cwd}`,\n });\n });\n\n/**\n * Effect service that discovers and loads the agentlint config file.\n *\n * Uses `jiti` under the hood so TypeScript configs work without a\n * separate compilation step.\n *\n * @example\n * ```ts\n * import { Console, Effect } from \"effect\"\n * import { ConfigLoader } from \"./infrastructure/config-loader.js\"\n *\n * const program = Effect.gen(function* () {\n * const loader = yield* ConfigLoader\n * const config = yield* loader.load()\n * yield* Console.log(Object.keys(config.rules))\n * })\n * ```\n *\n * @since 0.1.0\n * @category services\n */\nexport class ConfigLoader extends Context.Service<\n ConfigLoader,\n {\n /** Discover and import the config file from the working directory. */\n load(): Effect.Effect<AgentReviewConfig, ConfigError>;\n }\n>()(\"agentlint/ConfigLoader\") {\n static readonly layer: Layer.Layer<ConfigLoader, never, FileSystem.FileSystem | Path.Path | Env> = Layer.effect(\n ConfigLoader,\n Effect.gen(function* () {\n const env = yield* Env;\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n return ConfigLoader.of({\n load: () =>\n Effect.gen(function* () {\n const configPath = yield* discoverConfig(fs, path, env.cwd);\n\n const config = yield* Effect.tryPromise({\n try: async () => {\n const { createJiti } = await import(\"jiti\");\n const jiti = createJiti(import.meta.url, {\n interopDefault: true,\n });\n const loaded = await jiti.import(configPath);\n return (loaded as { default?: AgentReviewConfig }).default ?? (loaded as AgentReviewConfig);\n },\n catch: (error) =>\n new ConfigError({\n message: error instanceof Error ? error.message : String(error),\n }),\n });\n\n if (!config || typeof config !== \"object\" || !(\"rules\" in config)) {\n return yield* new ConfigError({\n message: `Invalid config at ${configPath}: must export an object with a \"rules\" field`,\n });\n }\n\n return config;\n }),\n });\n }),\n );\n}\n","/**\n * Local state store for tracking reviewed flags.\n *\n * Manages a `.agentlint-state` file in the project root that stores\n * hashes of flags that have been reviewed. This file is intended to\n * be **gitignored** — it is per-developer scratch state for tracking\n * progress during review sweeps.\n *\n * **Caveats**:\n * - Hashes encode file path, line, column, and message. Editing code\n * above a reviewed flag shifts its position and invalidates the hash.\n * This is by design — changed context should be re-reviewed.\n * - Stale hashes (from flags that no longer exist) accumulate harmlessly.\n * Use `agentlint review --reset` to start fresh.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { Context, Effect, FileSystem, HashSet, Layer, Path } from \"effect\";\nimport { Env } from \"../../config/env.js\";\n\n/**\n * The filename used for local review state.\n *\n * @since 0.1.0\n * @category constants\n */\nconst STATE_FILENAME = \".agentlint-state\";\n\n/**\n * Parse the state file into a set of hashes.\n * Tolerates blank lines and `#`-prefixed comments.\n *\n * @since 0.1.0\n * @category internals\n */\nfunction parseStateFile(content: string): HashSet.HashSet<string> {\n let hashes: HashSet.HashSet<string> = HashSet.empty();\n for (const raw of content.split(\"\\n\")) {\n const line = raw.trim();\n if (line.length > 0 && !line.startsWith(\"#\")) {\n hashes = HashSet.add(hashes, line);\n }\n }\n return hashes;\n}\n\n/**\n * Serialize a set of hashes into file content.\n *\n * @since 0.1.0\n * @category internals\n */\nfunction serializeHashes(hashes: HashSet.HashSet<string>): string {\n return [...hashes].join(\"\\n\") + \"\\n\";\n}\n\n/**\n * Effect service for loading and persisting reviewed-flag state.\n *\n * @since 0.1.0\n * @category services\n */\nexport class StateStore extends Context.Service<\n StateStore,\n {\n /** Load reviewed hashes from `.agentlint-state`. Returns an empty set if the file is missing. */\n load(): Effect.Effect<HashSet.HashSet<string>>;\n /** Append one or more hashes to `.agentlint-state`, deduplicating against existing entries. */\n append(hashes: ReadonlyArray<string>): Effect.Effect<void>;\n /** Delete the `.agentlint-state` file entirely. */\n reset(): Effect.Effect<void>;\n }\n>()(\"agentlint/StateStore\") {\n /**\n * Default layer — resolves the state file path from `Env.cwd`.\n *\n * @since 0.1.0\n * @category layers\n */\n static readonly layer: Layer.Layer<StateStore, never, FileSystem.FileSystem | Path.Path | Env> = Layer.unwrap(\n Effect.gen(function* () {\n const env = yield* Env;\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n const statePath = path.resolve(env.cwd, STATE_FILENAME);\n\n return Layer.succeed(\n StateStore,\n StateStore.of({\n load: () =>\n fs.exists(statePath).pipe(\n Effect.orElseSucceed(() => false),\n Effect.flatMap((exists) =>\n exists\n ? fs.readFileString(statePath).pipe(\n Effect.map(parseStateFile),\n Effect.orElseSucceed(() => HashSet.empty<string>()),\n )\n : Effect.succeed(HashSet.empty<string>()),\n ),\n ),\n\n append: (hashes) =>\n fs.exists(statePath).pipe(\n Effect.orElseSucceed(() => false),\n Effect.flatMap((exists) =>\n exists\n ? fs.readFileString(statePath).pipe(\n Effect.map(parseStateFile),\n Effect.orElseSucceed(() => HashSet.empty<string>()),\n )\n : Effect.succeed(HashSet.empty<string>()),\n ),\n Effect.map((existing) => hashes.reduce((acc, h) => HashSet.add(acc, h), existing)),\n Effect.flatMap((merged) =>\n fs.writeFileString(statePath, serializeHashes(merged)).pipe(Effect.orElseSucceed(() => {})),\n ),\n ),\n\n reset: () =>\n fs.exists(statePath).pipe(\n Effect.orElseSucceed(() => false),\n Effect.flatMap((exists) =>\n exists ? fs.remove(statePath).pipe(Effect.orElseSucceed(() => {})) : Effect.void,\n ),\n ),\n }),\n );\n }),\n );\n}\n","/**\n * FNV-1a hashing utility.\n *\n * Produces a 7-character hex digest used for stable, deterministic\n * flag identification. The hash encodes rule name, file path, position,\n * and message so that identical matches across runs share the same id.\n *\n * @see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function\n *\n * @module\n * @since 0.1.0\n */\n\n/**\n * FNV-1a 32-bit offset basis.\n *\n * @since 0.1.0\n * @category constants\n */\nconst FNV_OFFSET_BASIS = 0x811c9dc5;\n\n/**\n * FNV-1a 32-bit prime multiplier.\n *\n * @since 0.1.0\n * @category constants\n */\nconst FNV_PRIME = 0x01000193;\n\n/**\n * Compute a 7-character hex FNV-1a hash of `input`.\n *\n * The result is the first 7 hex characters of the unsigned 32-bit\n * FNV-1a digest — short enough for display, long enough to avoid\n * collisions in typical lint runs.\n *\n * @example\n * ```ts\n * import { fnv1a7 } from \"./utils/hash.js\"\n *\n * fnv1a7(\"my-rule:src/index.ts:10:1:message\") // => \"a3f4b2c\"\n * ```\n *\n * @since 0.1.0\n * @category constructors\n */\nexport function fnv1a7(input: string): string {\n let hash = FNV_OFFSET_BASIS;\n for (let i = 0; i < input.length; i++) {\n hash ^= input.charCodeAt(i);\n hash = Math.imul(hash, FNV_PRIME);\n }\n return (hash >>> 0).toString(16).padStart(8, \"0\").slice(0, 7);\n}\n","/**\n * Rule context — the interface rules use to interact with the runner.\n *\n * Provides file metadata, source access, and the {@link RuleContext.flag}\n * method for recording matches.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { fnv1a7 } from \"./hash.js\";\nimport { type FlagOptions, FlagRecord } from \"./flag.js\";\n\n/**\n * Context object passed to `createOnce`. Available throughout the rule's lifecycle.\n *\n * @since 0.1.0\n * @category models\n */\nexport interface RuleContext {\n /** Absolute path of the current file being analyzed. */\n getFilename(): string;\n /** Full source content of the current file. */\n getSourceCode(): string;\n /**\n * Lines around the given 1-based line number, formatted with line numbers.\n * @param line 1-based line number\n * @param radius number of lines above/below to include (default 10)\n */\n getLinesAround(line: number, radius?: number): string;\n /** Record a match for the output report. */\n flag(options: FlagOptions): void;\n}\n\n/**\n * Internal implementation of {@link RuleContext}.\n *\n * Tracks the current file, accumulates flags, and provides source\n * access helpers. The check command calls {@link setFile} before each\n * file and {@link drainFlags} after the tree walk to collect results.\n *\n * @since 0.1.0\n * @category internals\n */\nexport class RuleContextImpl implements RuleContext {\n readonly ruleName: string;\n readonly flags: FlagRecord[] = [];\n\n #filename = \"\";\n #source = \"\";\n\n constructor(ruleName: string) {\n this.ruleName = ruleName;\n }\n\n /**\n * Set the current file context. Called by the check command before\n * each file is walked.\n */\n setFile(filename: string, source: string): void {\n this.#filename = filename;\n this.#source = source;\n }\n\n /**\n * Remove and return all accumulated flags. Called after the tree\n * walk for each file to collect results.\n */\n drainFlags(): FlagRecord[] {\n return this.flags.splice(0);\n }\n\n getFilename(): string {\n return this.#filename;\n }\n\n getSourceCode(): string {\n return this.#source;\n }\n\n getLinesAround(line: number, radius = 10): string {\n const lines = this.#source.split(\"\\n\");\n const start = Math.max(0, line - 1 - radius);\n const end = Math.min(lines.length, line + radius);\n return lines\n .slice(start, end)\n .map((l, i) => `${String(start + i + 1).padStart(4)} | ${l}`)\n .join(\"\\n\");\n }\n\n flag(options: FlagOptions): void {\n const line = options.node.startPosition.row + 1;\n const col = options.node.startPosition.column + 1;\n const sourceLines = this.#source.split(\"\\n\");\n const rawLine = sourceLines[line - 1] ?? \"\";\n const trimmed = rawLine.trim();\n const sourceSnippet = trimmed.length > 100 ? trimmed.slice(0, 97) + \"...\" : trimmed;\n\n const hash = fnv1a7(`${this.ruleName}:${this.#filename}:${line}:${col}:${options.message}`);\n\n this.flags.push(\n new FlagRecord({\n ruleName: this.ruleName,\n filename: this.#filename,\n line,\n col,\n message: options.message,\n sourceSnippet,\n hash,\n instruction: options.instruction,\n suggest: options.suggest,\n }),\n );\n }\n}\n","/**\n * File resolution service.\n *\n * Determines which files to lint by applying the filter pipeline:\n * 1. Candidate files (from git diff or all files)\n * 2. Config include/ignore\n *\n * Per-rule filtering (languages, include, ignore) is done by the check command.\n *\n * @module\n */\n\nimport { Effect, FileSystem, HashSet, Path, Schema } from \"effect\";\nimport { Env } from \"../../config/env.js\";\nimport picomatch from \"picomatch\";\n\n/**\n * Raised when file resolution fails — e.g. a git error bubbling up\n * from the changed-files query.\n *\n * @since 0.1.0\n * @category errors\n */\nexport class FileResolverError extends Schema.TaggedErrorClass<FileResolverError>()(\"FileResolverError\", {\n message: Schema.String,\n}) {}\n\n/**\n * Options controlling which files enter the lint pipeline.\n *\n * @since 0.1.0\n * @category models\n */\nexport const ResolveOptions = Schema.Struct({\n /** When `true`, scan all files instead of only git-changed files. */\n all: Schema.Boolean,\n /** Git ref to diff against. Defaults to the detected default branch. */\n baseRef: Schema.optional(Schema.String),\n /** Global include globs from the config file. */\n configInclude: Schema.optional(Schema.Array(Schema.String)),\n /** Global ignore globs from the config file. */\n configIgnore: Schema.optional(Schema.Array(Schema.String)),\n /** Explicit file paths passed as CLI positional arguments. */\n positionalFiles: Schema.optional(Schema.Array(Schema.String)),\n});\n\n/** @since 0.1.0 */\nexport type ResolveOptions = Schema.Schema.Type<typeof ResolveOptions>;\n\n/** Directories that are always skipped during recursive listing. */\nconst SKIP_DIRS: HashSet.HashSet<string> = HashSet.make(\"node_modules\", \".git\", \"dist\");\n\n/**\n * Recursively list all files under `dir`, returning paths relative to `base`.\n *\n * Skips `node_modules`, `.git`, and `dist` directories. Errors (e.g.\n * permission denied) are silently swallowed.\n *\n * Uses the Effect `FileSystem` and `Path` services for cross-platform\n * file system access.\n *\n * @since 0.1.0\n * @category internals\n */\nfunction listAllFiles(dir: string, base: string, fs: FileSystem.FileSystem, path: Path.Path): Effect.Effect<string[]> {\n return Effect.gen(function* () {\n const entries = yield* fs.readDirectory(dir);\n const results: string[] = [];\n\n for (const name of entries) {\n if (HashSet.has(SKIP_DIRS, name)) continue;\n\n const fullPath = path.resolve(dir, name);\n const info = yield* fs.stat(fullPath);\n const relPath = path.relative(base, fullPath).replace(/\\\\/g, \"/\");\n\n if (info.type === \"Directory\") {\n results.push(...(yield* listAllFiles(fullPath, base, fs, path)));\n } else {\n results.push(relPath);\n }\n }\n\n return results;\n }).pipe(Effect.catch(() => Effect.succeed([] as string[])));\n}\n\n/**\n * Determine the final set of files to lint.\n *\n * Applies the multi-layer filter pipeline described in the module header,\n * then sorts the result alphabetically for deterministic output.\n *\n * @since 0.1.0\n * @category constructors\n */\nexport function resolveFiles(\n options: ResolveOptions,\n gitService: {\n changedFiles(baseRef?: string): Effect.Effect<ReadonlyArray<string>, any>;\n },\n): Effect.Effect<ReadonlyArray<string>, FileResolverError, FileSystem.FileSystem | Path.Path | Env> {\n return Effect.gen(function* () {\n const env = yield* Env;\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n const { cwd } = env;\n let candidates: string[];\n\n if (options.positionalFiles && options.positionalFiles.length > 0) {\n candidates = [...options.positionalFiles];\n } else if (options.all) {\n candidates = yield* listAllFiles(cwd, cwd, fs, path);\n } else {\n const changed = yield* Effect.mapError(\n gitService.changedFiles(options.baseRef),\n (e) => new FileResolverError({ message: `Git error: ${e}` }),\n );\n candidates = [...changed];\n }\n\n const includeMatcher = options.configInclude?.length ? picomatch(options.configInclude as string[]) : undefined;\n const ignoreMatcher = options.configIgnore?.length ? picomatch(options.configIgnore as string[]) : undefined;\n\n return candidates\n .filter((f) => !includeMatcher || includeMatcher(f))\n .filter((f) => !ignoreMatcher || !ignoreMatcher(f))\n .filter((f) => path.extname(f).length > 0)\n .toSorted();\n });\n}\n","/**\n * Git integration — default branch detection and changed file collection.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { Context, Effect, HashSet, Layer, Schema } from \"effect\";\nimport { Env } from \"../../config/env.js\";\nimport { ChildProcess, ChildProcessSpawner } from \"effect/unstable/process\";\n\n/**\n * Raised when a git operation fails — e.g. not a git repo,\n * invalid ref, or `git` binary not found.\n *\n * @since 0.1.0\n * @category errors\n */\nexport class GitError extends Schema.TaggedErrorClass<GitError>()(\"GitError\", {\n message: Schema.String,\n}) {}\n\n/**\n * Execute a git command and return trimmed stdout.\n *\n * Uses the array form of `ChildProcess.make` so that dynamic arguments\n * are properly tokenized.\n *\n * @since 0.1.0\n * @category internals\n */\nconst gitCmd = (args: string, cwd: string) =>\n Effect.gen(function* () {\n const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;\n return (yield* spawner.string(ChildProcess.make(\"git\", args.split(/\\s+/), { cwd }))).trim();\n });\n\n/**\n * Detect the default branch by checking whether `main` or `master` exists.\n * Falls back to `\"main\"` when neither can be verified.\n *\n * @since 0.1.0\n * @category internals\n */\nconst detectDefault = (cwd: string) =>\n gitCmd(\"rev-parse --verify main\", cwd).pipe(\n Effect.map(() => \"main\" as string),\n Effect.catch(() =>\n gitCmd(\"rev-parse --verify master\", cwd).pipe(\n Effect.map(() => \"master\" as string),\n Effect.catch(() => Effect.succeed(\"main\" as string)),\n ),\n ),\n );\n\n/**\n * Collect all files that differ from `baseRef`.\n *\n * Gathers the union of committed diffs, uncommitted changes, and\n * untracked files. Each source is caught so partial failures\n * (e.g. empty repo, no merge-base) are silently skipped.\n *\n * @since 0.1.0\n * @category internals\n */\nconst parseLines = (output: string): ReadonlyArray<string> =>\n output\n .split(\"\\n\")\n .map((f) => f.trim())\n .filter((f) => f.length > 0);\n\nconst collectChangedFiles = (cwd: string, baseRef: string) =>\n Effect.all([\n // Committed changes since merge-base\n gitCmd(`merge-base HEAD ${baseRef}`, cwd).pipe(\n Effect.flatMap((mergeBase) => gitCmd(`diff --name-only ${mergeBase}...HEAD`, cwd)),\n Effect.catch(() => Effect.succeed(\"\")),\n ),\n // Uncommitted changes\n gitCmd(\"diff --name-only HEAD\", cwd).pipe(Effect.catch(() => Effect.succeed(\"\"))),\n // Untracked files\n gitCmd(\"ls-files --others --exclude-standard\", cwd).pipe(Effect.catch(() => Effect.succeed(\"\"))),\n ]).pipe(\n Effect.map(([committed, uncommitted, untracked]) =>\n [\n ...HashSet.fromIterable([...parseLines(committed), ...parseLines(uncommitted), ...parseLines(untracked)]),\n ].toSorted(),\n ),\n );\n\n/**\n * @example\n * ```ts\n * import { Console, Effect } from \"effect\"\n * import { Git } from \"./infrastructure/git.js\"\n *\n * const program = Effect.gen(function* () {\n * const git = yield* Git\n * const branch = yield* git.detectDefaultBranch()\n * const changed = yield* git.changedFiles(branch)\n * yield* Console.log(`${changed.length} files changed since ${branch}`)\n * })\n * ```\n *\n * @since 0.1.0\n */\nexport class Git extends Context.Service<\n Git,\n {\n /** Detect whether the default branch is `main` or `master`. */\n detectDefaultBranch(): Effect.Effect<string, GitError>;\n /** Return sorted list of files changed relative to `baseRef` (defaults to the detected default branch). */\n changedFiles(baseRef?: string): Effect.Effect<ReadonlyArray<string>, GitError>;\n }\n>()(\"agentlint/Git\") {\n static readonly layer: Layer.Layer<Git, never, ChildProcessSpawner.ChildProcessSpawner | Env> = Layer.effect(\n Git,\n Effect.gen(function* () {\n const env = yield* Env;\n const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;\n const provide = <A, E>(effect: Effect.Effect<A, E, ChildProcessSpawner.ChildProcessSpawner>) =>\n Effect.provideService(effect, ChildProcessSpawner.ChildProcessSpawner, spawner);\n\n return Git.of({\n detectDefaultBranch: () =>\n provide(detectDefault(env.cwd)).pipe(Effect.mapError((e) => new GitError({ message: String(e) }))),\n\n changedFiles: (baseRef) =>\n (baseRef ? Effect.succeed(baseRef) : provide(detectDefault(env.cwd))).pipe(\n Effect.flatMap((base) => provide(collectChangedFiles(env.cwd, base))),\n Effect.mapError((e) => new GitError({ message: String(e) })),\n ),\n });\n }),\n );\n}\n","/**\n * Tree-sitter WASM parser.\n *\n * WASM init is lazy — the first `parse` call triggers initialization.\n * Grammars are cached after first load.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { Context, Effect, FileSystem, HashMap, Layer, Option, Path, Schema } from \"effect\";\nimport { Env } from \"../../config/env.js\";\nimport { Language, Parser as TSParser, type Tree } from \"web-tree-sitter\";\n\n/**\n * Raised when parsing fails — e.g. missing grammar, corrupt WASM, or\n * tree-sitter returning a null tree.\n *\n * @since 0.1.0\n * @category errors\n */\nexport class ParserError extends Schema.TaggedErrorClass<ParserError>()(\"ParserError\", {\n message: Schema.String,\n}) {}\n\n/**\n * Maps grammar names to their corresponding `.wasm` filenames.\n *\n * @since 0.1.0\n * @category constants\n */\nconst GRAMMAR_FILES: HashMap.HashMap<string, string> = HashMap.make(\n [\"typescript\", \"tree-sitter-typescript.wasm\"],\n [\"tsx\", \"tree-sitter-tsx.wasm\"],\n [\"javascript\", \"tree-sitter-javascript.wasm\"],\n);\n\nexport function resolvePackagedWasmPath(path: Pick<Path.Path, \"resolve\">, dir: string, filename: string): string {\n return path.resolve(dir, \"wasm\", filename);\n}\n\n/**\n * @example\n * ```ts\n * import { Console, Effect } from \"effect\"\n * import { Parser } from \"./infrastructure/parser.js\"\n *\n * const program = Effect.gen(function* () {\n * const parser = yield* Parser\n * const tree = yield* parser.parse(\"const x = 1\", \"typescript\")\n * yield* Console.log(tree.rootNode.type) // \"program\"\n * })\n * ```\n *\n * @since 0.1.0\n * @category services\n */\nexport class Parser extends Context.Service<\n Parser,\n {\n parse(source: string, grammar: string): Effect.Effect<Tree, ParserError>;\n }\n>()(\"agentlint/Parser\") {\n /** Default layer — lazily initializes WASM and caches grammars. */\n static readonly layer: Layer.Layer<Parser, never, FileSystem.FileSystem | Path.Path | Env> = Layer.effect(\n Parser,\n Effect.gen(function* () {\n const env = yield* Env;\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n const resolveWasmPath = (filename: string): Effect.Effect<string, ParserError> =>\n Effect.gen(function* () {\n const thisDir = path.resolve(import.meta.dirname ?? \".\");\n const distPath = resolvePackagedWasmPath(path, thisDir, filename);\n if (yield* fs.exists(distPath).pipe(Effect.orElseSucceed(() => false))) return distPath;\n\n const nmBase = path.resolve(env.cwd, \"node_modules\");\n if (filename === \"tree-sitter.wasm\") {\n const p = path.resolve(nmBase, \"web-tree-sitter\", filename);\n if (yield* fs.exists(p).pipe(Effect.orElseSucceed(() => false))) return p;\n } else {\n const p = path.resolve(nmBase, \"tree-sitter-wasms\", \"out\", filename);\n if (yield* fs.exists(p).pipe(Effect.orElseSucceed(() => false))) return p;\n }\n\n return yield* new ParserError({ message: `WASM file not found: ${filename}` });\n });\n\n let parserInstance: TSParser | undefined;\n let languageCache: HashMap.HashMap<string, Language> = HashMap.empty();\n\n return Parser.of({\n parse: (source, grammar) =>\n Effect.gen(function* () {\n if (!parserInstance) {\n const initPath = yield* resolveWasmPath(\"tree-sitter.wasm\");\n yield* Effect.tryPromise({\n try: async () => {\n await TSParser.init({ locateFile: () => initPath });\n parserInstance = new TSParser();\n },\n catch: (error) => new ParserError({ message: error instanceof Error ? error.message : String(error) }),\n });\n }\n\n let lang = Option.getOrUndefined(HashMap.get(languageCache, grammar));\n if (!lang) {\n const file = Option.getOrUndefined(HashMap.get(GRAMMAR_FILES, grammar));\n if (!file) return yield* new ParserError({ message: `Unknown grammar: ${grammar}` });\n\n const wasmPath = yield* resolveWasmPath(file);\n lang = yield* Effect.tryPromise({\n try: () => Language.load(wasmPath),\n catch: (error) => new ParserError({ message: error instanceof Error ? error.message : String(error) }),\n });\n languageCache = HashMap.set(languageCache, grammar, lang);\n }\n\n parserInstance!.setLanguage(lang);\n const tree = parserInstance!.parse(source);\n if (!tree) return yield* new ParserError({ message: \"Parser returned null tree\" });\n return tree;\n }),\n });\n }),\n );\n}\n","/**\n * Single-pass multi-rule tree walker.\n *\n * Builds a dispatch table from all active rules' visitor methods,\n * walks the tree once using tree-sitter's cursor API, and calls\n * all matching handlers per node.\n *\n * @module\n */\n\nimport { Effect, HashMap, Option } from \"effect\";\nimport type { Tree, TreeCursor } from \"web-tree-sitter\";\nimport { type AgentReviewNode, wrapNode } from \"../../domain/node.js\";\nimport type { FlagRecord } from \"../../domain/flag.js\";\nimport type { VisitorHandler, Visitors } from \"../../domain/rule.js\";\nimport type { RuleContextImpl } from \"../../domain/rule-context.js\";\n\n/**\n * Internal binding of a rule to its context and visitors for a walk pass.\n *\n * @since 0.1.0\n * @category models\n */\ninterface RuleEntry {\n readonly ruleName: string;\n readonly context: RuleContextImpl;\n readonly visitors: Visitors;\n}\n\n/**\n * A single handler entry in the dispatch table, keyed by node type.\n *\n * @since 0.1.0\n * @category models\n */\ninterface DispatchHandler {\n readonly ruleName: string;\n readonly handler: VisitorHandler;\n}\n\n/**\n * Walk files with the given rules, collecting all flags.\n *\n * Call this once per file. The caller is responsible for:\n * - Calling `context.setFile()` before this function\n * - Calling `before()` and filtering out skipped rules\n * - Calling `after()` after all files are processed\n *\n * @internal\n */\nexport function walkFile(tree: Tree, rules: ReadonlyArray<RuleEntry>): ReadonlyArray<FlagRecord> {\n const dispatchTable: HashMap.HashMap<string, DispatchHandler[]> = HashMap.mutate(\n HashMap.empty<string, DispatchHandler[]>(),\n (m) => {\n for (const entry of rules) {\n for (const key of Object.keys(entry.visitors)) {\n if (key === \"before\" || key === \"after\") continue;\n const handler = entry.visitors[key];\n if (typeof handler !== \"function\") continue;\n\n const existing = Option.getOrUndefined(HashMap.get(m, key));\n if (existing) {\n existing.push({ ruleName: entry.ruleName, handler: handler as VisitorHandler });\n } else {\n HashMap.set(m, key, [{ ruleName: entry.ruleName, handler: handler as VisitorHandler }]);\n }\n }\n }\n },\n );\n\n const cursor: TreeCursor = tree.walk();\n let reachedEnd = false;\n\n while (!reachedEnd) {\n const nodeType = cursor.nodeType;\n\n const handlers = Option.getOrUndefined(HashMap.get(dispatchTable, nodeType));\n if (handlers) {\n const wrapped: AgentReviewNode = wrapNode(cursor.currentNode);\n for (const { handler } of handlers) {\n handler(wrapped);\n }\n }\n\n if (cursor.gotoFirstChild()) continue;\n while (!cursor.gotoNextSibling()) {\n if (!cursor.gotoParent()) {\n reachedEnd = true;\n break;\n }\n }\n }\n\n const allFlags: FlagRecord[] = [];\n for (const entry of rules) {\n allFlags.push(...entry.context.drainFlags());\n }\n\n return allFlags;\n}\n\n/**\n * Effect wrapper around {@link walkFile}.\n *\n * Runs the tree walk synchronously inside `Effect.sync`, making it\n * composable with the rest of the Effect pipeline.\n *\n * @since 0.1.0\n * @category constructors\n */\nexport function walkFileEffect(tree: Tree, rules: ReadonlyArray<RuleEntry>): Effect.Effect<ReadonlyArray<FlagRecord>> {\n return Effect.sync(() => walkFile(tree, rules));\n}\n","/**\n * File extension → tree-sitter grammar mapping.\n *\n * Maps every supported file extension to the grammar name used by\n * the parser service. This is the single source of truth for which\n * file types agentlint can analyze.\n *\n * Uses Effect `HashMap` for an immutable, structurally-equal lookup table.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { HashMap, Option } from \"effect\";\n\n/**\n * Maps file extensions (without leading dot) to their tree-sitter\n * grammar name.\n *\n * @since 0.1.0\n * @category constants\n */\nconst EXTENSION_TO_GRAMMAR: HashMap.HashMap<string, string> = HashMap.make(\n [\"ts\", \"typescript\"],\n [\"tsx\", \"tsx\"],\n [\"js\", \"javascript\"],\n [\"jsx\", \"javascript\"],\n [\"mts\", \"typescript\"],\n [\"cts\", \"typescript\"],\n [\"mjs\", \"javascript\"],\n [\"cjs\", \"javascript\"],\n);\n\n/**\n * Look up the tree-sitter grammar name for a file extension.\n *\n * Returns `undefined` for unsupported extensions — callers should\n * skip those files.\n *\n * @since 0.1.0\n * @category constructors\n */\nexport function grammarForExtension(ext: string): string | undefined {\n return Option.getOrUndefined(HashMap.get(EXTENSION_TO_GRAMMAR, ext));\n}\n\n/**\n * Return all file extensions that agentlint can parse.\n *\n * @since 0.1.0\n * @category constructors\n */\nexport function supportedExtensions(): ReadonlyArray<string> {\n return [...HashMap.keys(EXTENSION_TO_GRAMMAR)];\n}\n","/**\n * Flag collection pipeline — shared between `check` and `review`.\n *\n * @module\n * @since 0.1.0\n */\n\nimport { Effect, FileSystem, Path, Schema } from \"effect\";\nimport picomatch from \"picomatch\";\nimport { Env } from \"../../config/env.js\";\nimport { FlagRecord } from \"../../domain/flag.js\";\nimport type { AgentReviewRule, Visitors } from \"../../domain/rule.js\";\nimport { RuleContextImpl } from \"../../domain/rule-context.js\";\nimport { ConfigLoader } from \"../infrastructure/config-loader.js\";\nimport { resolveFiles } from \"./file-resolver.js\";\nimport { Git } from \"../infrastructure/git.js\";\nimport { Parser } from \"../infrastructure/parser.js\";\nimport { walkFile } from \"./tree-walker.js\";\nimport { grammarForExtension } from \"./language-map.js\";\n\n/**\n * @since 0.1.0\n * @category models\n */\nexport const CollectResult = Schema.Struct({\n /** Collected flags. */\n flags: Schema.Array(FlagRecord),\n /** `true` when the `--rule` filter matched no registered rules. */\n noMatchingRules: Schema.Boolean,\n});\n\n/** @since 0.1.0 */\nexport type CollectResult = Schema.Schema.Type<typeof CollectResult>;\n\n/**\n * @since 0.1.0\n * @category models\n */\nexport const CollectOptions = Schema.Struct({\n /** When `true`, scan all files instead of only git-changed files. */\n all: Schema.Boolean,\n /** Rule name filter. Empty array means \"run all rules\". */\n rules: Schema.Array(Schema.String),\n /** When `true`, suppress instruction and hint blocks in output. */\n dryRun: Schema.Boolean,\n /** Git ref to diff against. `undefined` means auto-detect. */\n base: Schema.UndefinedOr(Schema.String),\n /** Explicit file paths from positional CLI arguments. */\n files: Schema.Array(Schema.String),\n});\n\n/** @since 0.1.0 */\nexport type CollectOptions = Schema.Schema.Type<typeof CollectOptions>;\n\n/** @since 0.1.0 */\nexport const collectFlags = Effect.fn(\"collectFlags\")(function* (\n options: CollectOptions,\n): Generator<any, CollectResult> {\n const configLoader = yield* ConfigLoader;\n const env = yield* Env;\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n const gitService = yield* Git;\n const parserService = yield* Parser;\n\n const config = yield* configLoader.load();\n\n let activeRules: Array<[string, AgentReviewRule]> = Object.entries(config.rules);\n if (options.rules.length > 0) {\n activeRules = activeRules.filter(([name]) => options.rules.includes(name));\n if (activeRules.length === 0) return { flags: [], noMatchingRules: true };\n }\n\n const includePatterns = config.include ? [...config.include] : undefined;\n const ignorePatterns = config.ignore ? [...config.ignore] : undefined;\n\n const files = yield* resolveFiles(\n {\n all: options.all,\n baseRef: options.base,\n configInclude: includePatterns,\n configIgnore: ignorePatterns,\n positionalFiles: options.files.length > 0 ? [...options.files] : undefined,\n },\n gitService,\n );\n\n if (files.length === 0) return { flags: [], noMatchingRules: false };\n\n const ruleEntries: Array<{\n name: string;\n rule: AgentReviewRule;\n context: RuleContextImpl;\n visitors: Visitors;\n }> = [];\n\n for (const [name, rule] of activeRules) {\n const context = new RuleContextImpl(name);\n const visitors = rule.createOnce(context);\n ruleEntries.push({ name, rule, context, visitors });\n }\n\n const allFlags: FlagRecord[] = [];\n\n for (const file of files) {\n const ext = path.extname(file).slice(1);\n const absPath = path.resolve(env.cwd, file);\n\n const applicableRules = ruleEntries.filter((entry) => {\n if (!entry.rule.meta.languages.includes(ext)) return false;\n\n if (entry.rule.meta.include && entry.rule.meta.include.length > 0) {\n const matcher = picomatch([...entry.rule.meta.include]);\n if (!matcher(file)) return false;\n }\n\n if (entry.rule.meta.ignore && entry.rule.meta.ignore.length > 0) {\n const matcher = picomatch([...entry.rule.meta.ignore]);\n if (matcher(file)) return false;\n }\n\n return true;\n });\n\n if (applicableRules.length === 0) continue;\n\n const sourceResult = yield* fs.readFileString(absPath).pipe(Effect.result);\n if (sourceResult._tag === \"Failure\") continue;\n const source = sourceResult.success;\n\n const grammar = grammarForExtension(ext);\n if (!grammar) continue;\n\n const tree = yield* parserService.parse(source, grammar);\n\n const rulesForFile: Array<{\n ruleName: string;\n context: RuleContextImpl;\n visitors: Visitors;\n }> = [];\n\n for (const entry of applicableRules) {\n entry.context.setFile(absPath, source);\n const beforeResult = entry.visitors.before?.(absPath);\n if (beforeResult === false) continue;\n rulesForFile.push({\n ruleName: entry.name,\n context: entry.context,\n visitors: entry.visitors,\n });\n }\n\n if (rulesForFile.length === 0) continue;\n\n const fileFlags = walkFile(tree, rulesForFile);\n allFlags.push(...fileFlags);\n }\n\n for (const entry of ruleEntries) {\n entry.visitors.after?.();\n }\n\n return { flags: allFlags, noMatchingRules: false };\n});\n","/**\n * @module\n * @since 0.1.0\n */\n\nimport { Schema } from \"effect\";\nimport { FlagRecord } from \"../../domain/flag.js\";\n\n/**\n * @since 0.1.0\n * @category models\n */\nexport class CheckCommand extends Schema.TaggedClass<CheckCommand>()(\"CheckCommand\", {\n /** When `true`, scan all files instead of only git-changed files. */\n all: Schema.Boolean,\n /** Rule name filter. Empty array means \"run all rules\". */\n rules: Schema.Array(Schema.String),\n /** When `true`, suppress instruction and hint blocks in output. */\n dryRun: Schema.Boolean,\n /** Git ref to diff against. `undefined` means auto-detect. */\n base: Schema.UndefinedOr(Schema.String),\n /** Explicit file paths from positional CLI arguments. */\n files: Schema.Array(Schema.String),\n}) {}\n\n/**\n * @since 0.1.0\n * @category models\n */\nexport class CheckResult extends Schema.TaggedClass<CheckResult>()(\"CheckResult\", {\n /** Unreviewed flags to display. */\n flags: Schema.Array(FlagRecord),\n /** Total flags before filtering out reviewed ones. */\n totalFlags: Schema.Number,\n /** Number of flags filtered out because they were previously reviewed. */\n filteredCount: Schema.Number,\n /** `true` when the `--rule` filter matched no registered rules. */\n noMatchingRules: Schema.Boolean,\n /** Available rule names (for error messages when no match). */\n availableRules: Schema.Array(Schema.String),\n}) {}\n","/**\n * @module\n * @since 0.1.0\n */\n\nimport { Effect, HashSet } from \"effect\";\nimport { ConfigLoader } from \"../../shared/infrastructure/config-loader.js\";\nimport { StateStore } from \"../../shared/infrastructure/state-store.js\";\nimport { collectFlags } from \"../../shared/pipeline/collect-flags.js\";\nimport { CheckCommand, CheckResult } from \"./request.js\";\n\n/** @since 0.1.0 */\nexport const checkHandler = Effect.fn(\"checkHandler\")(function* (command: CheckCommand) {\n const configLoader = yield* ConfigLoader;\n const stateStore = yield* StateStore;\n\n const config = yield* configLoader.load();\n const availableRules = Object.keys(config.rules);\n\n const result = yield* collectFlags({\n all: command.all,\n rules: command.rules,\n dryRun: command.dryRun,\n base: command.base,\n files: command.files,\n });\n\n if (result.noMatchingRules) {\n return new CheckResult({\n flags: [],\n totalFlags: 0,\n filteredCount: 0,\n noMatchingRules: true,\n availableRules,\n });\n }\n\n const allFlags = result.flags;\n\n if (allFlags.length === 0) {\n return new CheckResult({\n flags: [],\n totalFlags: 0,\n filteredCount: 0,\n noMatchingRules: false,\n availableRules,\n });\n }\n\n const reviewed = yield* stateStore.load();\n const reviewedSize = HashSet.size(reviewed);\n const filteredCount = reviewedSize > 0 ? allFlags.filter((f) => HashSet.has(reviewed, f.hash)).length : 0;\n const unreviewedFlags = reviewedSize > 0 ? allFlags.filter((f) => !HashSet.has(reviewed, f.hash)) : allFlags;\n\n return new CheckResult({\n flags: unreviewedFlags,\n totalFlags: allFlags.length,\n filteredCount,\n noMatchingRules: false,\n availableRules,\n });\n});\n","/**\n * @module\n * @since 0.1.0\n */\n\nimport { Schema } from \"effect\";\n\n/**\n * @since 0.1.0\n * @category models\n */\nexport class InitCommand extends Schema.TaggedClass<InitCommand>()(\"InitCommand\", {}) {}\n\n/**\n * @since 0.1.0\n * @category models\n */\nexport class InitResult extends Schema.TaggedClass<InitResult>()(\"InitResult\", {\n /** Whether a new config file was created. */\n created: Schema.Boolean,\n /** Human-readable message describing what happened. */\n message: Schema.String,\n}) {}\n","/**\n * @module\n * @since 0.1.0\n */\n\nimport { Effect, FileSystem, Path } from \"effect\";\nimport { Env } from \"../../config/env.js\";\nimport { InitCommand, InitResult } from \"./request.js\";\n\ntype PackageManager = \"npm\" | \"pnpm\" | \"yarn\" | \"bun\";\n\n/**\n * Minimal starter config written by `agentlint init`.\n *\n * @since 0.1.0\n * @category constants\n */\nconst STARTER_CONFIG = `import { defineConfig } from \"@aurelienbbn/agentlint\"\n\nexport default defineConfig({\n include: [\"src/**/*.{ts,tsx}\"],\n rules: {},\n})\n`;\n\nfunction packageManagerFromValue(value: string | undefined): PackageManager | undefined {\n if (!value) return undefined;\n if (value.startsWith(\"pnpm@\")) return \"pnpm\";\n if (value.startsWith(\"yarn@\")) return \"yarn\";\n if (value.startsWith(\"bun@\")) return \"bun\";\n if (value.startsWith(\"npm@\")) return \"npm\";\n return undefined;\n}\n\nfunction commandsForPackageManager(packageManager: PackageManager) {\n switch (packageManager) {\n case \"pnpm\":\n return {\n skillInstall: \"pnpm dlx skills@latest add aurelienbobenrieth/agentlint\",\n intentInstall: \"pnpm dlx @tanstack/intent install\",\n agentlintCheck: \"pnpm agentlint check --all\",\n };\n case \"yarn\":\n return {\n skillInstall: \"yarn dlx skills@latest add aurelienbobenrieth/agentlint\",\n intentInstall: \"yarn dlx @tanstack/intent install\",\n agentlintCheck: \"yarn agentlint check --all\",\n };\n case \"bun\":\n return {\n skillInstall: \"bunx skills@latest add aurelienbobenrieth/agentlint\",\n intentInstall: \"bunx @tanstack/intent install\",\n agentlintCheck: \"bun run agentlint check --all\",\n };\n case \"npm\":\n return {\n skillInstall: \"npx skills@latest add aurelienbobenrieth/agentlint\",\n intentInstall: \"npx @tanstack/intent install\",\n agentlintCheck: \"npm exec agentlint -- check --all\",\n };\n }\n}\n\nconst detectPackageManager = Effect.fn(\"detectPackageManager\")(function* (cwd: string) {\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n const packageJsonPath = path.resolve(cwd, \"package.json\");\n if (yield* fs.exists(packageJsonPath)) {\n const packageJson = yield* fs.readFileString(packageJsonPath).pipe(Effect.orElseSucceed(() => \"\"));\n try {\n const parsed = JSON.parse(packageJson) as { packageManager?: string };\n const detected = packageManagerFromValue(parsed.packageManager);\n if (detected) return detected;\n } catch {\n // Ignore invalid package.json contents and fall back to lockfiles.\n }\n }\n\n const lockfiles: ReadonlyArray<readonly [string, PackageManager]> = [\n [\"pnpm-lock.yaml\", \"pnpm\"],\n [\"yarn.lock\", \"yarn\"],\n [\"package-lock.json\", \"npm\"],\n [\"bun.lock\", \"bun\"],\n [\"bun.lockb\", \"bun\"],\n ];\n\n for (const [lockfile, packageManager] of lockfiles) {\n if (yield* fs.exists(path.resolve(cwd, lockfile))) {\n return packageManager;\n }\n }\n\n return \"npm\" as const;\n});\n\n/**\n * Detect which skill installation method is most likely appropriate.\n *\n * Checks for TanStack Intent or existing AGENTS.md with intent block.\n */\nconst detectSkillMethod = Effect.fn(\"detectSkillMethod\")(function* (cwd: string) {\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n // Check if TanStack Intent is installed\n const hasIntent = yield* fs\n .exists(path.resolve(cwd, \"node_modules/@tanstack/intent\"))\n .pipe(Effect.orElseSucceed(() => false));\n\n if (hasIntent) return \"intent\" as const;\n\n // Check if AGENTS.md has an intent-skills block (installed by intent previously)\n const agentsPath = path.resolve(cwd, \"AGENTS.md\");\n if (yield* fs.exists(agentsPath)) {\n const content = yield* fs.readFileString(agentsPath);\n if (content.includes(\"intent-skills:start\")) return \"intent\" as const;\n }\n\n return \"skills\" as const;\n});\n\n/** @since 0.1.0 */\nexport const initHandler = Effect.fn(\"initHandler\")(function* (_command: InitCommand) {\n const env = yield* Env;\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n const configPath = path.resolve(env.cwd, \"agentlint.config.ts\");\n\n const gitignorePath = path.resolve(env.cwd, \".gitignore\");\n\n // --- Step 1: Create config ---\n const configCreated = !(yield* fs.exists(configPath));\n if (configCreated) {\n yield* fs.writeFileString(configPath, STARTER_CONFIG);\n }\n\n // --- Step 2: Ensure .agentlint-state is gitignored ---\n let gitignoreUpdated = false;\n const gitignoreExists = yield* fs.exists(gitignorePath);\n if (gitignoreExists) {\n const content = yield* fs.readFileString(gitignorePath);\n if (!content.includes(\".agentlint-state\")) {\n const separator = content.endsWith(\"\\n\") ? \"\" : \"\\n\";\n yield* fs.writeFileString(gitignorePath, content + separator + \"\\n# agentlint local state\\n.agentlint-state\\n\");\n gitignoreUpdated = true;\n }\n } else {\n yield* fs.writeFileString(gitignorePath, \"# agentlint local state\\n.agentlint-state\\n\");\n gitignoreUpdated = true;\n }\n\n const lines: Array<string> = [];\n\n if (configCreated) {\n lines.push(\"✓ Created agentlint.config.ts\");\n } else {\n lines.push(\"· agentlint.config.ts already exists — skipped\");\n }\n\n if (gitignoreUpdated) {\n lines.push(\"✓ Added .agentlint-state to .gitignore\");\n }\n\n // --- Step 2: Next steps ---\n const packageManager = yield* detectPackageManager(env.cwd);\n const commands = commandsForPackageManager(packageManager);\n const method = yield* detectSkillMethod(env.cwd);\n const skillCmd = method === \"intent\" ? commands.intentInstall : commands.skillInstall;\n\n lines.push(\n \"\",\n \"Next steps:\",\n \" 1. Add rules to your config\",\n ` 2. Install the agentlint skill for your AI agents:`,\n ` ${skillCmd}`,\n ` 3. Run: ${commands.agentlintCheck}`,\n );\n\n return new InitResult({\n created: configCreated,\n message: lines.join(\"\\n\"),\n });\n});\n","/**\n * @module\n * @since 0.1.0\n */\n\nimport { Schema } from \"effect\";\n\n/**\n * @since 0.1.0\n *