@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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +1355 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/index.d.mts +265 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +98 -0
- package/dist/index.mjs.map +1 -0
- package/dist/node-yh9mLvnE.mjs +137 -0
- package/dist/node-yh9mLvnE.mjs.map +1 -0
- package/dist/wasm/tree-sitter-javascript.wasm +0 -0
- package/dist/wasm/tree-sitter-tsx.wasm +0 -0
- package/dist/wasm/tree-sitter-typescript.wasm +0 -0
- package/dist/wasm/tree-sitter.wasm +0 -0
- package/package.json +69 -0
- package/skills/agentlint/rule-advisor/SKILL.md +131 -0
- package/skills/agentlint/usage/SKILL.md +157 -0
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
|