@aurelienbbn/agentlint 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.mjs ADDED
@@ -0,0 +1,1355 @@
1
+ #!/usr/bin/env node
2
+ import { n as wrapNode, r as FlagRecord } from "./node-yh9mLvnE.mjs";
3
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4
+ import * as NodeServices from "@effect/platform-node/NodeServices";
5
+ import { Console, Effect, FileSystem, HashMap, HashSet, Layer, Option, Path, Schema } from "effect";
6
+ import { Argument, Command, Flag } from "effect/unstable/cli";
7
+ import * as ServiceMap from "effect/ServiceMap";
8
+ import picomatch from "picomatch";
9
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10
+ import { Language, Parser } from "web-tree-sitter";
11
+ //#region src/config/env.ts
12
+ /**
13
+ * Centralised process / environment access.
14
+ *
15
+ * **This is the only module in the codebase that may touch `process.*`.**
16
+ *
17
+ * Every other module that needs the working directory, TTY state, colour
18
+ * preference, or exit-code control must depend on the `Env` service
19
+ * instead of reaching into `process` directly.
20
+ *
21
+ * The layer is built once at startup with `Layer.sync` (no external
22
+ * dependencies), so it can be provided before every other service layer.
23
+ *
24
+ * @module
25
+ * @since 0.1.0
26
+ */
27
+ /**
28
+ * Read-only snapshot of the runtime environment.
29
+ *
30
+ * @since 0.1.0
31
+ * @category services
32
+ */
33
+ var Env = class Env extends ServiceMap.Service()("agentreview/Env") {
34
+ /**
35
+ * Default layer — reads from `process` globals exactly once.
36
+ *
37
+ * @since 0.1.0
38
+ * @category layers
39
+ */
40
+ static layer = Layer.sync(Env, () => {
41
+ const isTTY = process.stdout.isTTY ?? false;
42
+ return Env.of({
43
+ cwd: process.cwd(),
44
+ noColor: !!process.env["NO_COLOR"] || !isTTY,
45
+ isTTY,
46
+ setExitCode: (code) => {
47
+ process.exitCode = code;
48
+ }
49
+ });
50
+ });
51
+ };
52
+ //#endregion
53
+ //#region src/shared/infrastructure/config-loader.ts
54
+ /**
55
+ * Configuration file discovery and loading.
56
+ *
57
+ * Searches the current working directory for a config file, imports it
58
+ * via `jiti` (for TypeScript support without pre-compilation), and
59
+ * validates the exported shape.
60
+ *
61
+ * **Search order**: `agentreview.config.ts` → `.js` → `.mts` → `.mjs`.
62
+ * The first match wins.
63
+ *
64
+ * @module
65
+ * @since 0.1.0
66
+ */
67
+ /**
68
+ * Raised when the config file is missing, malformed, or fails to import.
69
+ *
70
+ * @since 0.1.0
71
+ * @category errors
72
+ */
73
+ var ConfigError = class extends Schema.TaggedErrorClass()("ConfigError", { message: Schema.String }) {};
74
+ /**
75
+ * Candidate config file names, checked in order.
76
+ *
77
+ * @since 0.1.0
78
+ * @category constants
79
+ */
80
+ const CONFIG_NAMES = [
81
+ "agentreview.config.ts",
82
+ "agentreview.config.js",
83
+ "agentreview.config.mts",
84
+ "agentreview.config.mjs"
85
+ ];
86
+ /**
87
+ * Discover the config file path by checking candidates in order.
88
+ *
89
+ * @since 0.1.0
90
+ * @category internals
91
+ */
92
+ const discoverConfig = (fs, path, cwd) => Effect.gen(function* () {
93
+ for (const name of CONFIG_NAMES) {
94
+ const candidate = path.resolve(cwd, name);
95
+ if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) return candidate;
96
+ }
97
+ return yield* new ConfigError({ message: `No agentreview config found. Create agentreview.config.ts in ${cwd}` });
98
+ });
99
+ /**
100
+ * Effect service that discovers and loads the agentreview config file.
101
+ *
102
+ * Uses `jiti` under the hood so TypeScript configs work without a
103
+ * separate compilation step.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * import { Console, Effect } from "effect"
108
+ * import { ConfigLoader } from "./infrastructure/config-loader.js"
109
+ *
110
+ * const program = Effect.gen(function* () {
111
+ * const loader = yield* ConfigLoader
112
+ * const config = yield* loader.load()
113
+ * yield* Console.log(Object.keys(config.rules))
114
+ * })
115
+ * ```
116
+ *
117
+ * @since 0.1.0
118
+ * @category services
119
+ */
120
+ var ConfigLoader = class ConfigLoader extends ServiceMap.Service()("agentreview/ConfigLoader") {
121
+ static layer = Layer.effect(ConfigLoader, Effect.gen(function* () {
122
+ const env = yield* Env;
123
+ const fs = yield* FileSystem.FileSystem;
124
+ const path = yield* Path.Path;
125
+ return ConfigLoader.of({ load: () => Effect.gen(function* () {
126
+ const configPath = yield* discoverConfig(fs, path, env.cwd);
127
+ const config = yield* Effect.tryPromise({
128
+ try: async () => {
129
+ const { createJiti } = await import("jiti");
130
+ const loaded = await createJiti(import.meta.url, { interopDefault: true }).import(configPath);
131
+ return loaded.default ?? loaded;
132
+ },
133
+ catch: (error) => new ConfigError({ message: error instanceof Error ? error.message : String(error) })
134
+ });
135
+ 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` });
136
+ return config;
137
+ }) });
138
+ }));
139
+ };
140
+ //#endregion
141
+ //#region src/shared/infrastructure/state-store.ts
142
+ /**
143
+ * Local state store for tracking reviewed flags.
144
+ *
145
+ * Manages a `.agentreview-state` file in the project root that stores
146
+ * hashes of flags that have been reviewed. This file is intended to
147
+ * be **gitignored** — it is per-developer scratch state for tracking
148
+ * progress during review sweeps.
149
+ *
150
+ * **Caveats**:
151
+ * - Hashes encode file path, line, column, and message. Editing code
152
+ * above a reviewed flag shifts its position and invalidates the hash.
153
+ * This is by design — changed context should be re-reviewed.
154
+ * - Stale hashes (from flags that no longer exist) accumulate harmlessly.
155
+ * Use `agentreview review --reset` to start fresh.
156
+ *
157
+ * @module
158
+ * @since 0.1.0
159
+ */
160
+ /**
161
+ * The filename used for local review state.
162
+ *
163
+ * @since 0.1.0
164
+ * @category constants
165
+ */
166
+ const STATE_FILENAME = ".agentreview-state";
167
+ /**
168
+ * Parse the state file into a set of hashes.
169
+ * Tolerates blank lines and `#`-prefixed comments.
170
+ *
171
+ * @since 0.1.0
172
+ * @category internals
173
+ */
174
+ function parseStateFile(content) {
175
+ let hashes = HashSet.empty();
176
+ for (const raw of content.split("\n")) {
177
+ const line = raw.trim();
178
+ if (line.length > 0 && !line.startsWith("#")) hashes = HashSet.add(hashes, line);
179
+ }
180
+ return hashes;
181
+ }
182
+ /**
183
+ * Serialize a set of hashes into file content.
184
+ *
185
+ * @since 0.1.0
186
+ * @category internals
187
+ */
188
+ function serializeHashes(hashes) {
189
+ return [...hashes].join("\n") + "\n";
190
+ }
191
+ /**
192
+ * Effect service for loading and persisting reviewed-flag state.
193
+ *
194
+ * @since 0.1.0
195
+ * @category services
196
+ */
197
+ var StateStore = class StateStore extends ServiceMap.Service()("agentreview/StateStore") {
198
+ /**
199
+ * Default layer — resolves the state file path from `Env.cwd`.
200
+ *
201
+ * @since 0.1.0
202
+ * @category layers
203
+ */
204
+ static layer = Layer.unwrap(Effect.gen(function* () {
205
+ const env = yield* Env;
206
+ const fs = yield* FileSystem.FileSystem;
207
+ const statePath = (yield* Path.Path).resolve(env.cwd, STATE_FILENAME);
208
+ return Layer.succeed(StateStore, StateStore.of({
209
+ 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()))),
210
+ 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(() => {})))),
211
+ reset: () => fs.exists(statePath).pipe(Effect.orElseSucceed(() => false), Effect.flatMap((exists) => exists ? fs.remove(statePath).pipe(Effect.orElseSucceed(() => {})) : Effect.void))
212
+ }));
213
+ }));
214
+ };
215
+ //#endregion
216
+ //#region src/domain/hash.ts
217
+ /**
218
+ * FNV-1a hashing utility.
219
+ *
220
+ * Produces a 7-character hex digest used for stable, deterministic
221
+ * flag identification. The hash encodes rule name, file path, position,
222
+ * and message so that identical matches across runs share the same id.
223
+ *
224
+ * @see https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function
225
+ *
226
+ * @module
227
+ * @since 0.1.0
228
+ */
229
+ /**
230
+ * FNV-1a 32-bit offset basis.
231
+ *
232
+ * @since 0.1.0
233
+ * @category constants
234
+ */
235
+ const FNV_OFFSET_BASIS = 2166136261;
236
+ /**
237
+ * FNV-1a 32-bit prime multiplier.
238
+ *
239
+ * @since 0.1.0
240
+ * @category constants
241
+ */
242
+ const FNV_PRIME = 16777619;
243
+ /**
244
+ * Compute a 7-character hex FNV-1a hash of `input`.
245
+ *
246
+ * The result is the first 7 hex characters of the unsigned 32-bit
247
+ * FNV-1a digest — short enough for display, long enough to avoid
248
+ * collisions in typical lint runs.
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * import { fnv1a7 } from "./utils/hash.js"
253
+ *
254
+ * fnv1a7("my-rule:src/index.ts:10:1:message") // => "a3f4b2c"
255
+ * ```
256
+ *
257
+ * @since 0.1.0
258
+ * @category constructors
259
+ */
260
+ function fnv1a7(input) {
261
+ let hash = FNV_OFFSET_BASIS;
262
+ for (let i = 0; i < input.length; i++) {
263
+ hash ^= input.charCodeAt(i);
264
+ hash = Math.imul(hash, FNV_PRIME);
265
+ }
266
+ return (hash >>> 0).toString(16).padStart(8, "0").slice(0, 7);
267
+ }
268
+ //#endregion
269
+ //#region src/domain/rule-context.ts
270
+ /**
271
+ * Rule context — the interface rules use to interact with the runner.
272
+ *
273
+ * Provides file metadata, source access, and the {@link RuleContext.flag}
274
+ * method for recording matches.
275
+ *
276
+ * @module
277
+ * @since 0.1.0
278
+ */
279
+ /**
280
+ * Internal implementation of {@link RuleContext}.
281
+ *
282
+ * Tracks the current file, accumulates flags, and provides source
283
+ * access helpers. The check command calls {@link setFile} before each
284
+ * file and {@link drainFlags} after the tree walk to collect results.
285
+ *
286
+ * @since 0.1.0
287
+ * @category internals
288
+ */
289
+ var RuleContextImpl = class {
290
+ ruleName;
291
+ flags = [];
292
+ #filename = "";
293
+ #source = "";
294
+ constructor(ruleName) {
295
+ this.ruleName = ruleName;
296
+ }
297
+ /**
298
+ * Set the current file context. Called by the check command before
299
+ * each file is walked.
300
+ */
301
+ setFile(filename, source) {
302
+ this.#filename = filename;
303
+ this.#source = source;
304
+ }
305
+ /**
306
+ * Remove and return all accumulated flags. Called after the tree
307
+ * walk for each file to collect results.
308
+ */
309
+ drainFlags() {
310
+ return this.flags.splice(0);
311
+ }
312
+ getFilename() {
313
+ return this.#filename;
314
+ }
315
+ getSourceCode() {
316
+ return this.#source;
317
+ }
318
+ getLinesAround(line, radius = 10) {
319
+ const lines = this.#source.split("\n");
320
+ const start = Math.max(0, line - 1 - radius);
321
+ const end = Math.min(lines.length, line + radius);
322
+ return lines.slice(start, end).map((l, i) => `${String(start + i + 1).padStart(4)} | ${l}`).join("\n");
323
+ }
324
+ flag(options) {
325
+ const line = options.node.startPosition.row + 1;
326
+ const col = options.node.startPosition.column + 1;
327
+ const trimmed = (this.#source.split("\n")[line - 1] ?? "").trim();
328
+ const sourceSnippet = trimmed.length > 100 ? trimmed.slice(0, 97) + "..." : trimmed;
329
+ const hash = fnv1a7(`${this.ruleName}:${this.#filename}:${line}:${col}:${options.message}`);
330
+ this.flags.push(new FlagRecord({
331
+ ruleName: this.ruleName,
332
+ filename: this.#filename,
333
+ line,
334
+ col,
335
+ message: options.message,
336
+ sourceSnippet,
337
+ hash,
338
+ instruction: options.instruction,
339
+ suggest: options.suggest
340
+ }));
341
+ }
342
+ };
343
+ //#endregion
344
+ //#region src/shared/pipeline/file-resolver.ts
345
+ /**
346
+ * File resolution service.
347
+ *
348
+ * Determines which files to lint by applying the 6-layer filter pipeline:
349
+ * 1. Candidate files (from git diff or all files)
350
+ * 2. Built-in ignores
351
+ * 3. .gitignore
352
+ * 4. .agentreviewignore
353
+ * 5. Config include/ignore
354
+ * (Steps 2-4 are handled by IgnoreReader)
355
+ *
356
+ * Per-rule filtering (languages, include, ignore) is done by the check command.
357
+ *
358
+ * @module
359
+ */
360
+ /**
361
+ * Raised when file resolution fails — e.g. a git error bubbling up
362
+ * from the changed-files query.
363
+ *
364
+ * @since 0.1.0
365
+ * @category errors
366
+ */
367
+ var FileResolverError = class extends Schema.TaggedErrorClass()("FileResolverError", { message: Schema.String }) {};
368
+ Schema.Struct({
369
+ all: Schema.Boolean,
370
+ baseRef: Schema.optional(Schema.String),
371
+ configInclude: Schema.optional(Schema.Array(Schema.String)),
372
+ configIgnore: Schema.optional(Schema.Array(Schema.String)),
373
+ positionalFiles: Schema.optional(Schema.Array(Schema.String))
374
+ });
375
+ /** Directories that are always skipped during recursive listing. */
376
+ const SKIP_DIRS = HashSet.make("node_modules", ".git", "dist");
377
+ /**
378
+ * Recursively list all files under `dir`, returning paths relative to `base`.
379
+ *
380
+ * Skips `node_modules`, `.git`, and `dist` directories. Errors (e.g.
381
+ * permission denied) are silently swallowed.
382
+ *
383
+ * Uses the Effect `FileSystem` and `Path` services for cross-platform
384
+ * file system access.
385
+ *
386
+ * @since 0.1.0
387
+ * @category internals
388
+ */
389
+ function listAllFiles(dir, base, fs, path) {
390
+ return Effect.gen(function* () {
391
+ const entries = yield* fs.readDirectory(dir);
392
+ const results = [];
393
+ for (const name of entries) {
394
+ if (HashSet.has(SKIP_DIRS, name)) continue;
395
+ const fullPath = path.resolve(dir, name);
396
+ const info = yield* fs.stat(fullPath);
397
+ const relPath = path.relative(base, fullPath).replace(/\\/g, "/");
398
+ if (info.type === "Directory") results.push(...yield* listAllFiles(fullPath, base, fs, path));
399
+ else results.push(relPath);
400
+ }
401
+ return results;
402
+ }).pipe(Effect.catch(() => Effect.succeed([])));
403
+ }
404
+ /**
405
+ * Determine the final set of files to lint.
406
+ *
407
+ * Applies the multi-layer filter pipeline described in the module header,
408
+ * then sorts the result alphabetically for deterministic output.
409
+ *
410
+ * @since 0.1.0
411
+ * @category constructors
412
+ */
413
+ function resolveFiles(options, ignoreReader, gitService) {
414
+ return Effect.gen(function* () {
415
+ const env = yield* Env;
416
+ const fs = yield* FileSystem.FileSystem;
417
+ const path = yield* Path.Path;
418
+ const { cwd } = env;
419
+ let candidates;
420
+ if (options.positionalFiles && options.positionalFiles.length > 0) candidates = [...options.positionalFiles];
421
+ else if (options.all) candidates = yield* listAllFiles(cwd, cwd, fs, path);
422
+ else candidates = [...yield* Effect.mapError(gitService.changedFiles(options.baseRef), (e) => new FileResolverError({ message: `Git error: ${e}` }))];
423
+ const includeMatcher = options.configInclude?.length ? picomatch(options.configInclude) : void 0;
424
+ const ignoreMatcher = options.configIgnore?.length ? picomatch(options.configIgnore) : void 0;
425
+ return candidates.filter((f) => !ignoreReader.isIgnored(f)).filter((f) => !includeMatcher || includeMatcher(f)).filter((f) => !ignoreMatcher || !ignoreMatcher(f)).filter((f) => path.extname(f).length > 0).toSorted();
426
+ });
427
+ }
428
+ //#endregion
429
+ //#region src/shared/infrastructure/git.ts
430
+ /**
431
+ * Git integration — default branch detection and changed file collection.
432
+ *
433
+ * @module
434
+ * @since 0.1.0
435
+ */
436
+ /**
437
+ * Raised when a git operation fails — e.g. not a git repo,
438
+ * invalid ref, or `git` binary not found.
439
+ *
440
+ * @since 0.1.0
441
+ * @category errors
442
+ */
443
+ var GitError = class extends Schema.TaggedErrorClass()("GitError", { message: Schema.String }) {};
444
+ /**
445
+ * Execute a git command and return trimmed stdout.
446
+ *
447
+ * Uses the array form of `ChildProcess.make` so that dynamic arguments
448
+ * are properly tokenized.
449
+ *
450
+ * @since 0.1.0
451
+ * @category internals
452
+ */
453
+ const gitCmd = (args, cwd) => Effect.gen(function* () {
454
+ return (yield* (yield* ChildProcessSpawner.ChildProcessSpawner).string(ChildProcess.make("git", args.split(/\s+/), { cwd }))).trim();
455
+ });
456
+ /**
457
+ * Detect the default branch by checking whether `main` or `master` exists.
458
+ * Falls back to `"main"` when neither can be verified.
459
+ *
460
+ * @since 0.1.0
461
+ * @category internals
462
+ */
463
+ 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")))));
464
+ /**
465
+ * Collect all files that differ from `baseRef`.
466
+ *
467
+ * Gathers the union of committed diffs, uncommitted changes, and
468
+ * untracked files. Each source is caught so partial failures
469
+ * (e.g. empty repo, no merge-base) are silently skipped.
470
+ *
471
+ * @since 0.1.0
472
+ * @category internals
473
+ */
474
+ const parseLines = (output) => output.split("\n").map((f) => f.trim()).filter((f) => f.length > 0);
475
+ const collectChangedFiles = (cwd, baseRef) => Effect.all([
476
+ gitCmd(`merge-base HEAD ${baseRef}`, cwd).pipe(Effect.flatMap((mergeBase) => gitCmd(`diff --name-only ${mergeBase}...HEAD`, cwd)), Effect.catch(() => Effect.succeed(""))),
477
+ gitCmd("diff --name-only HEAD", cwd).pipe(Effect.catch(() => Effect.succeed(""))),
478
+ gitCmd("ls-files --others --exclude-standard", cwd).pipe(Effect.catch(() => Effect.succeed("")))
479
+ ]).pipe(Effect.map(([committed, uncommitted, untracked]) => [...HashSet.fromIterable([
480
+ ...parseLines(committed),
481
+ ...parseLines(uncommitted),
482
+ ...parseLines(untracked)
483
+ ])].toSorted()));
484
+ /**
485
+ * @example
486
+ * ```ts
487
+ * import { Console, Effect } from "effect"
488
+ * import { Git } from "./infrastructure/git.js"
489
+ *
490
+ * const program = Effect.gen(function* () {
491
+ * const git = yield* Git
492
+ * const branch = yield* git.detectDefaultBranch()
493
+ * const changed = yield* git.changedFiles(branch)
494
+ * yield* Console.log(`${changed.length} files changed since ${branch}`)
495
+ * })
496
+ * ```
497
+ *
498
+ * @since 0.1.0
499
+ */
500
+ var Git = class Git extends ServiceMap.Service()("agentreview/Git") {
501
+ static layer = Layer.effect(Git, Effect.gen(function* () {
502
+ const env = yield* Env;
503
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
504
+ const provide = (effect) => Effect.provideService(effect, ChildProcessSpawner.ChildProcessSpawner, spawner);
505
+ return Git.of({
506
+ detectDefaultBranch: () => provide(detectDefault(env.cwd)).pipe(Effect.mapError((e) => new GitError({ message: String(e) }))),
507
+ 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) })))
508
+ });
509
+ }));
510
+ };
511
+ //#endregion
512
+ //#region src/shared/infrastructure/ignore-reader.ts
513
+ /**
514
+ * Ignore-pattern aggregation service.
515
+ *
516
+ * Merges built-in ignore patterns (e.g. `node_modules`, `dist`) with
517
+ * the project's `.gitignore` to produce a single `isIgnored` predicate.
518
+ * Patterns are compiled once via `picomatch` and reused for every file.
519
+ *
520
+ * @module
521
+ * @since 0.1.0
522
+ */
523
+ /**
524
+ * Paths that are always ignored regardless of user configuration.
525
+ *
526
+ * Covers common build output, package manager artifacts, source maps,
527
+ * lockfiles, and cache directories.
528
+ *
529
+ * @since 0.1.0
530
+ * @category constants
531
+ */
532
+ const BUILTIN_IGNORE_PATTERNS = [
533
+ "node_modules/**",
534
+ "dist/**",
535
+ "build/**",
536
+ ".git/**",
537
+ ".next/**",
538
+ ".cache/**",
539
+ "coverage/**",
540
+ "**/*.min.js",
541
+ "**/*.min.css",
542
+ "**/*.map",
543
+ "**/*.lock",
544
+ "**/*.log",
545
+ "**/*.tsbuildinfo",
546
+ ".agentreview-state"
547
+ ];
548
+ /**
549
+ * Parse a `.gitignore`-style file into an array of glob patterns.
550
+ * Strips blank lines and comments (lines starting with `#`).
551
+ *
552
+ * @since 0.1.0
553
+ * @category internals
554
+ */
555
+ function parseIgnoreFile(content) {
556
+ return content.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
557
+ }
558
+ /**
559
+ * Effect service that tests whether a file path should be ignored.
560
+ *
561
+ * Combines built-in patterns with `.gitignore` into a single
562
+ * `picomatch` matcher. Constructed once per run.
563
+ *
564
+ * @since 0.1.0
565
+ * @category services
566
+ */
567
+ var IgnoreReader = class IgnoreReader extends ServiceMap.Service()("agentreview/IgnoreReader") {
568
+ static layer = Layer.unwrap(Effect.gen(function* () {
569
+ const env = yield* Env;
570
+ const fs = yield* FileSystem.FileSystem;
571
+ const path = yield* Path.Path;
572
+ const { cwd } = env;
573
+ const gitignorePath = path.resolve(cwd, ".gitignore");
574
+ const gitignorePatterns = (yield* fs.exists(gitignorePath).pipe(Effect.orElseSucceed(() => false))) ? parseIgnoreFile(yield* fs.readFileString(gitignorePath).pipe(Effect.orElseSucceed(() => ""))) : [];
575
+ const matcher = picomatch([...BUILTIN_IGNORE_PATTERNS, ...gitignorePatterns], { dot: true });
576
+ return Layer.succeed(IgnoreReader, IgnoreReader.of({ isIgnored: (filepath) => matcher(filepath) }));
577
+ }));
578
+ };
579
+ //#endregion
580
+ //#region src/shared/infrastructure/parser.ts
581
+ /**
582
+ * Tree-sitter WASM parser.
583
+ *
584
+ * WASM init is lazy — the first `parse` call triggers initialization.
585
+ * Grammars are cached after first load.
586
+ *
587
+ * @module
588
+ * @since 0.1.0
589
+ */
590
+ /**
591
+ * Raised when parsing fails — e.g. missing grammar, corrupt WASM, or
592
+ * tree-sitter returning a null tree.
593
+ *
594
+ * @since 0.1.0
595
+ * @category errors
596
+ */
597
+ var ParserError = class extends Schema.TaggedErrorClass()("ParserError", { message: Schema.String }) {};
598
+ /**
599
+ * Maps grammar names to their corresponding `.wasm` filenames.
600
+ *
601
+ * @since 0.1.0
602
+ * @category constants
603
+ */
604
+ const GRAMMAR_FILES = HashMap.make(["typescript", "tree-sitter-typescript.wasm"], ["tsx", "tree-sitter-tsx.wasm"], ["javascript", "tree-sitter-javascript.wasm"]);
605
+ /**
606
+ * @example
607
+ * ```ts
608
+ * import { Console, Effect } from "effect"
609
+ * import { Parser } from "./infrastructure/parser.js"
610
+ *
611
+ * const program = Effect.gen(function* () {
612
+ * const parser = yield* Parser
613
+ * const tree = yield* parser.parse("const x = 1", "typescript")
614
+ * yield* Console.log(tree.rootNode.type) // "program"
615
+ * })
616
+ * ```
617
+ *
618
+ * @since 0.1.0
619
+ * @category services
620
+ */
621
+ var Parser$1 = class Parser$1 extends ServiceMap.Service()("agentreview/Parser") {
622
+ /** Default layer — lazily initializes WASM and caches grammars. */
623
+ static layer = Layer.effect(Parser$1, Effect.gen(function* () {
624
+ const env = yield* Env;
625
+ const fs = yield* FileSystem.FileSystem;
626
+ const path = yield* Path.Path;
627
+ const resolveWasmPath = (filename) => Effect.gen(function* () {
628
+ const thisDir = path.dirname(path.resolve(import.meta.dirname ?? ".", ""));
629
+ const distPath = path.resolve(thisDir, "wasm", filename);
630
+ if (yield* fs.exists(distPath).pipe(Effect.orElseSucceed(() => false))) return distPath;
631
+ const nmBase = path.resolve(env.cwd, "node_modules");
632
+ if (filename === "tree-sitter.wasm") {
633
+ const p = path.resolve(nmBase, "web-tree-sitter", filename);
634
+ if (yield* fs.exists(p).pipe(Effect.orElseSucceed(() => false))) return p;
635
+ } else {
636
+ const p = path.resolve(nmBase, "tree-sitter-wasms", "out", filename);
637
+ if (yield* fs.exists(p).pipe(Effect.orElseSucceed(() => false))) return p;
638
+ }
639
+ return yield* new ParserError({ message: `WASM file not found: ${filename}` });
640
+ });
641
+ let parserInstance;
642
+ let languageCache = HashMap.empty();
643
+ return Parser$1.of({ parse: (source, grammar) => Effect.gen(function* () {
644
+ if (!parserInstance) {
645
+ const initPath = yield* resolveWasmPath("tree-sitter.wasm");
646
+ yield* Effect.tryPromise({
647
+ try: async () => {
648
+ await Parser.init({ locateFile: () => initPath });
649
+ parserInstance = new Parser();
650
+ },
651
+ catch: (error) => new ParserError({ message: error instanceof Error ? error.message : String(error) })
652
+ });
653
+ }
654
+ let lang = Option.getOrUndefined(HashMap.get(languageCache, grammar));
655
+ if (!lang) {
656
+ const file = Option.getOrUndefined(HashMap.get(GRAMMAR_FILES, grammar));
657
+ if (!file) return yield* new ParserError({ message: `Unknown grammar: ${grammar}` });
658
+ const wasmPath = yield* resolveWasmPath(file);
659
+ lang = yield* Effect.tryPromise({
660
+ try: () => Language.load(wasmPath),
661
+ catch: (error) => new ParserError({ message: error instanceof Error ? error.message : String(error) })
662
+ });
663
+ languageCache = HashMap.set(languageCache, grammar, lang);
664
+ }
665
+ parserInstance.setLanguage(lang);
666
+ const tree = parserInstance.parse(source);
667
+ if (!tree) return yield* new ParserError({ message: "Parser returned null tree" });
668
+ return tree;
669
+ }) });
670
+ }));
671
+ };
672
+ //#endregion
673
+ //#region src/shared/pipeline/tree-walker.ts
674
+ /**
675
+ * Single-pass multi-rule tree walker.
676
+ *
677
+ * Builds a dispatch table from all active rules' visitor methods,
678
+ * walks the tree once using tree-sitter's cursor API, and calls
679
+ * all matching handlers per node.
680
+ *
681
+ * @module
682
+ */
683
+ /**
684
+ * Regex matching `agentreview-ignore` comments.
685
+ *
686
+ * Captures an optional rule name after the directive:
687
+ * - `// agentreview-ignore` → suppresses all rules on the next line
688
+ * - `// agentreview-ignore my-rule` → suppresses only `my-rule`
689
+ *
690
+ * @since 0.1.0
691
+ * @category constants
692
+ */
693
+ const IGNORE_PATTERN = /agentreview-ignore(?:\s+(\S+))?/;
694
+ /**
695
+ * Walk files with the given rules, collecting all flags.
696
+ *
697
+ * Call this once per file. The caller is responsible for:
698
+ * - Calling `context.setFile()` before this function
699
+ * - Calling `before()` and filtering out skipped rules
700
+ * - Calling `after()` after all files are processed
701
+ *
702
+ * @internal
703
+ */
704
+ function walkFile(tree, rules) {
705
+ const dispatchTable = HashMap.mutate(HashMap.empty(), (m) => {
706
+ for (const entry of rules) for (const key of Object.keys(entry.visitors)) {
707
+ if (key === "before" || key === "after") continue;
708
+ const handler = entry.visitors[key];
709
+ if (typeof handler !== "function") continue;
710
+ const existing = Option.getOrUndefined(HashMap.get(m, key));
711
+ if (existing) existing.push({
712
+ ruleName: entry.ruleName,
713
+ handler
714
+ });
715
+ else HashMap.set(m, key, [{
716
+ ruleName: entry.ruleName,
717
+ handler
718
+ }]);
719
+ }
720
+ });
721
+ const suppressions = [];
722
+ const cursor = tree.walk();
723
+ let reachedEnd = false;
724
+ while (!reachedEnd) {
725
+ const nodeType = cursor.nodeType;
726
+ if (nodeType === "comment") {
727
+ const node = cursor.currentNode;
728
+ const match = IGNORE_PATTERN.exec(node.text);
729
+ if (match) {
730
+ const ruleName = match[1]?.split("--")[0]?.trim() ?? "*";
731
+ suppressions.push({
732
+ ruleName,
733
+ line: node.startPosition.row + 1
734
+ });
735
+ }
736
+ }
737
+ const handlers = Option.getOrUndefined(HashMap.get(dispatchTable, nodeType));
738
+ if (handlers) {
739
+ const wrapped = wrapNode(cursor.currentNode);
740
+ for (const { handler } of handlers) handler(wrapped);
741
+ }
742
+ if (cursor.gotoFirstChild()) continue;
743
+ while (!cursor.gotoNextSibling()) if (!cursor.gotoParent()) {
744
+ reachedEnd = true;
745
+ break;
746
+ }
747
+ }
748
+ const allFlags = [];
749
+ for (const entry of rules) allFlags.push(...entry.context.drainFlags());
750
+ if (suppressions.length === 0) return allFlags;
751
+ return allFlags.filter((flag) => {
752
+ const flagLine0 = flag.line - 1;
753
+ return !suppressions.some((s) => (s.ruleName === "*" || s.ruleName === flag.ruleName) && s.line === flagLine0);
754
+ });
755
+ }
756
+ //#endregion
757
+ //#region src/shared/pipeline/language-map.ts
758
+ /**
759
+ * File extension → tree-sitter grammar mapping.
760
+ *
761
+ * Maps every supported file extension to the grammar name used by
762
+ * the parser service. This is the single source of truth for which
763
+ * file types agentreview can analyze.
764
+ *
765
+ * Uses Effect `HashMap` for an immutable, structurally-equal lookup table.
766
+ *
767
+ * @module
768
+ * @since 0.1.0
769
+ */
770
+ /**
771
+ * Maps file extensions (without leading dot) to their tree-sitter
772
+ * grammar name.
773
+ *
774
+ * @since 0.1.0
775
+ * @category constants
776
+ */
777
+ const EXTENSION_TO_GRAMMAR = HashMap.make(["ts", "typescript"], ["tsx", "tsx"], ["js", "javascript"], ["jsx", "javascript"], ["mts", "typescript"], ["cts", "typescript"], ["mjs", "javascript"], ["cjs", "javascript"]);
778
+ /**
779
+ * Look up the tree-sitter grammar name for a file extension.
780
+ *
781
+ * Returns `undefined` for unsupported extensions — callers should
782
+ * skip those files.
783
+ *
784
+ * @since 0.1.0
785
+ * @category constructors
786
+ */
787
+ function grammarForExtension(ext) {
788
+ return Option.getOrUndefined(HashMap.get(EXTENSION_TO_GRAMMAR, ext));
789
+ }
790
+ Schema.Struct({
791
+ flags: Schema.Array(FlagRecord),
792
+ noMatchingRules: Schema.Boolean
793
+ });
794
+ Schema.Struct({
795
+ all: Schema.Boolean,
796
+ rules: Schema.Array(Schema.String),
797
+ dryRun: Schema.Boolean,
798
+ base: Schema.UndefinedOr(Schema.String),
799
+ files: Schema.Array(Schema.String)
800
+ });
801
+ /** @since 0.1.0 */
802
+ const collectFlags = Effect.fn("collectFlags")(function* (options) {
803
+ const configLoader = yield* ConfigLoader;
804
+ const env = yield* Env;
805
+ const fs = yield* FileSystem.FileSystem;
806
+ const path = yield* Path.Path;
807
+ const ignoreReader = yield* IgnoreReader;
808
+ const gitService = yield* Git;
809
+ const parserService = yield* Parser$1;
810
+ const config = yield* configLoader.load();
811
+ let activeRules = Object.entries(config.rules);
812
+ if (options.rules.length > 0) {
813
+ activeRules = activeRules.filter(([name]) => options.rules.includes(name));
814
+ if (activeRules.length === 0) return {
815
+ flags: [],
816
+ noMatchingRules: true
817
+ };
818
+ }
819
+ const includePatterns = config.include ? [...config.include] : void 0;
820
+ const ignorePatterns = config.ignore ? [...config.ignore] : void 0;
821
+ const files = yield* resolveFiles({
822
+ all: options.all,
823
+ baseRef: options.base,
824
+ configInclude: includePatterns,
825
+ configIgnore: ignorePatterns,
826
+ positionalFiles: options.files.length > 0 ? [...options.files] : void 0
827
+ }, ignoreReader, gitService);
828
+ if (files.length === 0) return {
829
+ flags: [],
830
+ noMatchingRules: false
831
+ };
832
+ const ruleEntries = [];
833
+ for (const [name, rule] of activeRules) {
834
+ const context = new RuleContextImpl(name);
835
+ const visitors = rule.createOnce(context);
836
+ ruleEntries.push({
837
+ name,
838
+ rule,
839
+ context,
840
+ visitors
841
+ });
842
+ }
843
+ const allFlags = [];
844
+ for (const file of files) {
845
+ const ext = path.extname(file).slice(1);
846
+ const absPath = path.resolve(env.cwd, file);
847
+ const applicableRules = ruleEntries.filter((entry) => {
848
+ if (!entry.rule.meta.languages.includes(ext)) return false;
849
+ if (entry.rule.meta.include && entry.rule.meta.include.length > 0) {
850
+ if (!picomatch([...entry.rule.meta.include])(file)) return false;
851
+ }
852
+ if (entry.rule.meta.ignore && entry.rule.meta.ignore.length > 0) {
853
+ if (picomatch([...entry.rule.meta.ignore])(file)) return false;
854
+ }
855
+ return true;
856
+ });
857
+ if (applicableRules.length === 0) continue;
858
+ const sourceResult = yield* fs.readFileString(absPath).pipe(Effect.result);
859
+ if (sourceResult._tag === "Failure") continue;
860
+ const source = sourceResult.success;
861
+ const grammar = grammarForExtension(ext);
862
+ if (!grammar) continue;
863
+ const tree = yield* parserService.parse(source, grammar);
864
+ const rulesForFile = [];
865
+ for (const entry of applicableRules) {
866
+ entry.context.setFile(absPath, source);
867
+ if (entry.visitors.before?.(absPath) === false) continue;
868
+ rulesForFile.push({
869
+ ruleName: entry.name,
870
+ context: entry.context,
871
+ visitors: entry.visitors
872
+ });
873
+ }
874
+ if (rulesForFile.length === 0) continue;
875
+ const fileFlags = walkFile(tree, rulesForFile);
876
+ allFlags.push(...fileFlags);
877
+ }
878
+ for (const entry of ruleEntries) entry.visitors.after?.();
879
+ return {
880
+ flags: allFlags,
881
+ noMatchingRules: false
882
+ };
883
+ });
884
+ //#endregion
885
+ //#region src/features/check/request.ts
886
+ /**
887
+ * @module
888
+ * @since 0.1.0
889
+ */
890
+ /**
891
+ * @since 0.1.0
892
+ * @category models
893
+ */
894
+ var CheckCommand = class extends Schema.TaggedClass()("CheckCommand", {
895
+ all: Schema.Boolean,
896
+ rules: Schema.Array(Schema.String),
897
+ dryRun: Schema.Boolean,
898
+ base: Schema.UndefinedOr(Schema.String),
899
+ files: Schema.Array(Schema.String)
900
+ }) {};
901
+ /**
902
+ * @since 0.1.0
903
+ * @category models
904
+ */
905
+ var CheckResult = class extends Schema.TaggedClass()("CheckResult", {
906
+ flags: Schema.Array(FlagRecord),
907
+ totalFlags: Schema.Number,
908
+ filteredCount: Schema.Number,
909
+ noMatchingRules: Schema.Boolean,
910
+ availableRules: Schema.Array(Schema.String)
911
+ }) {};
912
+ //#endregion
913
+ //#region src/features/check/handler.ts
914
+ /**
915
+ * @module
916
+ * @since 0.1.0
917
+ */
918
+ /** @since 0.1.0 */
919
+ const checkHandler = Effect.fn("checkHandler")(function* (command) {
920
+ const configLoader = yield* ConfigLoader;
921
+ const stateStore = yield* StateStore;
922
+ const config = yield* configLoader.load();
923
+ const availableRules = Object.keys(config.rules);
924
+ const result = yield* collectFlags({
925
+ all: command.all,
926
+ rules: command.rules,
927
+ dryRun: command.dryRun,
928
+ base: command.base,
929
+ files: command.files
930
+ });
931
+ if (result.noMatchingRules) return new CheckResult({
932
+ flags: [],
933
+ totalFlags: 0,
934
+ filteredCount: 0,
935
+ noMatchingRules: true,
936
+ availableRules
937
+ });
938
+ const allFlags = result.flags;
939
+ if (allFlags.length === 0) return new CheckResult({
940
+ flags: [],
941
+ totalFlags: 0,
942
+ filteredCount: 0,
943
+ noMatchingRules: false,
944
+ availableRules
945
+ });
946
+ const reviewed = yield* stateStore.load();
947
+ const reviewedSize = HashSet.size(reviewed);
948
+ const filteredCount = reviewedSize > 0 ? allFlags.filter((f) => HashSet.has(reviewed, f.hash)).length : 0;
949
+ return new CheckResult({
950
+ flags: reviewedSize > 0 ? allFlags.filter((f) => !HashSet.has(reviewed, f.hash)) : allFlags,
951
+ totalFlags: allFlags.length,
952
+ filteredCount,
953
+ noMatchingRules: false,
954
+ availableRules
955
+ });
956
+ });
957
+ //#endregion
958
+ //#region src/features/init/request.ts
959
+ /**
960
+ * @module
961
+ * @since 0.1.0
962
+ */
963
+ /**
964
+ * @since 0.1.0
965
+ * @category models
966
+ */
967
+ var InitCommand = class extends Schema.TaggedClass()("InitCommand", {}) {};
968
+ /**
969
+ * @since 0.1.0
970
+ * @category models
971
+ */
972
+ var InitResult = class extends Schema.TaggedClass()("InitResult", {
973
+ created: Schema.Boolean,
974
+ message: Schema.String
975
+ }) {};
976
+ //#endregion
977
+ //#region src/features/init/handler.ts
978
+ /**
979
+ * @module
980
+ * @since 0.1.0
981
+ */
982
+ /**
983
+ * Minimal starter config written by `agentreview init`.
984
+ *
985
+ * @since 0.1.0
986
+ * @category constants
987
+ */
988
+ const STARTER_CONFIG = `import { defineConfig } from "agentreview"
989
+
990
+ export default defineConfig({
991
+ include: ["src/**/*.{ts,tsx}"],
992
+ rules: {},
993
+ })
994
+ `;
995
+ const SKILLS_ADD_CMD = "npx skills@latest add aurelienbobenrieth/agentreview";
996
+ const INTENT_INSTALL_CMD = "npx @tanstack/intent install";
997
+ /**
998
+ * Detect which skill installation method is most likely appropriate.
999
+ *
1000
+ * Checks for TanStack Intent or existing AGENTS.md with intent block.
1001
+ */
1002
+ const detectSkillMethod = Effect.fn("detectSkillMethod")(function* (cwd) {
1003
+ const fs = yield* FileSystem.FileSystem;
1004
+ const path = yield* Path.Path;
1005
+ if (yield* fs.exists(path.resolve(cwd, "node_modules/@tanstack/intent")).pipe(Effect.orElseSucceed(() => false))) return "intent";
1006
+ const agentsPath = path.resolve(cwd, "AGENTS.md");
1007
+ if (yield* fs.exists(agentsPath)) {
1008
+ if ((yield* fs.readFileString(agentsPath)).includes("intent-skills:start")) return "intent";
1009
+ }
1010
+ return "skills";
1011
+ });
1012
+ /** @since 0.1.0 */
1013
+ const initHandler = Effect.fn("initHandler")(function* (_command) {
1014
+ const env = yield* Env;
1015
+ const fs = yield* FileSystem.FileSystem;
1016
+ const path = yield* Path.Path;
1017
+ const configPath = path.resolve(env.cwd, "agentreview.config.ts");
1018
+ const gitignorePath = path.resolve(env.cwd, ".gitignore");
1019
+ const configCreated = !(yield* fs.exists(configPath));
1020
+ if (configCreated) yield* fs.writeFileString(configPath, STARTER_CONFIG);
1021
+ let gitignoreUpdated = false;
1022
+ if (yield* fs.exists(gitignorePath)) {
1023
+ const content = yield* fs.readFileString(gitignorePath);
1024
+ if (!content.includes(".agentreview-state")) {
1025
+ const separator = content.endsWith("\n") ? "" : "\n";
1026
+ yield* fs.writeFileString(gitignorePath, content + separator + "\n# agentreview local state\n.agentreview-state\n");
1027
+ gitignoreUpdated = true;
1028
+ }
1029
+ } else {
1030
+ yield* fs.writeFileString(gitignorePath, "# agentreview local state\n.agentreview-state\n");
1031
+ gitignoreUpdated = true;
1032
+ }
1033
+ const lines = [];
1034
+ if (configCreated) lines.push("✓ Created agentreview.config.ts");
1035
+ else lines.push("· agentreview.config.ts already exists — skipped");
1036
+ if (gitignoreUpdated) lines.push("✓ Added .agentreview-state to .gitignore");
1037
+ const skillCmd = (yield* detectSkillMethod(env.cwd)) === "intent" ? INTENT_INSTALL_CMD : SKILLS_ADD_CMD;
1038
+ lines.push("", "Next steps:", " 1. Add rules to your config", ` 2. Install the agentreview skill for your AI agents:`, ` ${skillCmd}`, " 3. Run: npx agentreview check --all");
1039
+ return new InitResult({
1040
+ created: configCreated,
1041
+ message: lines.join("\n")
1042
+ });
1043
+ });
1044
+ //#endregion
1045
+ //#region src/features/list/request.ts
1046
+ /**
1047
+ * @module
1048
+ * @since 0.1.0
1049
+ */
1050
+ /**
1051
+ * @since 0.1.0
1052
+ * @category models
1053
+ */
1054
+ const RuleSummary = Schema.Struct({
1055
+ name: Schema.String,
1056
+ description: Schema.String,
1057
+ languages: Schema.Array(Schema.String),
1058
+ include: Schema.UndefinedOr(Schema.Array(Schema.String)),
1059
+ ignore: Schema.UndefinedOr(Schema.Array(Schema.String))
1060
+ });
1061
+ /**
1062
+ * @since 0.1.0
1063
+ * @category models
1064
+ */
1065
+ var ListCommand = class extends Schema.TaggedClass()("ListCommand", {}) {};
1066
+ /**
1067
+ * @since 0.1.0
1068
+ * @category models
1069
+ */
1070
+ var ListResult = class extends Schema.TaggedClass()("ListResult", { rules: Schema.Array(RuleSummary) }) {};
1071
+ //#endregion
1072
+ //#region src/features/list/handler.ts
1073
+ /**
1074
+ * @module
1075
+ * @since 0.1.0
1076
+ */
1077
+ /** @since 0.1.0 */
1078
+ const listHandler = Effect.fn("listHandler")(function* (_command) {
1079
+ const config = yield* (yield* ConfigLoader).load();
1080
+ return new ListResult({ rules: Object.entries(config.rules).map(([name, rule]) => ({
1081
+ name,
1082
+ description: rule.meta.description,
1083
+ languages: rule.meta.languages,
1084
+ include: rule.meta.include,
1085
+ ignore: rule.meta.ignore
1086
+ })) });
1087
+ });
1088
+ //#endregion
1089
+ //#region src/features/review/request.ts
1090
+ /**
1091
+ * @module
1092
+ * @since 0.1.0
1093
+ */
1094
+ /**
1095
+ * @since 0.1.0
1096
+ * @category models
1097
+ */
1098
+ var ReviewCommand = class extends Schema.TaggedClass()("ReviewCommand", {
1099
+ hashes: Schema.Array(Schema.String),
1100
+ all: Schema.Boolean,
1101
+ reset: Schema.Boolean
1102
+ }) {};
1103
+ /**
1104
+ * @since 0.1.0
1105
+ * @category models
1106
+ */
1107
+ var ReviewResult = class extends Schema.TaggedClass()("ReviewResult", { message: Schema.String }) {};
1108
+ //#endregion
1109
+ //#region src/features/review/handler.ts
1110
+ /**
1111
+ * @module
1112
+ * @since 0.1.0
1113
+ */
1114
+ /** @since 0.1.0 */
1115
+ const reviewHandler = Effect.fn("reviewHandler")(function* (command) {
1116
+ const stateStore = yield* StateStore;
1117
+ if (command.reset) {
1118
+ yield* stateStore.reset();
1119
+ return new ReviewResult({ message: "Cleared .agentreview-state" });
1120
+ }
1121
+ if (command.all) {
1122
+ const allFlags = (yield* collectFlags({
1123
+ all: true,
1124
+ rules: [],
1125
+ dryRun: false,
1126
+ base: void 0,
1127
+ files: []
1128
+ })).flags;
1129
+ if (allFlags.length === 0) return new ReviewResult({ message: "No flags to review." });
1130
+ yield* stateStore.append(allFlags.map((f) => f.hash));
1131
+ return new ReviewResult({ message: `Marked ${allFlags.length} flag(s) as reviewed.` });
1132
+ }
1133
+ if (command.hashes.length > 0) {
1134
+ yield* stateStore.append([...command.hashes]);
1135
+ return new ReviewResult({ message: `Marked ${command.hashes.length} hash(es) as reviewed.` });
1136
+ }
1137
+ return new ReviewResult({ message: [
1138
+ "Usage:",
1139
+ " agentreview review <hash...> Mark specific flags as reviewed",
1140
+ " agentreview review --all Mark all current flags as reviewed",
1141
+ " agentreview review --reset Wipe the state file"
1142
+ ].join("\n") });
1143
+ });
1144
+ //#endregion
1145
+ //#region src/cli/reporter.ts
1146
+ /**
1147
+ * Terminal reporter — formats flag results into human-readable output.
1148
+ *
1149
+ * Respects `NO_COLOR` and non-TTY environments. Groups flags by rule,
1150
+ * then by file, and appends instruction/hint blocks when available.
1151
+ *
1152
+ * @module
1153
+ * @since 0.1.0
1154
+ */
1155
+ /**
1156
+ * Group an array by a key function, returning a HashMap of arrays.
1157
+ *
1158
+ * @since 0.1.0
1159
+ * @category internals
1160
+ */
1161
+ function groupBy(items, key) {
1162
+ return items.reduce((acc, item) => {
1163
+ const k = key(item);
1164
+ const existing = Option.getOrUndefined(HashMap.get(acc, k));
1165
+ return existing ? (existing.push(item), acc) : HashMap.set(acc, k, [item]);
1166
+ }, HashMap.empty());
1167
+ }
1168
+ /**
1169
+ * Build the minimal ANSI escape helpers. Each function is a no-op when
1170
+ * `noColor` is `true`.
1171
+ *
1172
+ * @since 0.1.0
1173
+ * @category internals
1174
+ */
1175
+ function makeAnsi(noColor) {
1176
+ return {
1177
+ bold: (s) => noColor ? s : `\x1b[1m${s}\x1b[22m`,
1178
+ dim: (s) => noColor ? s : `\x1b[2m${s}\x1b[22m`,
1179
+ yellow: (s) => noColor ? s : `\x1b[33m${s}\x1b[39m`,
1180
+ cyan: (s) => noColor ? s : `\x1b[36m${s}\x1b[39m`,
1181
+ magenta: (s) => noColor ? s : `\x1b[35m${s}\x1b[39m`,
1182
+ gray: (s) => noColor ? s : `\x1b[90m${s}\x1b[39m`,
1183
+ underline: (s) => noColor ? s : `\x1b[4m${s}\x1b[24m`,
1184
+ reset: noColor ? "" : "\x1B[0m"
1185
+ };
1186
+ }
1187
+ Schema.Struct({
1188
+ dryRun: Schema.Boolean,
1189
+ version: Schema.String
1190
+ });
1191
+ /**
1192
+ * Format flag results into a terminal-friendly report string.
1193
+ *
1194
+ * Groups flags by rule name, then by file path. Includes source
1195
+ * snippets, per-match instructions/hints, and a summary line.
1196
+ * Returns a single "no rules triggered" line when the flag list
1197
+ * is empty.
1198
+ *
1199
+ * Uses the `Env` service for colour/cwd detection and the Effect
1200
+ * `Path` service for cross-platform path resolution.
1201
+ *
1202
+ * @since 0.1.0
1203
+ * @category constructors
1204
+ */
1205
+ const formatReport = Effect.fn("formatReport")(function* (flags, rulesMeta, options) {
1206
+ const env = yield* Env;
1207
+ const path = yield* Path.Path;
1208
+ const ansi = makeAnsi(env.noColor);
1209
+ if (flags.length === 0) return `${ansi.bold("agentreview")} ${ansi.dim(`v${options.version}`)} ${ansi.dim("-")} no rules triggered.`;
1210
+ const { cwd } = env;
1211
+ const lines = [];
1212
+ const grouped = groupBy(flags, (f) => f.ruleName);
1213
+ const groupedSize = HashMap.size(grouped);
1214
+ if (flags.length > 50) {
1215
+ lines.push(ansi.yellow("⚠") + ` ${flags.length} matches across ${groupedSize} rules. ` + ansi.dim("Consider narrowing scope with --rule or targeting specific files."));
1216
+ lines.push("");
1217
+ }
1218
+ for (const [ruleName, ruleFlags] of grouped) {
1219
+ const meta = Option.getOrUndefined(HashMap.get(rulesMeta, ruleName));
1220
+ lines.push(ansi.yellow(` x ${ruleName}`) + ansi.dim(meta ? `: ${meta.description}` : ""));
1221
+ lines.push("");
1222
+ const byFile = groupBy(ruleFlags, (f) => path.relative(cwd, f.filename).replace(/\\/g, "/"));
1223
+ for (const [_filePath, fileFlags] of byFile) for (const flag of fileFlags) {
1224
+ const loc = `${path.relative(cwd, flag.filename).replace(/\\/g, "/")}:${flag.line}:${flag.col}`;
1225
+ const snippet = flag.sourceSnippet.length > 80 ? flag.sourceSnippet.slice(0, 77) + "..." : flag.sourceSnippet;
1226
+ lines.push(` ${ansi.cyan(loc)} ${ansi.dim(`[${flag.hash}]`)} ${flag.message}`);
1227
+ if (snippet && snippet !== flag.message) lines.push(` ${ansi.dim(snippet)}`);
1228
+ lines.push("");
1229
+ }
1230
+ if (!options.dryRun && meta?.instruction) {
1231
+ lines.push(ansi.dim(" ┌─ Instruction ─────────────────────────────────"));
1232
+ for (const instrLine of meta.instruction.split("\n")) lines.push(ansi.dim(` │ ${instrLine}`));
1233
+ lines.push(ansi.dim(" └───────────────────────────────────────────────"));
1234
+ lines.push("");
1235
+ }
1236
+ const matchNotes = ruleFlags.filter((f) => f.instruction || f.suggest);
1237
+ if (!options.dryRun && matchNotes.length > 0) {
1238
+ for (const flag of matchNotes) {
1239
+ const relPath = path.relative(cwd, flag.filename).replace(/\\/g, "/");
1240
+ if (flag.instruction) lines.push(` ${ansi.magenta("note")} ${ansi.dim(`${relPath}:${flag.line}`)} ${flag.instruction}`);
1241
+ if (flag.suggest) lines.push(` ${ansi.magenta("hint")} ${ansi.dim(`${relPath}:${flag.line}`)} ${flag.suggest}`);
1242
+ }
1243
+ lines.push("");
1244
+ }
1245
+ }
1246
+ const ruleWord = groupedSize === 1 ? "rule" : "rules";
1247
+ const matchWord = flags.length === 1 ? "match" : "matches";
1248
+ lines.push(ansi.bold(ansi.yellow(`Found ${flags.length} ${matchWord}`)) + ansi.dim(` (${groupedSize} ${ruleWord})`));
1249
+ return lines.join("\n");
1250
+ });
1251
+ //#endregion
1252
+ //#region src/bin.ts
1253
+ /**
1254
+ * CLI entry point for `agentreview`.
1255
+ *
1256
+ * Thin adapter that translates CLI arguments into feature commands,
1257
+ * dispatches to the appropriate handler, and formats the result
1258
+ * for terminal output.
1259
+ *
1260
+ * @module
1261
+ * @since 0.1.0
1262
+ */
1263
+ /** The `check` subcommand — scans files and outputs a report. */
1264
+ const check = Command.make("check", {
1265
+ files: Argument.string("files").pipe(Argument.withDescription("Specific files or globs to scan"), Argument.variadic()),
1266
+ all: Flag.boolean("all").pipe(Flag.withAlias("a"), Flag.withDescription("Scan all files (not just git diff)")),
1267
+ rule: Flag.string("rule").pipe(Flag.withAlias("r"), Flag.withDescription("Run only this rule (comma-separated for multiple)"), Flag.optional),
1268
+ dryRun: Flag.boolean("dry-run").pipe(Flag.withAlias("d"), Flag.withDescription("Show counts only, no instruction blocks")),
1269
+ base: Flag.string("base").pipe(Flag.withDescription("Git ref to diff against"), Flag.optional)
1270
+ }, (config) => {
1271
+ const ruleFilter = Option.match(config.rule, {
1272
+ onNone: () => [],
1273
+ onSome: (r) => r.split(",").map((s) => s.trim())
1274
+ });
1275
+ const baseRef = Option.match(config.base, {
1276
+ onNone: () => void 0,
1277
+ onSome: (b) => b
1278
+ });
1279
+ return Effect.gen(function* () {
1280
+ const env = yield* Env;
1281
+ const result = yield* checkHandler(new CheckCommand({
1282
+ all: config.all,
1283
+ rules: ruleFilter,
1284
+ dryRun: config.dryRun,
1285
+ base: baseRef,
1286
+ files: config.files
1287
+ }));
1288
+ if (result.noMatchingRules) {
1289
+ yield* Console.log(`No matching rules found. Available: ${result.availableRules.join(", ")}`);
1290
+ return;
1291
+ }
1292
+ if (result.totalFlags === 0) {
1293
+ yield* Console.log(`agentreview v0.1.0 - no rules triggered.`);
1294
+ return;
1295
+ }
1296
+ const cfg = yield* (yield* ConfigLoader).load();
1297
+ const rulesMeta = HashMap.fromIterable(Object.entries(cfg.rules).map(([name, rule]) => [name, rule.meta]));
1298
+ const output = yield* formatReport(result.flags, rulesMeta, {
1299
+ dryRun: config.dryRun,
1300
+ version: "0.1.0"
1301
+ });
1302
+ yield* Console.log(output);
1303
+ if (result.filteredCount > 0) yield* Console.log(` (${result.filteredCount} reviewed flag(s) hidden — run agentreview review --reset to clear)`);
1304
+ if (result.flags.length > 0) env.setExitCode(1);
1305
+ });
1306
+ }).pipe(Command.withDescription("Scan files and output report for AI agents"));
1307
+ /** The `list` subcommand — prints all registered rules. */
1308
+ const list = Command.make("list", {}, () => Effect.gen(function* () {
1309
+ const result = yield* listHandler(new ListCommand({}));
1310
+ if (result.rules.length === 0) {
1311
+ yield* Console.log("No rules registered.");
1312
+ return;
1313
+ }
1314
+ yield* Console.log(`${result.rules.length} rule(s) registered:\n`);
1315
+ for (const rule of result.rules) {
1316
+ const langs = rule.languages.join(", ");
1317
+ yield* Console.log(` ${rule.name}`);
1318
+ yield* Console.log(` ${rule.description}`);
1319
+ yield* Console.log(` Languages: ${langs}`);
1320
+ if (rule.include) yield* Console.log(` Include: ${rule.include.join(", ")}`);
1321
+ if (rule.ignore) yield* Console.log(` Ignore: ${rule.ignore.join(", ")}`);
1322
+ yield* Console.log();
1323
+ }
1324
+ })).pipe(Command.withDescription("List all registered rules"));
1325
+ /** The `init` subcommand — scaffolds a starter config file. */
1326
+ const init = Command.make("init", {}, () => Effect.gen(function* () {
1327
+ const result = yield* initHandler(new InitCommand({}));
1328
+ yield* Console.log(result.message);
1329
+ })).pipe(Command.withDescription("Create agentreview.config.ts and set up agent skill discovery"));
1330
+ /** The `review` subcommand — manage reviewed-flag state. */
1331
+ const review = Command.make("review", {
1332
+ hashes: Argument.string("hashes").pipe(Argument.withDescription("Flag hashes to mark as reviewed"), Argument.variadic()),
1333
+ all: Flag.boolean("all").pipe(Flag.withAlias("a"), Flag.withDescription("Mark all current flags as reviewed")),
1334
+ reset: Flag.boolean("reset").pipe(Flag.withDescription("Wipe the state file"))
1335
+ }, (config) => Effect.gen(function* () {
1336
+ const result = yield* reviewHandler(new ReviewCommand({
1337
+ hashes: config.hashes,
1338
+ all: config.all,
1339
+ reset: config.reset
1340
+ }));
1341
+ yield* Console.log(result.message);
1342
+ })).pipe(Command.withDescription("Mark flags as reviewed (filters them from check output)"));
1343
+ const agentreview = Command.make("agentreview").pipe(Command.withDescription("Deterministic linting for AI agents"), Command.withSubcommands([
1344
+ check,
1345
+ list,
1346
+ init,
1347
+ review
1348
+ ]));
1349
+ const AppLayer = Layer.mergeAll(ConfigLoader.layer, Parser$1.layer, Git.layer, IgnoreReader.layer, StateStore.layer).pipe(Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Env.layer));
1350
+ const program = Command.run(agentreview, { version: "0.1.0" }).pipe(Effect.provide(AppLayer));
1351
+ NodeRuntime.runMain(program);
1352
+ //#endregion
1353
+ export {};
1354
+
1355
+ //# sourceMappingURL=bin.mjs.map