@syv-ai/rulecast 0.1.1 → 0.2.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.
@@ -0,0 +1,1301 @@
1
+ import {
2
+ CORE_VARIABLES,
3
+ DeadlineError,
4
+ LlmUnavailableError,
5
+ MODEL_ALIASES,
6
+ errorMessage,
7
+ isNotFound,
8
+ lineStarts,
9
+ offsetAt,
10
+ onPath,
11
+ perRule,
12
+ positionAt,
13
+ providerByName,
14
+ readSourceFile,
15
+ renderAgentText,
16
+ resolveModel
17
+ } from "./chunk-ZEFEQKG5.js";
18
+
19
+ // src/core/detection/registry.ts
20
+ function createRegistry(detectors) {
21
+ const byKind = /* @__PURE__ */ new Map();
22
+ for (const detector of detectors) {
23
+ if (byKind.has(detector.kind)) throw new Error(`detector "${detector.kind}" registered twice`);
24
+ byKind.set(detector.kind, detector);
25
+ }
26
+ return {
27
+ get: (kind) => byKind.get(kind),
28
+ kinds: () => [...byKind.keys()]
29
+ };
30
+ }
31
+
32
+ // src/detectors/ast-grep/languages.ts
33
+ var LANGUAGES = {
34
+ css: { builtin: "Css" },
35
+ html: { builtin: "Html" },
36
+ javascript: { builtin: "JavaScript" },
37
+ python: { load: () => import("@ast-grep/lang-python") },
38
+ tsx: { builtin: "Tsx" },
39
+ typescript: { builtin: "TypeScript" }
40
+ };
41
+ var LANGUAGE_NAMES = Object.keys(LANGUAGES);
42
+ function napiLanguage(language) {
43
+ const entry = LANGUAGES[language];
44
+ return "builtin" in entry ? entry.builtin : language;
45
+ }
46
+
47
+ // src/detectors/ast-grep/load.ts
48
+ var loading = null;
49
+ async function load() {
50
+ loading ??= (async () => {
51
+ const napi = await import("@ast-grep/napi");
52
+ const dynamic = {};
53
+ for (const [name, entry] of Object.entries(LANGUAGES)) {
54
+ if (!("load" in entry)) continue;
55
+ const module = await entry.load();
56
+ dynamic[name] = module.default ?? module;
57
+ }
58
+ if (Object.keys(dynamic).length > 0) napi.registerDynamicLanguage(dynamic);
59
+ return napi;
60
+ })();
61
+ return loading;
62
+ }
63
+ var AstGrepUnavailable = class extends Error {
64
+ constructor(cause) {
65
+ super(`ast-grep is unavailable: ${errorMessage(cause)}`);
66
+ }
67
+ };
68
+ async function parserFor(language) {
69
+ let napi;
70
+ try {
71
+ napi = await load();
72
+ } catch (error) {
73
+ loading = null;
74
+ throw new AstGrepUnavailable(error);
75
+ }
76
+ const name = napiLanguage(language);
77
+ return { parse: (source) => napi.parse(name, source) };
78
+ }
79
+
80
+ // src/detectors/ast-grep/metavars.ts
81
+ var METAVARIABLE = /\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)/g;
82
+ function scan(value, into) {
83
+ if (typeof value === "string") {
84
+ for (const found of value.matchAll(METAVARIABLE)) {
85
+ const multi = found[1] !== void 0;
86
+ const name = found[1] ?? found[2];
87
+ if (name.startsWith("_")) continue;
88
+ into.set(name, (into.get(name) ?? false) || multi);
89
+ }
90
+ return;
91
+ }
92
+ if (Array.isArray(value)) {
93
+ for (const item of value) scan(item, into);
94
+ return;
95
+ }
96
+ if (typeof value === "object" && value !== null) {
97
+ for (const item of Object.values(value)) scan(item, into);
98
+ }
99
+ }
100
+ function metavariables(config) {
101
+ const found = /* @__PURE__ */ new Map();
102
+ scan(config, found);
103
+ return [...found].map(([name, multi]) => ({ name, multi }));
104
+ }
105
+
106
+ // src/detectors/ast-grep/schema.ts
107
+ import { z } from "zod";
108
+ var ruleObject = z.record(z.unknown()).refine((rule) => Object.keys(rule).length > 0, "rule must not be empty");
109
+ var astGrepSchema = z.object({
110
+ language: z.enum(LANGUAGE_NAMES),
111
+ rule: ruleObject,
112
+ constraints: z.record(ruleObject).optional(),
113
+ utils: z.record(ruleObject).optional()
114
+ }).strict().superRefine(async (config, ctx) => {
115
+ try {
116
+ const parser = await parserFor(config.language);
117
+ parser.parse("").root().findAll(matcher(config));
118
+ } catch (error) {
119
+ ctx.addIssue({ code: "custom", path: ["rule"], message: oneLine(errorMessage(error)) });
120
+ }
121
+ });
122
+ function oneLine(message) {
123
+ return message.split("\n").map((line) => line.replace(/^\s*\|->\s*/, "").trim()).filter(Boolean).join(" ");
124
+ }
125
+ function matcher(config) {
126
+ const { language: _language, ...rest } = config;
127
+ return rest;
128
+ }
129
+ function captureNames(config) {
130
+ return metavariables(matcher(config)).map((variable) => variable.name);
131
+ }
132
+
133
+ // src/detectors/ast-grep/detector.ts
134
+ function capturesOf(node, variables) {
135
+ const captures = {};
136
+ for (const variable of variables) {
137
+ captures[variable.name] = variable.multi ? node.getMultipleMatches(variable.name).filter((match) => match.isNamed()).map((match) => match.text()).join(", ") : node.getMatch(variable.name)?.text() ?? "";
138
+ }
139
+ return captures;
140
+ }
141
+ function matchOf(file, node, variables) {
142
+ const range = node.range();
143
+ return {
144
+ file,
145
+ line: range.start.line + 1,
146
+ endLine: range.end.line + 1,
147
+ column: range.start.column + 1,
148
+ text: node.text(),
149
+ captures: capturesOf(node, variables)
150
+ };
151
+ }
152
+ var astGrepDetector = {
153
+ kind: "ast-grep",
154
+ schema: astGrepSchema,
155
+ captures: captureNames,
156
+ events: () => ["edit", "verify"],
157
+ guards: true,
158
+ async run(input) {
159
+ const result = { findings: [], errors: [] };
160
+ const byLanguage = /* @__PURE__ */ new Map();
161
+ for (const rule of input.rules) {
162
+ byLanguage.set(rule.config.language, [...byLanguage.get(rule.config.language) ?? [], rule]);
163
+ }
164
+ await Promise.all(
165
+ [...byLanguage].map(async ([language, rules]) => {
166
+ let parser;
167
+ try {
168
+ parser = await parserFor(language);
169
+ } catch (error) {
170
+ for (const rule of rules) result.errors.push({ rule: rule.id, message: errorMessage(error) });
171
+ return;
172
+ }
173
+ const variables = new Map(rules.map((rule) => [rule.id, metavariables(matcher(rule.config))]));
174
+ const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
175
+ for (const file of files) {
176
+ input.signal.throwIfAborted();
177
+ let root;
178
+ try {
179
+ const source = await input.read(file);
180
+ if (source === null) continue;
181
+ root = parser.parse(source).root();
182
+ } catch (error) {
183
+ if (input.signal.aborted) throw error;
184
+ const message = errorMessage(error);
185
+ for (const rule of rules) {
186
+ if (rule.files.includes(file) && !result.errors.some((existing) => existing.rule === rule.id)) {
187
+ result.errors.push({ rule: rule.id, message });
188
+ }
189
+ }
190
+ continue;
191
+ }
192
+ for (const rule of rules) {
193
+ if (!rule.files.includes(file)) continue;
194
+ try {
195
+ for (const node of root.findAll(matcher(rule.config))) {
196
+ result.findings.push({ rule: rule.id, match: matchOf(file, node, variables.get(rule.id)) });
197
+ }
198
+ } catch (error) {
199
+ if (input.signal.aborted) throw error;
200
+ if (!result.errors.some((existing) => existing.rule === rule.id)) {
201
+ result.errors.push({ rule: rule.id, message: errorMessage(error) });
202
+ }
203
+ }
204
+ }
205
+ }
206
+ })
207
+ );
208
+ return result;
209
+ },
210
+ async check(input) {
211
+ const languages = [...new Set(input.rules.map((rule) => rule.config.language))].sort();
212
+ for (const language of languages) await parserFor(language);
213
+ return [{ what: "ast-grep", level: "ok", detail: languages.join(", "), rules: [] }];
214
+ }
215
+ };
216
+
217
+ // src/detectors/command/detector.ts
218
+ import { execFile } from "child_process";
219
+ import { access, constants } from "fs/promises";
220
+ import path2 from "path";
221
+ import { promisify } from "util";
222
+
223
+ // src/detectors/command/results.ts
224
+ import { realpathSync } from "fs";
225
+ import path from "path";
226
+ import { fileURLToPath } from "url";
227
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
228
+ function parseJson(output) {
229
+ try {
230
+ return JSON.parse(output);
231
+ } catch {
232
+ throw new Error("output is not JSON");
233
+ }
234
+ }
235
+ function repoRelative(file, cwd) {
236
+ const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
237
+ if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
238
+ for (const root of rootsOf(cwd)) {
239
+ const relative = path.relative(root, withoutScheme);
240
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
241
+ }
242
+ return path.relative(cwd, withoutScheme).split(path.sep).join("/");
243
+ }
244
+ function rootsOf(root) {
245
+ try {
246
+ const real = realpathSync(root);
247
+ return real === root ? [root] : [root, real];
248
+ } catch {
249
+ return [root];
250
+ }
251
+ }
252
+ function capturesOf2(source, names, where) {
253
+ const captures = {};
254
+ for (const name of names) {
255
+ const value = source[name];
256
+ if (typeof value !== "string") throw new Error(`${where}: capture "${name}" is not a string`);
257
+ captures[name] = value;
258
+ }
259
+ return captures;
260
+ }
261
+ function matchesFromJson(output, captures, cwd) {
262
+ const parsed = parseJson(output);
263
+ if (!Array.isArray(parsed)) throw new Error("output is not a JSON array of results");
264
+ return parsed.map((result, index) => {
265
+ const where = `result ${index + 1}`;
266
+ if (!isObject(result)) throw new Error(`${where}: not an object`);
267
+ if (typeof result.file !== "string") throw new Error(`${where}: "file" must be a string`);
268
+ if (typeof result.line !== "number") throw new Error(`${where}: "line" must be a number`);
269
+ return {
270
+ file: repoRelative(result.file, cwd),
271
+ line: result.line,
272
+ endLine: typeof result.endLine === "number" ? result.endLine : result.line,
273
+ column: typeof result.column === "number" ? result.column : 1,
274
+ text: typeof result.text === "string" ? result.text : "",
275
+ captures: capturesOf2(result, captures, where)
276
+ };
277
+ });
278
+ }
279
+ function matchesFromSarif(output, captures, cwd) {
280
+ const parsed = parseJson(output);
281
+ if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error("output is not SARIF 2.1.0");
282
+ const matches = [];
283
+ let index = 0;
284
+ for (const run of parsed.runs) {
285
+ if (!isObject(run) || !Array.isArray(run.results)) throw new Error("output is not SARIF 2.1.0");
286
+ for (const result of run.results) {
287
+ index += 1;
288
+ const where = `result ${index}`;
289
+ if (!isObject(result)) throw new Error(`${where}: not an object`);
290
+ const location = Array.isArray(result.locations) ? result.locations[0] : void 0;
291
+ const physical = isObject(location) ? location.physicalLocation : void 0;
292
+ const artifact = isObject(physical) ? physical.artifactLocation : void 0;
293
+ if (!isObject(artifact) || typeof artifact.uri !== "string") continue;
294
+ const region = isObject(physical) && isObject(physical.region) ? physical.region : {};
295
+ const line = typeof region.startLine === "number" ? region.startLine : 1;
296
+ const message = isObject(result.message) && typeof result.message.text === "string" ? result.message.text : "";
297
+ const source = isObject(result.properties) ? { ...result, ...result.properties } : result;
298
+ matches.push({
299
+ file: repoRelative(artifact.uri, cwd),
300
+ line,
301
+ endLine: typeof region.endLine === "number" ? region.endLine : line,
302
+ column: typeof region.startColumn === "number" ? region.startColumn : 1,
303
+ text: message,
304
+ captures: capturesOf2(source, captures, where)
305
+ });
306
+ }
307
+ }
308
+ return matches;
309
+ }
310
+
311
+ // src/detectors/command/schema.ts
312
+ import { z as z2 } from "zod";
313
+ var VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
314
+ var commandSchema = z2.object({
315
+ /** argv. The element exactly "{{files}}" is replaced by the rule's files; otherwise they are appended. */
316
+ run: z2.array(z2.string().min(1)).min(1),
317
+ output: z2.enum(["json", "sarif"]).default("json"),
318
+ captures: z2.array(z2.string().regex(VARIABLE_NAME, "a capture must be a template variable name")).default([]).superRefine((captures, ctx) => {
319
+ for (const name of captures) {
320
+ if (CORE_VARIABLES.includes(name)) {
321
+ ctx.addIssue({ code: "custom", message: `"${name}" is a core template variable` });
322
+ }
323
+ }
324
+ })
325
+ }).strict();
326
+
327
+ // src/detectors/command/detector.ts
328
+ var exec = promisify(execFile);
329
+ var FILES = "{{files}}";
330
+ function argvFor(run, files) {
331
+ if (!run.includes(FILES)) return [...run, ...files];
332
+ return run.flatMap((argument) => argument === FILES ? files : [argument]);
333
+ }
334
+ async function reachable(command, cwd, signal) {
335
+ if (!command.includes("/")) {
336
+ return await onPath(command, signal) ? { level: "ok", detail: `${command} (PATH)` } : { level: "error", detail: "not installed" };
337
+ }
338
+ const full = path2.resolve(cwd, command);
339
+ try {
340
+ await access(full, constants.F_OK);
341
+ } catch {
342
+ return { level: "error", detail: "no such file" };
343
+ }
344
+ try {
345
+ await access(full, constants.X_OK);
346
+ } catch {
347
+ return { level: "error", detail: "not executable" };
348
+ }
349
+ return { level: "ok", detail: full };
350
+ }
351
+ var commandDetector = {
352
+ kind: "command",
353
+ schema: commandSchema,
354
+ captures: (config) => config.captures,
355
+ events: () => ["edit", "verify"],
356
+ run: perRule(async (rule, input) => {
357
+ if (rule.files.length === 0) return [];
358
+ const [command, ...rest] = argvFor(rule.config.run, rule.files);
359
+ let stdout;
360
+ try {
361
+ const result = await exec(command, rest, { cwd: input.cwd, signal: input.signal, maxBuffer: 64 * 1024 * 1024 });
362
+ stdout = result.stdout;
363
+ } catch (error) {
364
+ if (input.signal.aborted) throw error;
365
+ if (isNotFound(error)) throw new Error(`command not found: ${command}`);
366
+ const failure = error;
367
+ if (typeof failure.stdout !== "string") throw error;
368
+ stdout = failure.stdout;
369
+ }
370
+ return rule.config.output === "sarif" ? matchesFromSarif(stdout, rule.config.captures, input.cwd) : matchesFromJson(stdout, rule.config.captures, input.cwd);
371
+ }),
372
+ async check(input) {
373
+ const byCommand = /* @__PURE__ */ new Map();
374
+ for (const rule of input.rules) {
375
+ const command = rule.config.run[0];
376
+ byCommand.set(command, [...byCommand.get(command) ?? [], rule.id]);
377
+ }
378
+ return Promise.all(
379
+ [...byCommand].map(async ([command, rules]) => {
380
+ const { level, detail } = await reachable(command, input.cwd, input.signal);
381
+ return { what: command, level, detail, rules: level === "ok" ? [] : rules };
382
+ })
383
+ );
384
+ }
385
+ };
386
+
387
+ // src/detectors/linter/detector.ts
388
+ import { execFile as execFile2 } from "child_process";
389
+ import { promisify as promisify2 } from "util";
390
+
391
+ // src/detectors/linter/resolve.ts
392
+ import { access as access2, constants as constants2 } from "fs/promises";
393
+ import path3 from "path";
394
+ var PYTHON_TOOLS = /* @__PURE__ */ new Set(["ruff"]);
395
+ async function reachable2(file, mode) {
396
+ try {
397
+ await access2(file, mode);
398
+ return true;
399
+ } catch {
400
+ return false;
401
+ }
402
+ }
403
+ async function resolveTool(tool, root, available = onPath) {
404
+ const local = path3.join(root, "node_modules", ".bin", tool);
405
+ if (await reachable2(local, constants2.X_OK)) return { command: local, prefix: [] };
406
+ if (PYTHON_TOOLS.has(tool) && await reachable2(path3.join(root, "pyproject.toml"), constants2.F_OK) && await available("uv")) {
407
+ return { command: "uv", prefix: ["run", "--", tool] };
408
+ }
409
+ return { command: tool, prefix: [] };
410
+ }
411
+ async function describeTool(tool, root, options = {}) {
412
+ const available = options.available ?? ((command) => onPath(command, options.signal));
413
+ const resolved = await resolveTool(tool, root, available);
414
+ if (path3.isAbsolute(resolved.command)) {
415
+ const relative = path3.relative(root, resolved.command);
416
+ const inside = relative !== "" && !relative.startsWith("..") && !path3.isAbsolute(relative);
417
+ return { command: inside ? relative.split(path3.sep).join("/") : resolved.command, how: "local", found: true };
418
+ }
419
+ if (resolved.prefix.length > 0) {
420
+ return { command: [resolved.command, ...resolved.prefix].join(" "), how: "uv", found: true };
421
+ }
422
+ return { command: resolved.command, how: "path", found: await available(resolved.command) };
423
+ }
424
+
425
+ // src/detectors/linter/schema.ts
426
+ import { z as z3 } from "zod";
427
+ var TOOL_NAMES = ["eslint", "oxlint", "ruff"];
428
+ var linterSchema = z3.object({
429
+ tool: z3.enum(TOOL_NAMES),
430
+ /** Linter rule ids to report. Omitted: every finding the tool reports. */
431
+ rules: z3.array(z3.string().min(1)).min(1).optional()
432
+ }).strict();
433
+
434
+ // src/detectors/linter/tools.ts
435
+ import { realpathSync as realpathSync2 } from "fs";
436
+ import path4 from "path";
437
+ var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
438
+ function json(tool, stdout) {
439
+ try {
440
+ return JSON.parse(stdout);
441
+ } catch {
442
+ throw new Error(`${tool} output is not JSON`);
443
+ }
444
+ }
445
+ function relativeTo(roots, file) {
446
+ if (!path4.isAbsolute(file)) return file.split(path4.sep).join("/");
447
+ for (const root of roots) {
448
+ const relative = path4.relative(root, file);
449
+ if (!relative.startsWith("..") && !path4.isAbsolute(relative)) return relative.split(path4.sep).join("/");
450
+ }
451
+ return path4.relative(roots[0], file).split(path4.sep).join("/");
452
+ }
453
+ function rootsOf2(root) {
454
+ try {
455
+ const real = realpathSync2(root);
456
+ return real === root ? [root] : [root, real];
457
+ } catch {
458
+ return [root];
459
+ }
460
+ }
461
+ var number = (value, fallback) => typeof value === "number" ? value : fallback;
462
+ var text = (value) => typeof value === "string" ? value : "";
463
+ function oxlintRuleId(code) {
464
+ const raw = text(code);
465
+ return /^[\w-]+\((?<rule>[^)]+)\)$/.exec(raw)?.groups?.rule ?? raw;
466
+ }
467
+ var TOOLS = {
468
+ ruff: {
469
+ events: ["edit", "verify"],
470
+ args: (files) => ["check", "--output-format", "json", "--force-exclude", "--", ...files],
471
+ parse(stdout, root) {
472
+ const parsed = json("ruff", stdout);
473
+ if (!Array.isArray(parsed)) throw new Error("ruff output is not a JSON array of diagnostics");
474
+ const roots = rootsOf2(root);
475
+ return parsed.filter(isObject2).map((result) => {
476
+ const start = isObject2(result.location) ? result.location : {};
477
+ const end = isObject2(result.end_location) ? result.end_location : {};
478
+ const line = number(start.row, 1);
479
+ return {
480
+ file: relativeTo(roots, text(result.filename)),
481
+ line,
482
+ column: number(start.column, 1),
483
+ endLine: number(end.row, line),
484
+ endColumn: typeof end.column === "number" ? end.column : null,
485
+ ruleId: text(result.code),
486
+ message: text(result.message)
487
+ };
488
+ });
489
+ }
490
+ },
491
+ oxlint: {
492
+ events: ["edit", "verify"],
493
+ args: (files) => ["--format=json", "--", ...files],
494
+ parse(stdout, root) {
495
+ const parsed = json("oxlint", stdout);
496
+ if (!isObject2(parsed) || !Array.isArray(parsed.diagnostics)) {
497
+ throw new Error("oxlint output is not JSON with diagnostics");
498
+ }
499
+ const roots = rootsOf2(root);
500
+ return parsed.diagnostics.filter(isObject2).map((diagnostic) => {
501
+ const label = Array.isArray(diagnostic.labels) ? diagnostic.labels[0] : void 0;
502
+ const span = isObject2(label) && isObject2(label.span) ? label.span : {};
503
+ const line = number(span.line, 1);
504
+ return {
505
+ file: relativeTo(roots, text(diagnostic.filename)),
506
+ line,
507
+ column: number(span.column, 1),
508
+ endLine: line,
509
+ endColumn: null,
510
+ ruleId: oxlintRuleId(diagnostic.code),
511
+ message: text(diagnostic.message)
512
+ };
513
+ });
514
+ }
515
+ },
516
+ eslint: {
517
+ // eslint is the slow one: spec §6 keeps it off the edit hook by default.
518
+ events: ["verify"],
519
+ args: (files) => ["--format=json", "--no-error-on-unmatched-pattern", "--", ...files],
520
+ parse(stdout, root) {
521
+ const parsed = json("eslint", stdout);
522
+ if (!Array.isArray(parsed)) throw new Error("eslint output is not a JSON array of file results");
523
+ const roots = rootsOf2(root);
524
+ const findings = [];
525
+ for (const file of parsed.filter(isObject2)) {
526
+ const messages = Array.isArray(file.messages) ? file.messages : [];
527
+ for (const message of messages.filter(isObject2)) {
528
+ const line = number(message.line, 1);
529
+ findings.push({
530
+ file: relativeTo(roots, text(file.filePath)),
531
+ line,
532
+ column: number(message.column, 1),
533
+ endLine: number(message.endLine, line),
534
+ endColumn: typeof message.endColumn === "number" ? message.endColumn : null,
535
+ ruleId: text(message.ruleId),
536
+ message: text(message.message)
537
+ });
538
+ }
539
+ }
540
+ return findings;
541
+ }
542
+ }
543
+ };
544
+
545
+ // src/detectors/linter/detector.ts
546
+ var exec2 = promisify2(execFile2);
547
+ function sourceReader(cwd) {
548
+ const cache = /* @__PURE__ */ new Map();
549
+ return (file) => {
550
+ let source = cache.get(file);
551
+ if (source === void 0) {
552
+ source = readSourceFile(cwd, file);
553
+ cache.set(file, source);
554
+ }
555
+ return source;
556
+ };
557
+ }
558
+ function excerpt(source, finding) {
559
+ if (source === null) return "";
560
+ const starts = lineStarts(source);
561
+ const from = offsetAt(starts, finding.line, finding.column, source.length);
562
+ let to;
563
+ if (finding.endColumn === null) {
564
+ const newline = source.indexOf("\n", from);
565
+ to = newline === -1 ? source.length : newline;
566
+ } else {
567
+ to = offsetAt(starts, finding.endLine, finding.endColumn, source.length);
568
+ }
569
+ return source.slice(from, Math.max(from, to)).replace(/\n$/, "");
570
+ }
571
+ async function runTool(tool, files, cwd, signal) {
572
+ const resolved = await resolveTool(tool, cwd);
573
+ const args = [...resolved.prefix, ...TOOLS[tool].args(files)];
574
+ let stdout;
575
+ try {
576
+ const result = await exec2(resolved.command, args, { cwd, signal, maxBuffer: 64 * 1024 * 1024 });
577
+ stdout = result.stdout;
578
+ } catch (error) {
579
+ if (signal.aborted) throw error;
580
+ if (isNotFound(error)) throw new Error(`${tool} is not installed`);
581
+ const failure = error;
582
+ if (typeof failure.stdout !== "string") throw error;
583
+ stdout = failure.stdout;
584
+ }
585
+ return TOOLS[tool].parse(stdout, cwd);
586
+ }
587
+ var linterDetector = {
588
+ kind: "linter",
589
+ schema: linterSchema,
590
+ captures: () => ["message", "ruleId"],
591
+ // A copy: compileRule stores this as the rule's stages, and module-level data must not escape into it.
592
+ events: (config) => [...TOOLS[config.tool].events],
593
+ async run(input) {
594
+ const result = { findings: [], errors: [] };
595
+ const byTool = /* @__PURE__ */ new Map();
596
+ for (const rule of input.rules) {
597
+ byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule]);
598
+ }
599
+ const read = sourceReader(input.cwd);
600
+ await Promise.all(
601
+ [...byTool].map(async ([tool, rules]) => {
602
+ const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
603
+ if (files.length === 0) return;
604
+ try {
605
+ const findings = await runTool(tool, files, input.cwd, input.signal);
606
+ for (const finding of findings) {
607
+ const match = {
608
+ file: finding.file,
609
+ line: finding.line,
610
+ endLine: finding.endLine,
611
+ column: finding.column,
612
+ text: excerpt(await read(finding.file), finding),
613
+ captures: { message: finding.message, ruleId: finding.ruleId }
614
+ };
615
+ for (const rule of rules) {
616
+ if (!rule.files.includes(finding.file)) continue;
617
+ if (rule.config.rules && !rule.config.rules.includes(finding.ruleId)) continue;
618
+ result.findings.push({ rule: rule.id, match });
619
+ }
620
+ }
621
+ } catch (error) {
622
+ if (input.signal.aborted) throw error;
623
+ const message = errorMessage(error);
624
+ for (const rule of rules) result.errors.push({ rule: rule.id, message });
625
+ }
626
+ })
627
+ );
628
+ return result;
629
+ },
630
+ async check(input) {
631
+ const byTool = /* @__PURE__ */ new Map();
632
+ for (const rule of input.rules) {
633
+ byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule.id]);
634
+ }
635
+ return Promise.all(
636
+ [...byTool].map(async ([tool, rules]) => {
637
+ const described = await describeTool(tool, input.cwd, { signal: input.signal });
638
+ if (!described.found) return { what: tool, level: "error", detail: "not installed", rules };
639
+ const detail = described.how === "path" ? `${described.command} (PATH)` : described.command;
640
+ return { what: tool, level: "ok", detail, rules: [] };
641
+ })
642
+ );
643
+ }
644
+ };
645
+
646
+ // src/detectors/llm/call.ts
647
+ import { createHash } from "crypto";
648
+ function callKey(input) {
649
+ const payload = {
650
+ file: input.file,
651
+ source: input.source,
652
+ changedLines: input.changedLines,
653
+ provider: input.provider,
654
+ baseUrl: input.baseUrl,
655
+ model: input.model,
656
+ rules: [...input.rules].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).map((rule) => ({ id: rule.id, config: rule.config, grounding: rule.grounding }))
657
+ };
658
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
659
+ }
660
+ async function askCached(provider, cache, key, request) {
661
+ const cached = await cache.get(key);
662
+ if (cached !== void 0) return cached;
663
+ const findings = await provider.ask(request);
664
+ await cache.set(key, findings);
665
+ return findings;
666
+ }
667
+
668
+ // src/detectors/llm/prompt.ts
669
+ function buildPrompt(call) {
670
+ const marked = call.changedLines !== null;
671
+ const lines = [
672
+ "Check the file below against each rule. Report every line that breaks a rule.",
673
+ "",
674
+ 'Answer with JSON only: {"findings": [{"rule": "<rule id>", "line": <number>, "text": "<the line>", "reason": "<why>"}]}',
675
+ "Report nothing when a rule is not broken. Use the rule ids exactly as given.",
676
+ marked ? 'Only lines marked with ">" were changed. Report findings on those lines only.' : "Judge the whole file.",
677
+ "",
678
+ "## Rules",
679
+ ""
680
+ ];
681
+ for (const rule of call.rules) {
682
+ lines.push(`### ${rule.id}`, rule.question, "");
683
+ for (const reference of rule.grounding) {
684
+ lines.push(`Reference \u2014 ${reference.ref}:`, '"""', reference.content.replace(/\n$/, ""), '"""', "");
685
+ }
686
+ }
687
+ lines.push(`## File: ${call.file}`, "");
688
+ const fence = fenceFor(call.source);
689
+ lines.push(fence, ...numbered(call.source, call.changedLines), fence);
690
+ return lines.join("\n");
691
+ }
692
+ function fenceFor(source) {
693
+ let longest = 2;
694
+ for (const run of source.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
695
+ return "`".repeat(longest + 1);
696
+ }
697
+ function numbered(source, changedLines) {
698
+ const sourceLines = source.replace(/\n$/, "").split("\n");
699
+ const width = String(sourceLines.length).length;
700
+ return sourceLines.map((text2, index) => {
701
+ const number2 = index + 1;
702
+ const mark = changedLines !== null && isChanged(number2, changedLines) ? "> " : " ";
703
+ return `${mark}${String(number2).padStart(width)} ${text2}`;
704
+ });
705
+ }
706
+ function isChanged(line, changedLines) {
707
+ return changedLines.some(([start, end]) => line >= start && line <= end);
708
+ }
709
+
710
+ // src/detectors/llm/schema.ts
711
+ import { z as z4 } from "zod";
712
+ var llmSchema = z4.object({
713
+ /**
714
+ * An alias from ./models.ts — haiku, sonnet, opus, fable — or the exact name the configured
715
+ * provider uses. Required: there is no project-wide default, so the cost of a rule is always
716
+ * chosen by whoever wrote it (plan 6a, Decision 1).
717
+ */
718
+ model: z4.string().trim().min(1),
719
+ question: z4.string().trim().min(1),
720
+ /** Send the rule's resolved context references with the question, whatever their delivery mode. */
721
+ grounding: z4.boolean().default(true)
722
+ }).strict();
723
+
724
+ // src/detectors/llm/detector.ts
725
+ function sourceReader2(cwd) {
726
+ const cache = /* @__PURE__ */ new Map();
727
+ return (file) => {
728
+ let source = cache.get(file);
729
+ if (source === void 0) {
730
+ source = readSourceFile(cwd, file);
731
+ cache.set(file, source);
732
+ }
733
+ return source;
734
+ };
735
+ }
736
+ function changedLinesOf(changes, file) {
737
+ const change = changes.get(file);
738
+ if (change === void 0) return null;
739
+ return change.changedLines.length === 0 ? "skip" : change.changedLines;
740
+ }
741
+ async function groupCalls(rules, changes, read) {
742
+ const calls = /* @__PURE__ */ new Map();
743
+ for (const rule of rules) {
744
+ for (const file of rule.files) {
745
+ const changedLines = changedLinesOf(changes, file);
746
+ if (changedLines === "skip") continue;
747
+ const source = await read(file);
748
+ if (source === null) continue;
749
+ const key = `${file}\0${rule.config.model}`;
750
+ const call = calls.get(key);
751
+ if (call) call.rules.push(rule);
752
+ else calls.set(key, { file, source, changedLines, model: rule.config.model, rules: [rule] });
753
+ }
754
+ }
755
+ return [...calls.values()];
756
+ }
757
+ function promptRules(rules) {
758
+ return rules.map((rule) => ({
759
+ id: rule.id,
760
+ question: rule.config.question,
761
+ // Spec §6: the rule's resolved references, whatever their delivery mode.
762
+ grounding: rule.config.grounding ? rule.context.map((ref) => ({ ref: ref.ref, content: ref.content })) : []
763
+ }));
764
+ }
765
+ function toFindings(call, answered) {
766
+ const lines = call.source.replace(/\n$/, "").split("\n");
767
+ const ids = new Set(call.rules.map((rule) => rule.id));
768
+ const findings = [];
769
+ for (const finding of answered) {
770
+ if (!ids.has(finding.rule)) continue;
771
+ if (finding.line < 1 || finding.line > lines.length) continue;
772
+ if (call.changedLines !== null && !call.changedLines.some(([a, b]) => finding.line >= a && finding.line <= b)) {
773
+ continue;
774
+ }
775
+ findings.push({
776
+ rule: finding.rule,
777
+ match: {
778
+ file: call.file,
779
+ line: finding.line,
780
+ endLine: finding.line,
781
+ column: 1,
782
+ // The file's own line, not the model's quote: {{text}} is the matched source everywhere else.
783
+ text: lines[finding.line - 1],
784
+ captures: { reason: finding.reason }
785
+ }
786
+ });
787
+ }
788
+ return findings;
789
+ }
790
+ function unmappedModel(provider) {
791
+ return `no name for the ${provider} provider; use that provider's own model name`;
792
+ }
793
+ async function runCall(call, provider, settings, input) {
794
+ const model = resolveModel(call.model, settings.provider);
795
+ if (model === null) throw new Error(`model "${call.model}" has ${unmappedModel(settings.provider)}`);
796
+ const rules = promptRules(call.rules);
797
+ const key = callKey({
798
+ file: call.file,
799
+ source: call.source,
800
+ changedLines: call.changedLines,
801
+ provider: settings.provider,
802
+ baseUrl: settings.baseUrl,
803
+ model,
804
+ rules: call.rules.map((rule, index) => ({
805
+ id: rule.id,
806
+ config: rule.config,
807
+ grounding: rules[index].grounding
808
+ }))
809
+ });
810
+ const answered = await askCached(provider, input.cache, key, {
811
+ model,
812
+ prompt: buildPrompt({ file: call.file, source: call.source, changedLines: call.changedLines, rules }),
813
+ settings,
814
+ env: process.env,
815
+ cwd: input.cwd,
816
+ signal: input.signal
817
+ });
818
+ return toFindings(call, answered);
819
+ }
820
+ var llmDetector = {
821
+ kind: "llm",
822
+ schema: llmSchema,
823
+ captures: () => ["reason"],
824
+ // verify only by default (spec §6): a model call is far too slow for an edit hook. A rule opts
825
+ // in with `stages: [edit, verify]`.
826
+ events: () => ["verify"],
827
+ async run(input) {
828
+ const result = { findings: [], errors: [] };
829
+ const settings = input.settings.llm;
830
+ let provider;
831
+ try {
832
+ provider = providerByName(settings.provider);
833
+ } catch (error) {
834
+ return { findings: [], errors: [{ rule: null, message: errorMessage(error) }] };
835
+ }
836
+ const calls = await groupCalls(input.rules, input.changes, sourceReader2(input.cwd));
837
+ const outcomes = await Promise.all(
838
+ calls.map(async (call) => {
839
+ try {
840
+ return { call, findings: await runCall(call, provider, settings, input) };
841
+ } catch (error) {
842
+ if (input.signal.aborted) throw error;
843
+ return { call, error };
844
+ }
845
+ })
846
+ );
847
+ for (const outcome of outcomes) {
848
+ if ("error" in outcome && outcome.error instanceof LlmUnavailableError) {
849
+ return { findings: [], errors: [{ rule: null, message: outcome.error.message }] };
850
+ }
851
+ }
852
+ for (const outcome of outcomes) {
853
+ if ("error" in outcome) {
854
+ const message = errorMessage(outcome.error);
855
+ for (const rule of outcome.call.rules) result.errors.push({ rule: rule.id, message });
856
+ continue;
857
+ }
858
+ result.findings.push(...outcome.findings);
859
+ }
860
+ return result;
861
+ },
862
+ /**
863
+ * The two pre-flight checks plan 6a deferred here: is the backend reachable at all, and does
864
+ * every rule's model alias have a name for it. Both are environmental and free — nothing in this
865
+ * method calls a model, which is also why doctor does not dry-run llm rules.
866
+ */
867
+ async check(input) {
868
+ const settings = input.settings.llm;
869
+ const ids = input.rules.map((rule) => rule.id);
870
+ let provider;
871
+ try {
872
+ provider = providerByName(settings.provider);
873
+ } catch (error) {
874
+ return [{ what: settings.provider, level: "error", detail: errorMessage(error), rules: ids }];
875
+ }
876
+ const reachable3 = await provider.available({
877
+ settings,
878
+ env: input.env,
879
+ cwd: input.cwd,
880
+ signal: input.signal
881
+ });
882
+ const results = [
883
+ {
884
+ what: settings.provider,
885
+ level: reachable3.ok ? "ok" : "error",
886
+ detail: reachable3.detail,
887
+ rules: reachable3.ok ? [] : ids
888
+ }
889
+ ];
890
+ const byModel = /* @__PURE__ */ new Map();
891
+ for (const rule of input.rules) {
892
+ byModel.set(rule.config.model, [...byModel.get(rule.config.model) ?? [], rule.id]);
893
+ }
894
+ for (const [model, rules] of byModel) {
895
+ const resolved = resolveModel(model, settings.provider);
896
+ results.push(
897
+ resolved === null ? {
898
+ what: `model "${model}"`,
899
+ level: "error",
900
+ detail: unmappedModel(settings.provider),
901
+ rules
902
+ } : {
903
+ what: `model "${model}"`,
904
+ level: "ok",
905
+ // "passed through" is only true of a name that is not an alias at all. An alias that
906
+ // resolves to itself — haiku for the claude-code provider — is still an alias, and
907
+ // saying otherwise reads as "rulecast does not know this model".
908
+ detail: MODEL_ALIASES.includes(model) ? `${settings.provider} calls it ${resolved}` : "passed through",
909
+ rules: []
910
+ }
911
+ );
912
+ }
913
+ return results;
914
+ }
915
+ };
916
+
917
+ // src/detectors/path.ts
918
+ import { z as z5 } from "zod";
919
+ var schema = z5.object({}).strict();
920
+ var pathDetector = {
921
+ kind: "path",
922
+ schema,
923
+ captures: () => [],
924
+ events: () => ["edit", "verify"],
925
+ guards: true,
926
+ run: perRule(async (rule, input) => {
927
+ const matches = [];
928
+ for (const file of rule.files) {
929
+ input.signal.throwIfAborted();
930
+ const text2 = await input.read(file);
931
+ if (text2 === null) continue;
932
+ matches.push({ file, line: 1, endLine: text2.split(/\r?\n/).length, column: 1, text: file, captures: {} });
933
+ }
934
+ return matches;
935
+ })
936
+ };
937
+
938
+ // src/detectors/regex.ts
939
+ import { createContext, Script } from "vm";
940
+ import { z as z6 } from "zod";
941
+ var schema2 = z6.object({
942
+ pattern: z6.string().min(1),
943
+ flags: z6.string().regex(/^[dimsuvy]*$/, "allowed flags: d i m s u v y").default("")
944
+ }).strict().superRefine((config, ctx) => {
945
+ try {
946
+ new RegExp(config.pattern, config.flags);
947
+ } catch (error) {
948
+ ctx.addIssue({ code: "custom", path: ["pattern"], message: errorMessage(error) });
949
+ }
950
+ });
951
+ var NAMED_GROUP = /\(\?<([A-Za-z_$][\w$]*)>/g;
952
+ function groupNames(pattern) {
953
+ return [...new Set([...pattern.matchAll(NAMED_GROUP)].map((match) => match[1]))];
954
+ }
955
+ var SCOPE = { text: null, res: null, out: null };
956
+ var MATCH_CONTEXT = createContext(SCOPE);
957
+ var MATCH_SCRIPT = new Script("out = res.map((re) => [...text.matchAll(re)])", {
958
+ filename: "rulecast-regex-match.js"
959
+ });
960
+ function matchAllWithin(text2, patterns, budgetMs) {
961
+ SCOPE.text = text2;
962
+ SCOPE.res = patterns;
963
+ SCOPE.out = null;
964
+ try {
965
+ MATCH_SCRIPT.runInContext(MATCH_CONTEXT, { timeout: Math.max(1, Math.trunc(budgetMs)) });
966
+ return SCOPE.out ?? [];
967
+ } catch (error) {
968
+ throw new DeadlineError(errorMessage(error));
969
+ } finally {
970
+ SCOPE.text = null;
971
+ SCOPE.res = null;
972
+ SCOPE.out = null;
973
+ }
974
+ }
975
+ var regexDetector = {
976
+ kind: "regex",
977
+ schema: schema2,
978
+ captures: (config) => groupNames(config.pattern),
979
+ events: () => ["edit", "verify"],
980
+ guards: true,
981
+ /**
982
+ * Driven by file rather than by rule, so one file is read once and scanned once however many
983
+ * rules select it. `perRule` would read and match it once per rule — 100,000 reads for 500 rules
984
+ * over 200 files — and there is nothing for `perRule` to isolate here: a pattern the schema
985
+ * accepted cannot fail at run time, which is why the contract fixture has no failing case.
986
+ */
987
+ async run(input) {
988
+ const result = { findings: [], errors: [] };
989
+ const names = new Map(input.rules.map((rule) => [rule.id, groupNames(rule.config.pattern)]));
990
+ const byFile = /* @__PURE__ */ new Map();
991
+ for (const rule of input.rules) {
992
+ for (const file of rule.files) {
993
+ const group = byFile.get(file);
994
+ if (group === void 0) byFile.set(file, [rule]);
995
+ else group.push(rule);
996
+ }
997
+ }
998
+ for (const [file, rules] of byFile) {
999
+ input.signal.throwIfAborted();
1000
+ const text2 = await input.read(file);
1001
+ if (text2 === null) continue;
1002
+ const starts = lineStarts(text2);
1003
+ const patterns = rules.map((rule) => new RegExp(rule.config.pattern, `${rule.config.flags}g`));
1004
+ const perPattern = matchAllWithin(text2, patterns, input.deadlineAt - Date.now());
1005
+ for (const [index, rule] of rules.entries()) {
1006
+ const captured = names.get(rule.id) ?? [];
1007
+ for (const found of perPattern[index] ?? []) {
1008
+ const start = found.index;
1009
+ const end = start + found[0].length;
1010
+ const from = positionAt(starts, start);
1011
+ const to = positionAt(starts, Math.max(start, end - 1));
1012
+ result.findings.push({
1013
+ rule: rule.id,
1014
+ match: {
1015
+ file,
1016
+ line: from.line,
1017
+ endLine: to.line,
1018
+ column: from.column,
1019
+ text: found[0],
1020
+ captures: Object.fromEntries(captured.map((name) => [name, found.groups?.[name] ?? ""]))
1021
+ }
1022
+ });
1023
+ }
1024
+ }
1025
+ }
1026
+ return result;
1027
+ }
1028
+ };
1029
+
1030
+ // src/detectors/index.ts
1031
+ var builtinDetectors = [
1032
+ regexDetector,
1033
+ pathDetector,
1034
+ astGrepDetector,
1035
+ commandDetector,
1036
+ linterDetector,
1037
+ llmDetector
1038
+ ];
1039
+
1040
+ // src/adapters/claude-code/parse.ts
1041
+ import { z as z7 } from "zod";
1042
+ var common = z7.object({
1043
+ hook_event_name: z7.string(),
1044
+ session_id: z7.string().min(1),
1045
+ cwd: z7.string().min(1),
1046
+ agent_id: z7.string().min(1).optional(),
1047
+ agent_type: z7.string().optional(),
1048
+ source: z7.string().optional(),
1049
+ prompt: z7.string().optional()
1050
+ });
1051
+ var fileTool = z7.object({
1052
+ tool_name: z7.string(),
1053
+ tool_input: z7.object({ file_path: z7.string().min(1) })
1054
+ });
1055
+ var readResponse = z7.object({
1056
+ tool_response: z7.object({
1057
+ file: z7.object({ startLine: z7.number(), numLines: z7.number(), totalLines: z7.number() })
1058
+ })
1059
+ });
1060
+ var writeInput = z7.object({
1061
+ tool_input: z7.object({
1062
+ file_path: z7.string().min(1),
1063
+ content: z7.string().optional(),
1064
+ old_string: z7.string().optional(),
1065
+ new_string: z7.string().optional(),
1066
+ replace_all: z7.boolean().optional()
1067
+ })
1068
+ });
1069
+ function parseClaudeCode(input) {
1070
+ const head = common.safeParse(input);
1071
+ if (!head.success) return null;
1072
+ const {
1073
+ hook_event_name: hook,
1074
+ session_id: id,
1075
+ cwd,
1076
+ agent_id: agentId,
1077
+ agent_type: agentType,
1078
+ source,
1079
+ prompt
1080
+ } = head.data;
1081
+ const session = agentId === void 0 ? { id } : { id, agentId };
1082
+ const result = (kind, files = [], completeRead) => ({
1083
+ cwd,
1084
+ event: kind === null ? null : { kind, files, cwd, session, ...completeRead === void 0 ? {} : { completeRead } },
1085
+ warmup: false
1086
+ });
1087
+ switch (hook) {
1088
+ case "PreToolUse": {
1089
+ const tool = fileTool.safeParse(input);
1090
+ if (!tool.success) return result(null);
1091
+ const { tool_name: name } = tool.data;
1092
+ if (name !== "Edit" && name !== "Write") return result(null);
1093
+ const write = writeInput.safeParse(input);
1094
+ if (!write.success) return result(null);
1095
+ const {
1096
+ file_path: file,
1097
+ content,
1098
+ old_string: find,
1099
+ new_string: replace,
1100
+ replace_all: all
1101
+ } = write.data.tool_input;
1102
+ const intent = content !== void 0 ? { content } : find !== void 0 && replace !== void 0 ? { edit: { find, replace, all: all === true } } : null;
1103
+ if (intent === null) return result(null);
1104
+ return { ...result("guard", [file]), event: { kind: "guard", files: [file], cwd, session, intent } };
1105
+ }
1106
+ case "PostToolUse": {
1107
+ const tool = fileTool.safeParse(input);
1108
+ if (!tool.success) return result(null);
1109
+ const { tool_name: name, tool_input: toolInput } = tool.data;
1110
+ if (name === "Edit" || name === "Write") return result("edit", [toolInput.file_path]);
1111
+ if (name !== "Read") return result(null);
1112
+ const read = readResponse.safeParse(input);
1113
+ const file = read.success ? read.data.tool_response.file : null;
1114
+ return result(
1115
+ "touch",
1116
+ [toolInput.file_path],
1117
+ file !== null && file.startLine === 1 && file.numLines === file.totalLines
1118
+ );
1119
+ }
1120
+ case "Stop":
1121
+ return result("verify");
1122
+ case "SubagentStop":
1123
+ return result(agentType === "" ? null : "verify");
1124
+ case "UserPromptSubmit":
1125
+ return result(prompt?.startsWith("<task-notification>") ? null : "prompt");
1126
+ case "SessionStart":
1127
+ if (source === "compact") return result("reset");
1128
+ return { ...result(null), warmup: source === "startup" || source === "resume" };
1129
+ default:
1130
+ return result(null);
1131
+ }
1132
+ }
1133
+
1134
+ // src/adapters/claude-code/settings.ts
1135
+ var SettingsError = class extends Error {
1136
+ };
1137
+ var RULECAST_HOOK = /\brulecast\b.*\bhook claude-code\b/;
1138
+ var isObject3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1139
+ var isRulecastHook = (hook) => isObject3(hook) && typeof hook.command === "string" && RULECAST_HOOK.test(hook.command);
1140
+ function hookGroups(verifyMs) {
1141
+ const verifyTimeout = Math.ceil(verifyMs / 1e3) + 10;
1142
+ return [
1143
+ // Before the write, for refuse_write rules. It answers nothing at all unless the project has
1144
+ // one, so the cost on an ordinary edit is starting rulecast and compiling the config.
1145
+ { event: "PreToolUse", matcher: "Edit|Write", timeout: 5 },
1146
+ { event: "PostToolUse", matcher: "Read", timeout: 5 },
1147
+ { event: "PostToolUse", matcher: "Edit|Write", timeout: 5 },
1148
+ { event: "Stop", timeout: verifyTimeout },
1149
+ { event: "SubagentStop", timeout: verifyTimeout },
1150
+ { event: "UserPromptSubmit", timeout: 5 },
1151
+ { event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 }
1152
+ ];
1153
+ }
1154
+ function installed(groups, matcher2) {
1155
+ return groups.some(
1156
+ (group) => isObject3(group) && group.matcher === matcher2 && Array.isArray(group.hooks) && group.hooks.some(isRulecastHook)
1157
+ );
1158
+ }
1159
+ function hooksOf(settings) {
1160
+ if (!isObject3(settings)) throw new SettingsError("settings must be a JSON object");
1161
+ const hooks = settings.hooks ?? {};
1162
+ if (!isObject3(hooks)) throw new SettingsError('"hooks" must be an object');
1163
+ return hooks;
1164
+ }
1165
+ function mergeHooks(settings, command, verifyMs) {
1166
+ const merged = { ...hooksOf(settings) };
1167
+ const added = [];
1168
+ for (const { event, matcher: matcher2, timeout } of hookGroups(verifyMs)) {
1169
+ const groups = merged[event] ?? [];
1170
+ if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
1171
+ if (installed(groups, matcher2)) continue;
1172
+ const hook = { type: "command", command, timeout };
1173
+ merged[event] = [...groups, matcher2 === void 0 ? { hooks: [hook] } : { matcher: matcher2, hooks: [hook] }];
1174
+ added.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
1175
+ }
1176
+ return { settings: { ...settings, hooks: merged }, added };
1177
+ }
1178
+ function removeHooks(settings) {
1179
+ const hooks = hooksOf(settings);
1180
+ const source = settings;
1181
+ const kept = {};
1182
+ const removed = [];
1183
+ for (const [event, groups] of Object.entries(hooks)) {
1184
+ if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
1185
+ const remaining = [];
1186
+ for (const group of groups) {
1187
+ if (!isObject3(group) || !Array.isArray(group.hooks) || !group.hooks.some(isRulecastHook)) {
1188
+ remaining.push(group);
1189
+ continue;
1190
+ }
1191
+ removed.push(typeof group.matcher === "string" ? `${event} (${group.matcher})` : event);
1192
+ const others = group.hooks.filter((hook) => !isRulecastHook(hook));
1193
+ if (others.length > 0) remaining.push({ ...group, hooks: others });
1194
+ }
1195
+ if (remaining.length > 0 || groups.length === 0) kept[event] = remaining;
1196
+ }
1197
+ if (removed.length === 0) return { settings: source, removed };
1198
+ const result = {};
1199
+ for (const [key, value] of Object.entries(source)) {
1200
+ if (key !== "hooks") result[key] = value;
1201
+ else if (Object.keys(kept).length > 0) result[key] = kept;
1202
+ }
1203
+ return { settings: result, removed };
1204
+ }
1205
+
1206
+ // src/adapters/claude-code/adapter.ts
1207
+ var CONTEXT_LIMIT = 1e4;
1208
+ var CONTEXT_BUDGET = 9e3;
1209
+ var BLOCK_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish.";
1210
+ var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
1211
+ var DENY_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) refuse this write. Change what you are writing, or the file you are writing it to, and try again.";
1212
+ function cutNotice(delivery, event) {
1213
+ if (delivery.overflowPath !== null) {
1214
+ return `
1215
+
1216
+ \u2026cut to fit Claude Code's hook output limit. The whole delivery is in ${delivery.overflowPath}.`;
1217
+ }
1218
+ const session = event.session ? `--session ${event.session.id} ` : "";
1219
+ return `
1220
+
1221
+ \u2026cut to fit Claude Code's hook output limit. Run \`rulecast run ${session}--format agent\` for the full list.`;
1222
+ }
1223
+ function withinLimit(text2, delivery, event) {
1224
+ if (text2.length <= CONTEXT_LIMIT) return text2;
1225
+ const notice = cutNotice(delivery, event);
1226
+ const room = CONTEXT_LIMIT - notice.length;
1227
+ const cut = text2.slice(0, room);
1228
+ const lastLine = cut.lastIndexOf("\n");
1229
+ return (lastLine > room / 2 ? cut.slice(0, lastLine) : cut.trimEnd()) + notice;
1230
+ }
1231
+ var NONE = { stdout: "", exitCode: 0 };
1232
+ var json2 = (value) => ({ stdout: JSON.stringify(value), exitCode: 0 });
1233
+ var claudeCodeAdapter = {
1234
+ name: "claude-code",
1235
+ label: "Claude Code",
1236
+ maxContextChars: CONTEXT_BUDGET,
1237
+ /** Recorded from 2.1.278: /compact re-attaches the 5 most recently read, edited or written files. */
1238
+ restoredFiles: 5,
1239
+ parse: parseClaudeCode,
1240
+ format(delivery, event, options) {
1241
+ const text2 = renderAgentText(delivery, options);
1242
+ if (text2 === "") return NONE;
1243
+ switch (event.kind) {
1244
+ case "guard":
1245
+ return json2({
1246
+ hookSpecificOutput: {
1247
+ hookEventName: "PreToolUse",
1248
+ permissionDecision: "deny",
1249
+ permissionDecisionReason: withinLimit(`${DENY_PREAMBLE}
1250
+
1251
+ ${text2}`, delivery, event)
1252
+ }
1253
+ });
1254
+ case "touch":
1255
+ case "edit":
1256
+ return json2({
1257
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
1258
+ });
1259
+ case "reset":
1260
+ return json2({
1261
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: withinLimit(text2, delivery, event) }
1262
+ });
1263
+ case "verify":
1264
+ if (delivery.stop === "block")
1265
+ return json2({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
1266
+
1267
+ ${text2}`, delivery, event) });
1268
+ if (delivery.stop === "capReached")
1269
+ return json2({ systemMessage: withinLimit(`${CAP_PREAMBLE}
1270
+
1271
+ ${text2}`, delivery, event) });
1272
+ return NONE;
1273
+ default:
1274
+ return NONE;
1275
+ }
1276
+ },
1277
+ install: {
1278
+ markers: [".claude/", "CLAUDE.md"],
1279
+ scopes: [
1280
+ { scope: "shared", file: ".claude/settings.json" },
1281
+ { scope: "personal", file: ".claude/settings.local.json" }
1282
+ ],
1283
+ command: (local) => local ? '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast hook claude-code' : "rulecast hook claude-code",
1284
+ merge: mergeHooks,
1285
+ remove: removeHooks
1286
+ }
1287
+ };
1288
+
1289
+ // src/adapters/index.ts
1290
+ var ADAPTERS = [claudeCodeAdapter];
1291
+ function adapterByName(name) {
1292
+ return ADAPTERS.find((adapter) => adapter.name === name);
1293
+ }
1294
+
1295
+ export {
1296
+ createRegistry,
1297
+ builtinDetectors,
1298
+ claudeCodeAdapter,
1299
+ ADAPTERS,
1300
+ adapterByName
1301
+ };