@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.
@@ -1,3780 +0,0 @@
1
- // src/core/detection/registry.ts
2
- function createRegistry(detectors) {
3
- const byKind = /* @__PURE__ */ new Map();
4
- for (const detector of detectors) {
5
- if (byKind.has(detector.kind)) throw new Error(`detector "${detector.kind}" registered twice`);
6
- byKind.set(detector.kind, detector);
7
- }
8
- return {
9
- get: (kind) => byKind.get(kind),
10
- kinds: () => [...byKind.keys()]
11
- };
12
- }
13
-
14
- // src/core/detection/per-rule.ts
15
- import { readFile } from "fs/promises";
16
- import path from "path";
17
-
18
- // src/core/errors.ts
19
- function isNotFound(error) {
20
- return error instanceof Error && error.code === "ENOENT";
21
- }
22
- function errorMessage(error) {
23
- return error instanceof Error ? error.message : String(error);
24
- }
25
- function formatZodError(error) {
26
- return error.issues.map((issue) => `${issue.path.length ? issue.path.join(".") : "(root)"}: ${issue.message}`).join("; ");
27
- }
28
-
29
- // src/core/detection/per-rule.ts
30
- function perRule(detect) {
31
- return async (input) => {
32
- const result = { findings: [], errors: [] };
33
- await Promise.all(
34
- input.rules.map(async (rule) => {
35
- try {
36
- for (const match of await detect(rule, input)) result.findings.push({ rule: rule.id, match });
37
- } catch (error) {
38
- if (input.signal.aborted) throw error;
39
- result.errors.push({ rule: rule.id, message: errorMessage(error) });
40
- }
41
- })
42
- );
43
- return result;
44
- };
45
- }
46
- async function readSourceFile(cwd, file) {
47
- try {
48
- return await readFile(path.resolve(cwd, file), "utf8");
49
- } catch (error) {
50
- if (isNotFound(error)) return null;
51
- throw error;
52
- }
53
- }
54
-
55
- // src/detectors/llm/models.ts
56
- var ALIASES = {
57
- haiku: {
58
- "claude-code": "haiku",
59
- anthropic: "claude-haiku-4-5-20251001",
60
- opencode: "anthropic/claude-haiku-4-5-20251001"
61
- },
62
- sonnet: { "claude-code": "sonnet", anthropic: "claude-sonnet-5", opencode: "anthropic/claude-sonnet-5" },
63
- opus: { "claude-code": "opus", anthropic: "claude-opus-5", opencode: "anthropic/claude-opus-5" },
64
- fable: { "claude-code": "fable", anthropic: "claude-fable-5-1", opencode: "anthropic/claude-fable-5-1" }
65
- };
66
- var MODEL_ALIASES = Object.keys(ALIASES);
67
- function resolveModel(model, provider) {
68
- const alias = ALIASES[model];
69
- if (alias === void 0) return model;
70
- return alias[provider] ?? null;
71
- }
72
-
73
- // src/detectors/llm/providers/types.ts
74
- import { z } from "zod";
75
- var responseSchema = z.object({
76
- findings: z.array(
77
- z.object({
78
- rule: z.string(),
79
- line: z.number().int().positive(),
80
- /** The model's quote of the line. Asked for because quoting sharpens the line number; never rendered. */
81
- text: z.string().optional(),
82
- reason: z.string()
83
- })
84
- )
85
- });
86
- var FINDINGS_SCHEMA = {
87
- type: "object",
88
- properties: {
89
- findings: {
90
- type: "array",
91
- items: {
92
- type: "object",
93
- properties: {
94
- rule: { type: "string" },
95
- line: { type: "integer" },
96
- text: { type: "string" },
97
- reason: { type: "string" }
98
- },
99
- required: ["rule", "line", "reason"]
100
- }
101
- }
102
- },
103
- required: ["findings"]
104
- };
105
- var LlmUnavailableError = class extends Error {
106
- };
107
-
108
- // src/detectors/llm/providers/extract.ts
109
- function extractFindingsJson(text2) {
110
- let found = null;
111
- for (let start = text2.indexOf("{"); start !== -1; start = text2.indexOf("{", start + 1)) {
112
- const end = objectEnd(text2, start);
113
- if (end === -1) continue;
114
- let parsed;
115
- try {
116
- parsed = JSON.parse(text2.slice(start, end));
117
- } catch {
118
- continue;
119
- }
120
- if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.findings)) {
121
- found = parsed;
122
- start = end - 1;
123
- }
124
- }
125
- return found;
126
- }
127
- function objectEnd(text2, start) {
128
- let depth = 0;
129
- let inString = false;
130
- for (let i = start; i < text2.length; i++) {
131
- const char = text2[i];
132
- if (inString) {
133
- if (char === "\\") i++;
134
- else if (char === '"') inString = false;
135
- continue;
136
- }
137
- if (char === '"') inString = true;
138
- else if (char === "{") depth++;
139
- else if (char === "}" && --depth === 0) return i + 1;
140
- }
141
- return -1;
142
- }
143
-
144
- // src/detectors/llm/providers/http.ts
145
- function endpoint(baseUrl, fallback, path21) {
146
- return `${(baseUrl ?? fallback).replace(/\/+$/, "")}${path21}`;
147
- }
148
- function apiKey(request) {
149
- const key = request.env[request.settings.apiKeyEnv];
150
- if (!key) {
151
- throw new LlmUnavailableError(
152
- `${request.settings.apiKeyEnv} is not set; set it, or use the claude-code provider, which needs no API key`
153
- );
154
- }
155
- return key;
156
- }
157
- async function postJson(url, headers, body, signal) {
158
- const response = await fetch(url, {
159
- method: "POST",
160
- headers: { "content-type": "application/json", ...headers },
161
- body: JSON.stringify(body),
162
- signal
163
- });
164
- if (!response.ok) {
165
- const detail = (await response.text().catch(() => "")).slice(0, 200);
166
- const message = `${url} returned ${response.status}: ${detail}`;
167
- if (response.status === 401 || response.status === 403) throw new LlmUnavailableError(message);
168
- throw new Error(message);
169
- }
170
- return response.json();
171
- }
172
- function keyAvailability(settings, env, fallback, path21) {
173
- return env[settings.apiKeyEnv] ? { ok: true, detail: endpoint(settings.baseUrl, fallback, path21) } : { ok: false, detail: `$${settings.apiKeyEnv} is not set` };
174
- }
175
-
176
- // src/detectors/llm/providers/anthropic.ts
177
- var API = "https://api.anthropic.com";
178
- var VERSION = "2023-06-01";
179
- var anthropicProvider = {
180
- name: "anthropic",
181
- async ask(request) {
182
- const key = apiKey(request);
183
- const reply = await postJson(
184
- endpoint(request.settings.baseUrl, API, "/v1/messages"),
185
- { "x-api-key": key, "anthropic-version": VERSION },
186
- {
187
- model: request.model,
188
- max_tokens: 4096,
189
- messages: [{ role: "user", content: request.prompt }]
190
- },
191
- request.signal
192
- );
193
- const parts = reply.content ?? [];
194
- const text2 = parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
195
- const scanned = responseSchema.safeParse(extractFindingsJson(text2));
196
- if (scanned.success) return scanned.data.findings;
197
- throw new Error(`the anthropic API returned no usable JSON: ${text2.trim().slice(0, 200)}`);
198
- },
199
- available: async ({ settings, env }) => keyAvailability(settings, env, API, "/v1/messages")
200
- };
201
-
202
- // src/detectors/llm/providers/cli.ts
203
- import { spawn } from "child_process";
204
- import { access, constants } from "fs/promises";
205
- import path2 from "path";
206
-
207
- // src/core/which.ts
208
- import { execFile } from "child_process";
209
- import { promisify } from "util";
210
- var exec = promisify(execFile);
211
- async function onPath(command, signal) {
212
- try {
213
- await exec("/bin/sh", ["-c", 'command -v "$1" > /dev/null', "sh", command], { signal });
214
- return true;
215
- } catch {
216
- return false;
217
- }
218
- }
219
-
220
- // src/detectors/llm/providers/cli.ts
221
- async function resolveCli(name, root) {
222
- const local = path2.join(root, "node_modules", ".bin", name);
223
- try {
224
- await access(local, constants.X_OK);
225
- return local;
226
- } catch {
227
- return name;
228
- }
229
- }
230
- function runCli(command, args, options) {
231
- return new Promise((resolve, reject) => {
232
- const child = spawn(command, args, {
233
- cwd: options.cwd,
234
- env: options.env,
235
- signal: options.signal,
236
- stdio: ["pipe", "pipe", "pipe"]
237
- });
238
- const stdout = [];
239
- const stderr = [];
240
- child.stdout.on("data", (chunk) => stdout.push(chunk));
241
- child.stderr.on("data", (chunk) => stderr.push(chunk));
242
- child.stdin.on("error", () => {
243
- });
244
- child.stdin.end(options.input);
245
- child.on("error", (error) => {
246
- reject(isNotFound(error) ? new LlmUnavailableError(`${command} is not installed`) : error);
247
- });
248
- child.on("close", (code) => {
249
- resolve({
250
- stdout: Buffer.concat(stdout).toString("utf8"),
251
- stderr: Buffer.concat(stderr).toString("utf8"),
252
- code
253
- });
254
- });
255
- });
256
- }
257
- async function cliAvailable(name, cwd, signal) {
258
- const resolved = await resolveCli(name, cwd);
259
- if (resolved !== name) return { ok: true, detail: resolved };
260
- return await onPath(name, signal) ? { ok: true, detail: `${name} (PATH)` } : { ok: false, detail: `${name} is not installed` };
261
- }
262
-
263
- // src/detectors/llm/providers/claude-code.ts
264
- var SYSTEM_PROMPT = "You check one source file against a list of rules and answer only with the structured result. You never edit files, run commands or ask questions.";
265
- var claudeCodeProvider = {
266
- name: "claude-code",
267
- async ask(request) {
268
- const command = await resolveCli("claude", request.cwd);
269
- const args = [
270
- "-p",
271
- "--model",
272
- request.model,
273
- "--output-format",
274
- "json",
275
- "--json-schema",
276
- JSON.stringify(FINDINGS_SCHEMA),
277
- "--system-prompt",
278
- SYSTEM_PROMPT,
279
- "--tools",
280
- "",
281
- "--restricted",
282
- "--strict-mcp-config",
283
- "--no-session-persistence"
284
- ];
285
- const result = await runCli(command, args, {
286
- cwd: request.cwd,
287
- input: request.prompt,
288
- env: request.env,
289
- signal: request.signal
290
- });
291
- const envelope = parseJson(result.stdout);
292
- if (isRecord(envelope)) {
293
- if (envelope.is_error === true) {
294
- throw new Error(`claude reported an error: ${firstLine(String(envelope.result ?? result.stderr))}`);
295
- }
296
- const structured = responseSchema.safeParse(envelope.structured_output);
297
- if (structured.success) return structured.data.findings;
298
- if (typeof envelope.result === "string") {
299
- const fromResult = responseSchema.safeParse(parseJson(envelope.result));
300
- if (fromResult.success) return fromResult.data.findings;
301
- const scannedResult = responseSchema.safeParse(extractFindingsJson(envelope.result));
302
- if (scannedResult.success) return scannedResult.data.findings;
303
- }
304
- }
305
- const scanned = responseSchema.safeParse(extractFindingsJson(result.stdout));
306
- if (scanned.success) return scanned.data.findings;
307
- throw new Error(
308
- `claude returned no usable JSON (exit ${result.code}): ${firstLine(result.stderr || result.stdout)}`
309
- );
310
- },
311
- available: ({ cwd, signal }) => cliAvailable("claude", cwd, signal)
312
- };
313
- function parseJson(text2) {
314
- try {
315
- return JSON.parse(text2);
316
- } catch {
317
- return void 0;
318
- }
319
- }
320
- function isRecord(value) {
321
- return typeof value === "object" && value !== null;
322
- }
323
- function firstLine(text2) {
324
- return text2.trim().split("\n")[0]?.slice(0, 200) ?? "";
325
- }
326
-
327
- // src/detectors/llm/providers/openai.ts
328
- var API2 = "https://api.openai.com/v1";
329
- var openaiCompatibleProvider = {
330
- name: "openai-compatible",
331
- async ask(request) {
332
- const key = apiKey(request);
333
- const reply = await postJson(
334
- endpoint(request.settings.baseUrl, API2, "/chat/completions"),
335
- { authorization: `Bearer ${key}` },
336
- { model: request.model, messages: [{ role: "user", content: request.prompt }] },
337
- request.signal
338
- );
339
- const content = reply.choices?.[0]?.message?.content;
340
- const text2 = typeof content === "string" ? content : "";
341
- const scanned = responseSchema.safeParse(extractFindingsJson(text2));
342
- if (scanned.success) return scanned.data.findings;
343
- throw new Error(`the ${request.settings.provider} API returned no usable JSON: ${text2.trim().slice(0, 200)}`);
344
- },
345
- available: async ({ settings, env }) => keyAvailability(settings, env, API2, "/chat/completions")
346
- };
347
-
348
- // src/detectors/llm/providers/opencode.ts
349
- var PROMPT_LIMIT_BYTES = 128 * 1024;
350
- var opencodeProvider = {
351
- name: "opencode",
352
- async ask(request) {
353
- const size = Buffer.byteLength(request.prompt, "utf8");
354
- if (size > PROMPT_LIMIT_BYTES) {
355
- throw new Error(
356
- `prompt is too large for the opencode provider: ${size} bytes against a limit of ${PROMPT_LIMIT_BYTES}. Narrow the rule's files, or use a provider that takes the prompt on stdin.`
357
- );
358
- }
359
- const command = await resolveCli("opencode", request.cwd);
360
- const result = await runCli(command, ["run", "--model", request.model, "--format", "default", request.prompt], {
361
- cwd: request.cwd,
362
- input: "",
363
- env: request.env,
364
- signal: request.signal
365
- });
366
- const scanned = responseSchema.safeParse(extractFindingsJson(result.stdout));
367
- if (scanned.success) return scanned.data.findings;
368
- throw new Error(
369
- `opencode returned no usable JSON (exit ${result.code}): ${firstLine2(result.stderr || result.stdout)}`
370
- );
371
- },
372
- available: ({ cwd, signal }) => cliAvailable("opencode", cwd, signal)
373
- };
374
- function firstLine2(text2) {
375
- return text2.trim().split("\n")[0]?.slice(0, 200) ?? "";
376
- }
377
-
378
- // src/detectors/llm/providers/index.ts
379
- var PROVIDERS = {
380
- "claude-code": claudeCodeProvider,
381
- opencode: opencodeProvider,
382
- anthropic: anthropicProvider,
383
- "openai-compatible": openaiCompatibleProvider
384
- };
385
- function providerByName(name) {
386
- const provider = PROVIDERS[name];
387
- if (!provider) throw new LlmUnavailableError(`the ${name} provider is not available in this build`);
388
- return provider;
389
- }
390
-
391
- // src/detectors/ast-grep/languages.ts
392
- var LANGUAGES = {
393
- css: { builtin: "Css" },
394
- html: { builtin: "Html" },
395
- javascript: { builtin: "JavaScript" },
396
- python: { load: () => import("@ast-grep/lang-python") },
397
- tsx: { builtin: "Tsx" },
398
- typescript: { builtin: "TypeScript" }
399
- };
400
- var LANGUAGE_NAMES = Object.keys(LANGUAGES);
401
- function napiLanguage(language) {
402
- const entry = LANGUAGES[language];
403
- return "builtin" in entry ? entry.builtin : language;
404
- }
405
-
406
- // src/detectors/ast-grep/load.ts
407
- var loading = null;
408
- async function load() {
409
- loading ??= (async () => {
410
- const napi = await import("@ast-grep/napi");
411
- const dynamic = {};
412
- for (const [name, entry] of Object.entries(LANGUAGES)) {
413
- if (!("load" in entry)) continue;
414
- const module = await entry.load();
415
- dynamic[name] = module.default ?? module;
416
- }
417
- if (Object.keys(dynamic).length > 0) napi.registerDynamicLanguage(dynamic);
418
- return napi;
419
- })();
420
- return loading;
421
- }
422
- var AstGrepUnavailable = class extends Error {
423
- constructor(cause) {
424
- super(`ast-grep is unavailable: ${errorMessage(cause)}`);
425
- }
426
- };
427
- async function parserFor(language) {
428
- let napi;
429
- try {
430
- napi = await load();
431
- } catch (error) {
432
- loading = null;
433
- throw new AstGrepUnavailable(error);
434
- }
435
- const name = napiLanguage(language);
436
- return { parse: (source) => napi.parse(name, source) };
437
- }
438
-
439
- // src/detectors/ast-grep/metavars.ts
440
- var METAVARIABLE = /\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)/g;
441
- function scan(value, into) {
442
- if (typeof value === "string") {
443
- for (const found of value.matchAll(METAVARIABLE)) {
444
- const multi = found[1] !== void 0;
445
- const name = found[1] ?? found[2];
446
- if (name.startsWith("_")) continue;
447
- into.set(name, (into.get(name) ?? false) || multi);
448
- }
449
- return;
450
- }
451
- if (Array.isArray(value)) {
452
- for (const item of value) scan(item, into);
453
- return;
454
- }
455
- if (typeof value === "object" && value !== null) {
456
- for (const item of Object.values(value)) scan(item, into);
457
- }
458
- }
459
- function metavariables(config) {
460
- const found = /* @__PURE__ */ new Map();
461
- scan(config, found);
462
- return [...found].map(([name, multi]) => ({ name, multi }));
463
- }
464
-
465
- // src/detectors/ast-grep/schema.ts
466
- import { z as z2 } from "zod";
467
- var ruleObject = z2.record(z2.unknown()).refine((rule) => Object.keys(rule).length > 0, "rule must not be empty");
468
- var astGrepSchema = z2.object({
469
- language: z2.enum(LANGUAGE_NAMES),
470
- rule: ruleObject,
471
- constraints: z2.record(ruleObject).optional(),
472
- utils: z2.record(ruleObject).optional()
473
- }).strict().superRefine(async (config, ctx) => {
474
- try {
475
- const parser = await parserFor(config.language);
476
- parser.parse("").root().findAll(matcher(config));
477
- } catch (error) {
478
- ctx.addIssue({ code: "custom", path: ["rule"], message: oneLine(errorMessage(error)) });
479
- }
480
- });
481
- function oneLine(message) {
482
- return message.split("\n").map((line) => line.replace(/^\s*\|->\s*/, "").trim()).filter(Boolean).join(" ");
483
- }
484
- function matcher(config) {
485
- const { language: _language, ...rest } = config;
486
- return rest;
487
- }
488
- function captureNames(config) {
489
- return metavariables(matcher(config)).map((variable) => variable.name);
490
- }
491
-
492
- // src/detectors/ast-grep/detector.ts
493
- function capturesOf(node, variables) {
494
- const captures = {};
495
- for (const variable of variables) {
496
- captures[variable.name] = variable.multi ? node.getMultipleMatches(variable.name).filter((match) => match.isNamed()).map((match) => match.text()).join(", ") : node.getMatch(variable.name)?.text() ?? "";
497
- }
498
- return captures;
499
- }
500
- function matchOf(file, node, variables) {
501
- const range = node.range();
502
- return {
503
- file,
504
- line: range.start.line + 1,
505
- endLine: range.end.line + 1,
506
- column: range.start.column + 1,
507
- text: node.text(),
508
- captures: capturesOf(node, variables)
509
- };
510
- }
511
- var astGrepDetector = {
512
- kind: "ast-grep",
513
- schema: astGrepSchema,
514
- captures: captureNames,
515
- events: () => ["edit", "verify"],
516
- guards: true,
517
- async run(input) {
518
- const result = { findings: [], errors: [] };
519
- const byLanguage = /* @__PURE__ */ new Map();
520
- for (const rule of input.rules) {
521
- byLanguage.set(rule.config.language, [...byLanguage.get(rule.config.language) ?? [], rule]);
522
- }
523
- await Promise.all(
524
- [...byLanguage].map(async ([language, rules]) => {
525
- let parser;
526
- try {
527
- parser = await parserFor(language);
528
- } catch (error) {
529
- for (const rule of rules) result.errors.push({ rule: rule.id, message: errorMessage(error) });
530
- return;
531
- }
532
- const variables = new Map(rules.map((rule) => [rule.id, metavariables(matcher(rule.config))]));
533
- const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
534
- for (const file of files) {
535
- input.signal.throwIfAborted();
536
- let root;
537
- try {
538
- const source = await input.read(file);
539
- if (source === null) continue;
540
- root = parser.parse(source).root();
541
- } catch (error) {
542
- if (input.signal.aborted) throw error;
543
- const message = errorMessage(error);
544
- for (const rule of rules) {
545
- if (rule.files.includes(file) && !result.errors.some((existing) => existing.rule === rule.id)) {
546
- result.errors.push({ rule: rule.id, message });
547
- }
548
- }
549
- continue;
550
- }
551
- for (const rule of rules) {
552
- if (!rule.files.includes(file)) continue;
553
- try {
554
- for (const node of root.findAll(matcher(rule.config))) {
555
- result.findings.push({ rule: rule.id, match: matchOf(file, node, variables.get(rule.id)) });
556
- }
557
- } catch (error) {
558
- if (input.signal.aborted) throw error;
559
- if (!result.errors.some((existing) => existing.rule === rule.id)) {
560
- result.errors.push({ rule: rule.id, message: errorMessage(error) });
561
- }
562
- }
563
- }
564
- }
565
- })
566
- );
567
- return result;
568
- },
569
- async check(input) {
570
- const languages = [...new Set(input.rules.map((rule) => rule.config.language))].sort();
571
- for (const language of languages) await parserFor(language);
572
- return [{ what: "ast-grep", level: "ok", detail: languages.join(", "), rules: [] }];
573
- }
574
- };
575
-
576
- // src/detectors/command/detector.ts
577
- import { execFile as execFile2 } from "child_process";
578
- import { access as access2, constants as constants2 } from "fs/promises";
579
- import path4 from "path";
580
- import { promisify as promisify2 } from "util";
581
-
582
- // src/detectors/command/results.ts
583
- import { realpathSync } from "fs";
584
- import path3 from "path";
585
- import { fileURLToPath } from "url";
586
- var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
587
- function parseJson2(output) {
588
- try {
589
- return JSON.parse(output);
590
- } catch {
591
- throw new Error("output is not JSON");
592
- }
593
- }
594
- function repoRelative(file, cwd) {
595
- const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
596
- if (!path3.isAbsolute(withoutScheme)) return withoutScheme.split(path3.sep).join("/");
597
- for (const root of rootsOf(cwd)) {
598
- const relative = path3.relative(root, withoutScheme);
599
- if (!relative.startsWith("..") && !path3.isAbsolute(relative)) return relative.split(path3.sep).join("/");
600
- }
601
- return path3.relative(cwd, withoutScheme).split(path3.sep).join("/");
602
- }
603
- function rootsOf(root) {
604
- try {
605
- const real = realpathSync(root);
606
- return real === root ? [root] : [root, real];
607
- } catch {
608
- return [root];
609
- }
610
- }
611
- function capturesOf2(source, names, where) {
612
- const captures = {};
613
- for (const name of names) {
614
- const value = source[name];
615
- if (typeof value !== "string") throw new Error(`${where}: capture "${name}" is not a string`);
616
- captures[name] = value;
617
- }
618
- return captures;
619
- }
620
- function matchesFromJson(output, captures, cwd) {
621
- const parsed = parseJson2(output);
622
- if (!Array.isArray(parsed)) throw new Error("output is not a JSON array of results");
623
- return parsed.map((result, index) => {
624
- const where = `result ${index + 1}`;
625
- if (!isObject(result)) throw new Error(`${where}: not an object`);
626
- if (typeof result.file !== "string") throw new Error(`${where}: "file" must be a string`);
627
- if (typeof result.line !== "number") throw new Error(`${where}: "line" must be a number`);
628
- return {
629
- file: repoRelative(result.file, cwd),
630
- line: result.line,
631
- endLine: typeof result.endLine === "number" ? result.endLine : result.line,
632
- column: typeof result.column === "number" ? result.column : 1,
633
- text: typeof result.text === "string" ? result.text : "",
634
- captures: capturesOf2(result, captures, where)
635
- };
636
- });
637
- }
638
- function matchesFromSarif(output, captures, cwd) {
639
- const parsed = parseJson2(output);
640
- if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error("output is not SARIF 2.1.0");
641
- const matches = [];
642
- let index = 0;
643
- for (const run2 of parsed.runs) {
644
- if (!isObject(run2) || !Array.isArray(run2.results)) throw new Error("output is not SARIF 2.1.0");
645
- for (const result of run2.results) {
646
- index += 1;
647
- const where = `result ${index}`;
648
- if (!isObject(result)) throw new Error(`${where}: not an object`);
649
- const location = Array.isArray(result.locations) ? result.locations[0] : void 0;
650
- const physical = isObject(location) ? location.physicalLocation : void 0;
651
- const artifact = isObject(physical) ? physical.artifactLocation : void 0;
652
- if (!isObject(artifact) || typeof artifact.uri !== "string") continue;
653
- const region = isObject(physical) && isObject(physical.region) ? physical.region : {};
654
- const line = typeof region.startLine === "number" ? region.startLine : 1;
655
- const message = isObject(result.message) && typeof result.message.text === "string" ? result.message.text : "";
656
- const source = isObject(result.properties) ? { ...result, ...result.properties } : result;
657
- matches.push({
658
- file: repoRelative(artifact.uri, cwd),
659
- line,
660
- endLine: typeof region.endLine === "number" ? region.endLine : line,
661
- column: typeof region.startColumn === "number" ? region.startColumn : 1,
662
- text: message,
663
- captures: capturesOf2(source, captures, where)
664
- });
665
- }
666
- }
667
- return matches;
668
- }
669
-
670
- // src/detectors/command/schema.ts
671
- import { z as z3 } from "zod";
672
-
673
- // src/core/template.ts
674
- var VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
675
- var CORE_VARIABLES = ["file", "line", "column", "text", "rule"];
676
- var UnknownTemplateVariable = class extends Error {
677
- constructor(variable) {
678
- super(`template variable "${variable}" has no value`);
679
- this.variable = variable;
680
- }
681
- variable;
682
- };
683
- function templateVariables(template) {
684
- return [...new Set([...template.matchAll(VARIABLE)].map((match) => match[1]))];
685
- }
686
- function oneLine2(value) {
687
- return value.replace(/\s+/g, " ").trim();
688
- }
689
- function renderTemplate(template, values) {
690
- return template.replace(VARIABLE, (_, name) => {
691
- const value = values[name];
692
- if (value === void 0) throw new UnknownTemplateVariable(name);
693
- return oneLine2(value);
694
- });
695
- }
696
- function templateSkeleton(template) {
697
- return template.replace(VARIABLE, (_, name) => `{${name}}`);
698
- }
699
- function templateLiteralLength(template) {
700
- return oneLine2(template.replace(VARIABLE, "")).length;
701
- }
702
- var LOCATION_VARIABLES = /* @__PURE__ */ new Set(["file", "line", "column", "rule"]);
703
- function templateBindings(template) {
704
- return templateVariables(template).filter((name) => !LOCATION_VARIABLES.has(name));
705
- }
706
-
707
- // src/detectors/command/schema.ts
708
- var VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
709
- var commandSchema = z3.object({
710
- /** argv. The element exactly "{{files}}" is replaced by the rule's files; otherwise they are appended. */
711
- run: z3.array(z3.string().min(1)).min(1),
712
- output: z3.enum(["json", "sarif"]).default("json"),
713
- captures: z3.array(z3.string().regex(VARIABLE_NAME, "a capture must be a template variable name")).default([]).superRefine((captures, ctx) => {
714
- for (const name of captures) {
715
- if (CORE_VARIABLES.includes(name)) {
716
- ctx.addIssue({ code: "custom", message: `"${name}" is a core template variable` });
717
- }
718
- }
719
- })
720
- }).strict();
721
-
722
- // src/detectors/command/detector.ts
723
- var exec2 = promisify2(execFile2);
724
- var FILES = "{{files}}";
725
- function argvFor(run2, files) {
726
- if (!run2.includes(FILES)) return [...run2, ...files];
727
- return run2.flatMap((argument) => argument === FILES ? files : [argument]);
728
- }
729
- async function reachable(command, cwd, signal) {
730
- if (!command.includes("/")) {
731
- return await onPath(command, signal) ? { level: "ok", detail: `${command} (PATH)` } : { level: "error", detail: "not installed" };
732
- }
733
- const full = path4.resolve(cwd, command);
734
- try {
735
- await access2(full, constants2.F_OK);
736
- } catch {
737
- return { level: "error", detail: "no such file" };
738
- }
739
- try {
740
- await access2(full, constants2.X_OK);
741
- } catch {
742
- return { level: "error", detail: "not executable" };
743
- }
744
- return { level: "ok", detail: full };
745
- }
746
- var commandDetector = {
747
- kind: "command",
748
- schema: commandSchema,
749
- captures: (config) => config.captures,
750
- events: () => ["edit", "verify"],
751
- run: perRule(async (rule, input) => {
752
- if (rule.files.length === 0) return [];
753
- const [command, ...rest] = argvFor(rule.config.run, rule.files);
754
- let stdout;
755
- try {
756
- const result = await exec2(command, rest, { cwd: input.cwd, signal: input.signal, maxBuffer: 64 * 1024 * 1024 });
757
- stdout = result.stdout;
758
- } catch (error) {
759
- if (input.signal.aborted) throw error;
760
- if (isNotFound(error)) throw new Error(`command not found: ${command}`);
761
- const failure = error;
762
- if (typeof failure.stdout !== "string") throw error;
763
- stdout = failure.stdout;
764
- }
765
- return rule.config.output === "sarif" ? matchesFromSarif(stdout, rule.config.captures, input.cwd) : matchesFromJson(stdout, rule.config.captures, input.cwd);
766
- }),
767
- async check(input) {
768
- const byCommand = /* @__PURE__ */ new Map();
769
- for (const rule of input.rules) {
770
- const command = rule.config.run[0];
771
- byCommand.set(command, [...byCommand.get(command) ?? [], rule.id]);
772
- }
773
- return Promise.all(
774
- [...byCommand].map(async ([command, rules]) => {
775
- const { level, detail } = await reachable(command, input.cwd, input.signal);
776
- return { what: command, level, detail, rules: level === "ok" ? [] : rules };
777
- })
778
- );
779
- }
780
- };
781
-
782
- // src/detectors/linter/detector.ts
783
- import { execFile as execFile3 } from "child_process";
784
- import { promisify as promisify3 } from "util";
785
-
786
- // src/core/detection/positions.ts
787
- function lineStarts(text2) {
788
- const starts = [0];
789
- for (let i = 0; i < text2.length; i++) {
790
- if (text2.charCodeAt(i) === 10) starts.push(i + 1);
791
- }
792
- return starts;
793
- }
794
- function positionAt(starts, offset) {
795
- let low = 0;
796
- let high = starts.length - 1;
797
- while (low < high) {
798
- const mid = low + high + 1 >> 1;
799
- if (starts[mid] <= offset) low = mid;
800
- else high = mid - 1;
801
- }
802
- return { line: low + 1, column: offset - starts[low] + 1 };
803
- }
804
- function offsetAt(starts, line, column, length) {
805
- const index = Math.min(Math.max(line, 1), starts.length) - 1;
806
- const offset = starts[index] + Math.max(column, 1) - 1;
807
- return Math.min(Math.max(offset, 0), length);
808
- }
809
-
810
- // src/detectors/linter/resolve.ts
811
- import { access as access3, constants as constants3 } from "fs/promises";
812
- import path5 from "path";
813
- var PYTHON_TOOLS = /* @__PURE__ */ new Set(["ruff"]);
814
- async function reachable2(file, mode) {
815
- try {
816
- await access3(file, mode);
817
- return true;
818
- } catch {
819
- return false;
820
- }
821
- }
822
- async function resolveTool(tool, root, available = onPath) {
823
- const local = path5.join(root, "node_modules", ".bin", tool);
824
- if (await reachable2(local, constants3.X_OK)) return { command: local, prefix: [] };
825
- if (PYTHON_TOOLS.has(tool) && await reachable2(path5.join(root, "pyproject.toml"), constants3.F_OK) && await available("uv")) {
826
- return { command: "uv", prefix: ["run", "--", tool] };
827
- }
828
- return { command: tool, prefix: [] };
829
- }
830
- async function describeTool(tool, root, options = {}) {
831
- const available = options.available ?? ((command) => onPath(command, options.signal));
832
- const resolved = await resolveTool(tool, root, available);
833
- if (path5.isAbsolute(resolved.command)) {
834
- const relative = path5.relative(root, resolved.command);
835
- const inside = relative !== "" && !relative.startsWith("..") && !path5.isAbsolute(relative);
836
- return { command: inside ? relative.split(path5.sep).join("/") : resolved.command, how: "local", found: true };
837
- }
838
- if (resolved.prefix.length > 0) {
839
- return { command: [resolved.command, ...resolved.prefix].join(" "), how: "uv", found: true };
840
- }
841
- return { command: resolved.command, how: "path", found: await available(resolved.command) };
842
- }
843
-
844
- // src/detectors/linter/schema.ts
845
- import { z as z4 } from "zod";
846
- var TOOL_NAMES = ["eslint", "oxlint", "ruff"];
847
- var linterSchema = z4.object({
848
- tool: z4.enum(TOOL_NAMES),
849
- /** Linter rule ids to report. Omitted: every finding the tool reports. */
850
- rules: z4.array(z4.string().min(1)).min(1).optional()
851
- }).strict();
852
-
853
- // src/detectors/linter/tools.ts
854
- import { realpathSync as realpathSync2 } from "fs";
855
- import path6 from "path";
856
- var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
857
- function json(tool, stdout) {
858
- try {
859
- return JSON.parse(stdout);
860
- } catch {
861
- throw new Error(`${tool} output is not JSON`);
862
- }
863
- }
864
- function relativeTo(roots, file) {
865
- if (!path6.isAbsolute(file)) return file.split(path6.sep).join("/");
866
- for (const root of roots) {
867
- const relative = path6.relative(root, file);
868
- if (!relative.startsWith("..") && !path6.isAbsolute(relative)) return relative.split(path6.sep).join("/");
869
- }
870
- return path6.relative(roots[0], file).split(path6.sep).join("/");
871
- }
872
- function rootsOf2(root) {
873
- try {
874
- const real = realpathSync2(root);
875
- return real === root ? [root] : [root, real];
876
- } catch {
877
- return [root];
878
- }
879
- }
880
- var number = (value, fallback) => typeof value === "number" ? value : fallback;
881
- var text = (value) => typeof value === "string" ? value : "";
882
- function oxlintRuleId(code) {
883
- const raw = text(code);
884
- return /^[\w-]+\((?<rule>[^)]+)\)$/.exec(raw)?.groups?.rule ?? raw;
885
- }
886
- var TOOLS = {
887
- ruff: {
888
- events: ["edit", "verify"],
889
- args: (files) => ["check", "--output-format", "json", "--force-exclude", "--", ...files],
890
- parse(stdout, root) {
891
- const parsed = json("ruff", stdout);
892
- if (!Array.isArray(parsed)) throw new Error("ruff output is not a JSON array of diagnostics");
893
- const roots = rootsOf2(root);
894
- return parsed.filter(isObject2).map((result) => {
895
- const start = isObject2(result.location) ? result.location : {};
896
- const end = isObject2(result.end_location) ? result.end_location : {};
897
- const line = number(start.row, 1);
898
- return {
899
- file: relativeTo(roots, text(result.filename)),
900
- line,
901
- column: number(start.column, 1),
902
- endLine: number(end.row, line),
903
- endColumn: typeof end.column === "number" ? end.column : null,
904
- ruleId: text(result.code),
905
- message: text(result.message)
906
- };
907
- });
908
- }
909
- },
910
- oxlint: {
911
- events: ["edit", "verify"],
912
- args: (files) => ["--format=json", "--", ...files],
913
- parse(stdout, root) {
914
- const parsed = json("oxlint", stdout);
915
- if (!isObject2(parsed) || !Array.isArray(parsed.diagnostics)) {
916
- throw new Error("oxlint output is not JSON with diagnostics");
917
- }
918
- const roots = rootsOf2(root);
919
- return parsed.diagnostics.filter(isObject2).map((diagnostic) => {
920
- const label = Array.isArray(diagnostic.labels) ? diagnostic.labels[0] : void 0;
921
- const span = isObject2(label) && isObject2(label.span) ? label.span : {};
922
- const line = number(span.line, 1);
923
- return {
924
- file: relativeTo(roots, text(diagnostic.filename)),
925
- line,
926
- column: number(span.column, 1),
927
- endLine: line,
928
- endColumn: null,
929
- ruleId: oxlintRuleId(diagnostic.code),
930
- message: text(diagnostic.message)
931
- };
932
- });
933
- }
934
- },
935
- eslint: {
936
- // eslint is the slow one: spec §6 keeps it off the edit hook by default.
937
- events: ["verify"],
938
- args: (files) => ["--format=json", "--no-error-on-unmatched-pattern", "--", ...files],
939
- parse(stdout, root) {
940
- const parsed = json("eslint", stdout);
941
- if (!Array.isArray(parsed)) throw new Error("eslint output is not a JSON array of file results");
942
- const roots = rootsOf2(root);
943
- const findings = [];
944
- for (const file of parsed.filter(isObject2)) {
945
- const messages = Array.isArray(file.messages) ? file.messages : [];
946
- for (const message of messages.filter(isObject2)) {
947
- const line = number(message.line, 1);
948
- findings.push({
949
- file: relativeTo(roots, text(file.filePath)),
950
- line,
951
- column: number(message.column, 1),
952
- endLine: number(message.endLine, line),
953
- endColumn: typeof message.endColumn === "number" ? message.endColumn : null,
954
- ruleId: text(message.ruleId),
955
- message: text(message.message)
956
- });
957
- }
958
- }
959
- return findings;
960
- }
961
- }
962
- };
963
-
964
- // src/detectors/linter/detector.ts
965
- var exec3 = promisify3(execFile3);
966
- function sourceReader(cwd) {
967
- const cache = /* @__PURE__ */ new Map();
968
- return (file) => {
969
- let source = cache.get(file);
970
- if (source === void 0) {
971
- source = readSourceFile(cwd, file);
972
- cache.set(file, source);
973
- }
974
- return source;
975
- };
976
- }
977
- function excerpt(source, finding) {
978
- if (source === null) return "";
979
- const starts = lineStarts(source);
980
- const from = offsetAt(starts, finding.line, finding.column, source.length);
981
- let to;
982
- if (finding.endColumn === null) {
983
- const newline = source.indexOf("\n", from);
984
- to = newline === -1 ? source.length : newline;
985
- } else {
986
- to = offsetAt(starts, finding.endLine, finding.endColumn, source.length);
987
- }
988
- return source.slice(from, Math.max(from, to)).replace(/\n$/, "");
989
- }
990
- async function runTool(tool, files, cwd, signal) {
991
- const resolved = await resolveTool(tool, cwd);
992
- const args = [...resolved.prefix, ...TOOLS[tool].args(files)];
993
- let stdout;
994
- try {
995
- const result = await exec3(resolved.command, args, { cwd, signal, maxBuffer: 64 * 1024 * 1024 });
996
- stdout = result.stdout;
997
- } catch (error) {
998
- if (signal.aborted) throw error;
999
- if (isNotFound(error)) throw new Error(`${tool} is not installed`);
1000
- const failure = error;
1001
- if (typeof failure.stdout !== "string") throw error;
1002
- stdout = failure.stdout;
1003
- }
1004
- return TOOLS[tool].parse(stdout, cwd);
1005
- }
1006
- var linterDetector = {
1007
- kind: "linter",
1008
- schema: linterSchema,
1009
- captures: () => ["message", "ruleId"],
1010
- // A copy: compileRule stores this as the rule's stages, and module-level data must not escape into it.
1011
- events: (config) => [...TOOLS[config.tool].events],
1012
- async run(input) {
1013
- const result = { findings: [], errors: [] };
1014
- const byTool = /* @__PURE__ */ new Map();
1015
- for (const rule of input.rules) {
1016
- byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule]);
1017
- }
1018
- const read = sourceReader(input.cwd);
1019
- await Promise.all(
1020
- [...byTool].map(async ([tool, rules]) => {
1021
- const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
1022
- if (files.length === 0) return;
1023
- try {
1024
- const findings = await runTool(tool, files, input.cwd, input.signal);
1025
- for (const finding of findings) {
1026
- const match = {
1027
- file: finding.file,
1028
- line: finding.line,
1029
- endLine: finding.endLine,
1030
- column: finding.column,
1031
- text: excerpt(await read(finding.file), finding),
1032
- captures: { message: finding.message, ruleId: finding.ruleId }
1033
- };
1034
- for (const rule of rules) {
1035
- if (!rule.files.includes(finding.file)) continue;
1036
- if (rule.config.rules && !rule.config.rules.includes(finding.ruleId)) continue;
1037
- result.findings.push({ rule: rule.id, match });
1038
- }
1039
- }
1040
- } catch (error) {
1041
- if (input.signal.aborted) throw error;
1042
- const message = errorMessage(error);
1043
- for (const rule of rules) result.errors.push({ rule: rule.id, message });
1044
- }
1045
- })
1046
- );
1047
- return result;
1048
- },
1049
- async check(input) {
1050
- const byTool = /* @__PURE__ */ new Map();
1051
- for (const rule of input.rules) {
1052
- byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule.id]);
1053
- }
1054
- return Promise.all(
1055
- [...byTool].map(async ([tool, rules]) => {
1056
- const described = await describeTool(tool, input.cwd, { signal: input.signal });
1057
- if (!described.found) return { what: tool, level: "error", detail: "not installed", rules };
1058
- const detail = described.how === "path" ? `${described.command} (PATH)` : described.command;
1059
- return { what: tool, level: "ok", detail, rules: [] };
1060
- })
1061
- );
1062
- }
1063
- };
1064
-
1065
- // src/detectors/llm/call.ts
1066
- import { createHash } from "crypto";
1067
- function callKey(input) {
1068
- const payload = {
1069
- file: input.file,
1070
- source: input.source,
1071
- changedLines: input.changedLines,
1072
- provider: input.provider,
1073
- baseUrl: input.baseUrl,
1074
- model: input.model,
1075
- 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 }))
1076
- };
1077
- return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1078
- }
1079
- async function askCached(provider, cache, key, request) {
1080
- const cached = await cache.get(key);
1081
- if (cached !== void 0) return cached;
1082
- const findings = await provider.ask(request);
1083
- await cache.set(key, findings);
1084
- return findings;
1085
- }
1086
-
1087
- // src/detectors/llm/prompt.ts
1088
- function buildPrompt(call) {
1089
- const marked = call.changedLines !== null;
1090
- const lines = [
1091
- "Check the file below against each rule. Report every line that breaks a rule.",
1092
- "",
1093
- 'Answer with JSON only: {"findings": [{"rule": "<rule id>", "line": <number>, "text": "<the line>", "reason": "<why>"}]}',
1094
- "Report nothing when a rule is not broken. Use the rule ids exactly as given.",
1095
- marked ? 'Only lines marked with ">" were changed. Report findings on those lines only.' : "Judge the whole file.",
1096
- "",
1097
- "## Rules",
1098
- ""
1099
- ];
1100
- for (const rule of call.rules) {
1101
- lines.push(`### ${rule.id}`, rule.question, "");
1102
- for (const reference of rule.grounding) {
1103
- lines.push(`Reference \u2014 ${reference.ref}:`, '"""', reference.content.replace(/\n$/, ""), '"""', "");
1104
- }
1105
- }
1106
- lines.push(`## File: ${call.file}`, "");
1107
- const fence = fenceFor(call.source);
1108
- lines.push(fence, ...numbered(call.source, call.changedLines), fence);
1109
- return lines.join("\n");
1110
- }
1111
- function fenceFor(source) {
1112
- let longest = 2;
1113
- for (const run2 of source.match(/`+/g) ?? []) longest = Math.max(longest, run2.length);
1114
- return "`".repeat(longest + 1);
1115
- }
1116
- function numbered(source, changedLines2) {
1117
- const sourceLines = source.replace(/\n$/, "").split("\n");
1118
- const width = String(sourceLines.length).length;
1119
- return sourceLines.map((text2, index) => {
1120
- const number2 = index + 1;
1121
- const mark = changedLines2 !== null && isChanged(number2, changedLines2) ? "> " : " ";
1122
- return `${mark}${String(number2).padStart(width)} ${text2}`;
1123
- });
1124
- }
1125
- function isChanged(line, changedLines2) {
1126
- return changedLines2.some(([start, end]) => line >= start && line <= end);
1127
- }
1128
-
1129
- // src/detectors/llm/schema.ts
1130
- import { z as z5 } from "zod";
1131
- var llmSchema = z5.object({
1132
- /**
1133
- * An alias from ./models.ts — haiku, sonnet, opus, fable — or the exact name the configured
1134
- * provider uses. Required: there is no project-wide default, so the cost of a rule is always
1135
- * chosen by whoever wrote it (plan 6a, Decision 1).
1136
- */
1137
- model: z5.string().trim().min(1),
1138
- question: z5.string().trim().min(1),
1139
- /** Send the rule's resolved context references with the question, whatever their delivery mode. */
1140
- grounding: z5.boolean().default(true)
1141
- }).strict();
1142
-
1143
- // src/detectors/llm/detector.ts
1144
- function sourceReader2(cwd) {
1145
- const cache = /* @__PURE__ */ new Map();
1146
- return (file) => {
1147
- let source = cache.get(file);
1148
- if (source === void 0) {
1149
- source = readSourceFile(cwd, file);
1150
- cache.set(file, source);
1151
- }
1152
- return source;
1153
- };
1154
- }
1155
- function changedLinesOf(changes, file) {
1156
- const change = changes.get(file);
1157
- if (change === void 0) return null;
1158
- return change.changedLines.length === 0 ? "skip" : change.changedLines;
1159
- }
1160
- async function groupCalls(rules, changes, read) {
1161
- const calls = /* @__PURE__ */ new Map();
1162
- for (const rule of rules) {
1163
- for (const file of rule.files) {
1164
- const changedLines2 = changedLinesOf(changes, file);
1165
- if (changedLines2 === "skip") continue;
1166
- const source = await read(file);
1167
- if (source === null) continue;
1168
- const key = `${file}\0${rule.config.model}`;
1169
- const call = calls.get(key);
1170
- if (call) call.rules.push(rule);
1171
- else calls.set(key, { file, source, changedLines: changedLines2, model: rule.config.model, rules: [rule] });
1172
- }
1173
- }
1174
- return [...calls.values()];
1175
- }
1176
- function promptRules(rules) {
1177
- return rules.map((rule) => ({
1178
- id: rule.id,
1179
- question: rule.config.question,
1180
- // Spec §6: the rule's resolved references, whatever their delivery mode.
1181
- grounding: rule.config.grounding ? rule.context.map((ref) => ({ ref: ref.ref, content: ref.content })) : []
1182
- }));
1183
- }
1184
- function toFindings(call, answered) {
1185
- const lines = call.source.replace(/\n$/, "").split("\n");
1186
- const ids = new Set(call.rules.map((rule) => rule.id));
1187
- const findings = [];
1188
- for (const finding of answered) {
1189
- if (!ids.has(finding.rule)) continue;
1190
- if (finding.line < 1 || finding.line > lines.length) continue;
1191
- if (call.changedLines !== null && !call.changedLines.some(([a, b]) => finding.line >= a && finding.line <= b)) {
1192
- continue;
1193
- }
1194
- findings.push({
1195
- rule: finding.rule,
1196
- match: {
1197
- file: call.file,
1198
- line: finding.line,
1199
- endLine: finding.line,
1200
- column: 1,
1201
- // The file's own line, not the model's quote: {{text}} is the matched source everywhere else.
1202
- text: lines[finding.line - 1],
1203
- captures: { reason: finding.reason }
1204
- }
1205
- });
1206
- }
1207
- return findings;
1208
- }
1209
- function unmappedModel(provider) {
1210
- return `no name for the ${provider} provider; use that provider's own model name`;
1211
- }
1212
- async function runCall(call, provider, settings, input) {
1213
- const model = resolveModel(call.model, settings.provider);
1214
- if (model === null) throw new Error(`model "${call.model}" has ${unmappedModel(settings.provider)}`);
1215
- const rules = promptRules(call.rules);
1216
- const key = callKey({
1217
- file: call.file,
1218
- source: call.source,
1219
- changedLines: call.changedLines,
1220
- provider: settings.provider,
1221
- baseUrl: settings.baseUrl,
1222
- model,
1223
- rules: call.rules.map((rule, index) => ({
1224
- id: rule.id,
1225
- config: rule.config,
1226
- grounding: rules[index].grounding
1227
- }))
1228
- });
1229
- const answered = await askCached(provider, input.cache, key, {
1230
- model,
1231
- prompt: buildPrompt({ file: call.file, source: call.source, changedLines: call.changedLines, rules }),
1232
- settings,
1233
- env: process.env,
1234
- cwd: input.cwd,
1235
- signal: input.signal
1236
- });
1237
- return toFindings(call, answered);
1238
- }
1239
- var llmDetector = {
1240
- kind: "llm",
1241
- schema: llmSchema,
1242
- captures: () => ["reason"],
1243
- // verify only by default (spec §6): a model call is far too slow for an edit hook. A rule opts
1244
- // in with `stages: [edit, verify]`.
1245
- events: () => ["verify"],
1246
- async run(input) {
1247
- const result = { findings: [], errors: [] };
1248
- const settings = input.settings.llm;
1249
- let provider;
1250
- try {
1251
- provider = providerByName(settings.provider);
1252
- } catch (error) {
1253
- return { findings: [], errors: [{ rule: null, message: errorMessage(error) }] };
1254
- }
1255
- const calls = await groupCalls(input.rules, input.changes, sourceReader2(input.cwd));
1256
- const outcomes = await Promise.all(
1257
- calls.map(async (call) => {
1258
- try {
1259
- return { call, findings: await runCall(call, provider, settings, input) };
1260
- } catch (error) {
1261
- if (input.signal.aborted) throw error;
1262
- return { call, error };
1263
- }
1264
- })
1265
- );
1266
- for (const outcome of outcomes) {
1267
- if ("error" in outcome && outcome.error instanceof LlmUnavailableError) {
1268
- return { findings: [], errors: [{ rule: null, message: outcome.error.message }] };
1269
- }
1270
- }
1271
- for (const outcome of outcomes) {
1272
- if ("error" in outcome) {
1273
- const message = errorMessage(outcome.error);
1274
- for (const rule of outcome.call.rules) result.errors.push({ rule: rule.id, message });
1275
- continue;
1276
- }
1277
- result.findings.push(...outcome.findings);
1278
- }
1279
- return result;
1280
- },
1281
- /**
1282
- * The two pre-flight checks plan 6a deferred here: is the backend reachable at all, and does
1283
- * every rule's model alias have a name for it. Both are environmental and free — nothing in this
1284
- * method calls a model, which is also why doctor does not dry-run llm rules.
1285
- */
1286
- async check(input) {
1287
- const settings = input.settings.llm;
1288
- const ids = input.rules.map((rule) => rule.id);
1289
- let provider;
1290
- try {
1291
- provider = providerByName(settings.provider);
1292
- } catch (error) {
1293
- return [{ what: settings.provider, level: "error", detail: errorMessage(error), rules: ids }];
1294
- }
1295
- const reachable3 = await provider.available({
1296
- settings,
1297
- env: input.env,
1298
- cwd: input.cwd,
1299
- signal: input.signal
1300
- });
1301
- const results = [
1302
- {
1303
- what: settings.provider,
1304
- level: reachable3.ok ? "ok" : "error",
1305
- detail: reachable3.detail,
1306
- rules: reachable3.ok ? [] : ids
1307
- }
1308
- ];
1309
- const byModel = /* @__PURE__ */ new Map();
1310
- for (const rule of input.rules) {
1311
- byModel.set(rule.config.model, [...byModel.get(rule.config.model) ?? [], rule.id]);
1312
- }
1313
- for (const [model, rules] of byModel) {
1314
- const resolved = resolveModel(model, settings.provider);
1315
- results.push(
1316
- resolved === null ? {
1317
- what: `model "${model}"`,
1318
- level: "error",
1319
- detail: unmappedModel(settings.provider),
1320
- rules
1321
- } : {
1322
- what: `model "${model}"`,
1323
- level: "ok",
1324
- // "passed through" is only true of a name that is not an alias at all. An alias that
1325
- // resolves to itself — haiku for the claude-code provider — is still an alias, and
1326
- // saying otherwise reads as "rulecast does not know this model".
1327
- detail: MODEL_ALIASES.includes(model) ? `${settings.provider} calls it ${resolved}` : "passed through",
1328
- rules: []
1329
- }
1330
- );
1331
- }
1332
- return results;
1333
- }
1334
- };
1335
-
1336
- // src/detectors/path.ts
1337
- import { z as z6 } from "zod";
1338
- var schema = z6.object({}).strict();
1339
- var pathDetector = {
1340
- kind: "path",
1341
- schema,
1342
- captures: () => [],
1343
- events: () => ["edit", "verify"],
1344
- guards: true,
1345
- run: perRule(async (rule, input) => {
1346
- const matches = [];
1347
- for (const file of rule.files) {
1348
- input.signal.throwIfAborted();
1349
- const text2 = await input.read(file);
1350
- if (text2 === null) continue;
1351
- matches.push({ file, line: 1, endLine: text2.split(/\r?\n/).length, column: 1, text: file, captures: {} });
1352
- }
1353
- return matches;
1354
- })
1355
- };
1356
-
1357
- // src/detectors/regex.ts
1358
- import { z as z7 } from "zod";
1359
- var schema2 = z7.object({
1360
- pattern: z7.string().min(1),
1361
- flags: z7.string().regex(/^[dimsuvy]*$/, "allowed flags: d i m s u v y").default("")
1362
- }).strict().superRefine((config, ctx) => {
1363
- try {
1364
- new RegExp(config.pattern, config.flags);
1365
- } catch (error) {
1366
- ctx.addIssue({ code: "custom", path: ["pattern"], message: errorMessage(error) });
1367
- }
1368
- });
1369
- var NAMED_GROUP = /\(\?<([A-Za-z_$][\w$]*)>/g;
1370
- function groupNames(pattern) {
1371
- return [...new Set([...pattern.matchAll(NAMED_GROUP)].map((match) => match[1]))];
1372
- }
1373
- var regexDetector = {
1374
- kind: "regex",
1375
- schema: schema2,
1376
- captures: (config) => groupNames(config.pattern),
1377
- events: () => ["edit", "verify"],
1378
- guards: true,
1379
- run: perRule(async (rule, input) => {
1380
- const names = groupNames(rule.config.pattern);
1381
- const matches = [];
1382
- for (const file of rule.files) {
1383
- input.signal.throwIfAborted();
1384
- const text2 = await input.read(file);
1385
- if (text2 === null) continue;
1386
- const starts = lineStarts(text2);
1387
- for (const found of text2.matchAll(new RegExp(rule.config.pattern, `${rule.config.flags}g`))) {
1388
- const start = found.index;
1389
- const end = start + found[0].length;
1390
- const from = positionAt(starts, start);
1391
- const to = positionAt(starts, Math.max(start, end - 1));
1392
- matches.push({
1393
- file,
1394
- line: from.line,
1395
- endLine: to.line,
1396
- column: from.column,
1397
- text: found[0],
1398
- captures: Object.fromEntries(names.map((name) => [name, found.groups?.[name] ?? ""]))
1399
- });
1400
- }
1401
- }
1402
- return matches;
1403
- })
1404
- };
1405
-
1406
- // src/detectors/index.ts
1407
- var builtinDetectors = [
1408
- regexDetector,
1409
- pathDetector,
1410
- astGrepDetector,
1411
- commandDetector,
1412
- linterDetector,
1413
- llmDetector
1414
- ];
1415
-
1416
- // src/core/types.ts
1417
- var LLM_PROVIDERS = ["claude-code", "opencode", "anthropic", "openai-compatible"];
1418
- function defaultDetectorSettings() {
1419
- return { llm: { provider: "claude-code", baseUrl: null, apiKeyEnv: "ANTHROPIC_API_KEY", maxFilesPerVerify: 10 } };
1420
- }
1421
- function emptyDelivery() {
1422
- return {
1423
- findings: [],
1424
- preexistingSummary: [],
1425
- references: [],
1426
- touches: [],
1427
- stop: null,
1428
- warnings: [],
1429
- templates: {},
1430
- omitted: { findings: [], rules: 0, preexisting: 0 },
1431
- overflowPath: null
1432
- };
1433
- }
1434
-
1435
- // src/core/config/load.ts
1436
- import { readFile as readFile2 } from "fs/promises";
1437
- import path8 from "path";
1438
- import { parse } from "yaml";
1439
-
1440
- // src/core/config/schema.ts
1441
- import { z as z9 } from "zod";
1442
-
1443
- // src/core/references.ts
1444
- import path7 from "path";
1445
- import { z as z8 } from "zod";
1446
- var referenceInputSchema = z8.union([
1447
- z8.string(),
1448
- z8.object({ path: z8.string(), mode: z8.enum(["inject", "read"]).optional() }).strict()
1449
- ]);
1450
- var ReferenceSyntaxError = class extends Error {
1451
- };
1452
- function parseReference(input, defaultMode, root) {
1453
- const raw = typeof input === "string" ? input : input.path;
1454
- const mode = typeof input === "string" ? defaultMode : input.mode ?? defaultMode;
1455
- if (!raw.startsWith("@")) throw new ReferenceSyntaxError(`reference "${raw}" must start with "@"`);
1456
- const body = raw.slice(1);
1457
- const hash = body.indexOf("#");
1458
- const file = hash === -1 ? body : body.slice(0, hash);
1459
- const anchor = hash === -1 ? null : body.slice(hash + 1);
1460
- if (!file) throw new ReferenceSyntaxError(`reference "${raw}" has no path`);
1461
- if (anchor === "") throw new ReferenceSyntaxError(`reference "${raw}" has an empty anchor`);
1462
- if (anchor !== null && !/\.mdx?$/.test(file)) {
1463
- throw new ReferenceSyntaxError(`anchors are only supported in .md and .mdx files: "${raw}"`);
1464
- }
1465
- const relative = path7.posix.normalize(file);
1466
- if (path7.posix.isAbsolute(relative) || relative === ".." || relative.startsWith("../")) {
1467
- throw new ReferenceSyntaxError(`reference "${raw}" leaves its root`);
1468
- }
1469
- const suffix = anchor === null ? "" : `#${anchor}`;
1470
- if (root.label === null) return { ref: `${relative}${suffix}`, path: relative, anchor, mode };
1471
- return { ref: `${root.label}:${relative}${suffix}`, path: path7.join(root.dir, relative), anchor, mode };
1472
- }
1473
-
1474
- // src/core/config/schema.ts
1475
- var STAGES = ["touch", "edit", "verify"];
1476
- var RULE_ID = /^[a-z0-9-]+(\/[a-z0-9-]+)*$/;
1477
- var idSchema = z9.string().regex(RULE_ID, "must be lowercase segments separated by /");
1478
- var versionSchema = z9.string().regex(/^\d+\.\d+\.\d+$/, "must be a version like 0.2.0");
1479
- var stagesSchema = z9.array(z9.enum(STAGES)).nonempty();
1480
- var ruleKeys = {
1481
- alias: idSchema.optional(),
1482
- name: z9.string().min(1).optional(),
1483
- description: z9.string().optional(),
1484
- files: z9.string().optional(),
1485
- exclude: z9.string().optional(),
1486
- types: z9.array(z9.string()).optional(),
1487
- types_or: z9.array(z9.string()).optional(),
1488
- exclude_types: z9.array(z9.string()).optional(),
1489
- stages: stagesSchema.optional(),
1490
- minimum_rulecast_version: versionSchema.optional(),
1491
- severity: z9.enum(["error", "warning"]).optional(),
1492
- refuse_write: z9.boolean().optional(),
1493
- detect: z9.record(z9.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
1494
- message: z9.string().optional(),
1495
- context: z9.array(referenceInputSchema).optional()
1496
- };
1497
- var overrideSchema = z9.object({ id: idSchema, ...ruleKeys }).strict();
1498
- var ruleSchema = overrideSchema.extend({ name: z9.string().min(1) });
1499
- var repoSchema = z9.object({
1500
- repo: z9.string().min(1),
1501
- rev: z9.string().min(1).optional(),
1502
- /** Parsed rule by rule in compile, so one bad rule does not reject the config. */
1503
- rules: z9.array(z9.unknown())
1504
- }).strict();
1505
- var configSchema = z9.object({
1506
- repos: z9.array(repoSchema),
1507
- minimum_rulecast_version: versionSchema.optional(),
1508
- files: z9.string().default(""),
1509
- exclude: z9.string().default("^$"),
1510
- default_stages: stagesSchema.optional(),
1511
- context: z9.object({
1512
- mode: z9.enum(["inject", "read"]).default("inject"),
1513
- max_bytes: z9.number().int().positive().default(32768)
1514
- }).strict().default({}),
1515
- max_matches_per_rule: z9.number().int().positive().default(10),
1516
- timeouts: z9.object({
1517
- edit_deadline_ms: z9.number().int().positive().default(350),
1518
- verify_ms: z9.number().int().positive().default(6e4)
1519
- }).strict().default({}),
1520
- stop_gate: z9.object({ max_blocks: z9.number().int().nonnegative().default(1) }).strict().default({}),
1521
- refuse_gate: z9.object({ max_refusals: z9.number().int().nonnegative().default(1) }).strict().default({}),
1522
- llm: z9.object({
1523
- // No `model`: every llm rule names its own (plan 6a, Decision 1).
1524
- provider: z9.enum(LLM_PROVIDERS).default("claude-code"),
1525
- base_url: z9.string().nullable().default(null),
1526
- api_key_env: z9.string().default("ANTHROPIC_API_KEY"),
1527
- max_files_per_verify: z9.number().int().positive().default(10)
1528
- }).strict().default({})
1529
- }).strict().transform((input) => ({
1530
- repos: input.repos,
1531
- minimumRulecastVersion: input.minimum_rulecast_version ?? null,
1532
- files: input.files,
1533
- exclude: input.exclude,
1534
- defaultStages: input.default_stages ?? null,
1535
- context: { mode: input.context.mode, maxBytes: input.context.max_bytes },
1536
- maxMatchesPerRule: input.max_matches_per_rule,
1537
- timeouts: { editDeadlineMs: input.timeouts.edit_deadline_ms, verifyMs: input.timeouts.verify_ms },
1538
- stopGate: { maxBlocks: input.stop_gate.max_blocks },
1539
- refuseGate: { maxRefusals: input.refuse_gate.max_refusals },
1540
- llm: {
1541
- provider: input.llm.provider,
1542
- baseUrl: input.llm.base_url,
1543
- apiKeyEnv: input.llm.api_key_env,
1544
- maxFilesPerVerify: input.llm.max_files_per_verify
1545
- }
1546
- }));
1547
- function defaultConfig() {
1548
- return configSchema.parse({ repos: [] });
1549
- }
1550
-
1551
- // src/core/config/load.ts
1552
- var CONFIG_FILE = ".rulecast-config.yaml";
1553
- var MANIFEST_FILE = ".rulecast-rules.yaml";
1554
- async function readYamlFile(file) {
1555
- const name = path8.basename(file);
1556
- let text2;
1557
- try {
1558
- text2 = await readFile2(file, "utf8");
1559
- } catch (error) {
1560
- if (isNotFound(error)) return { ok: false, message: `${name} not found` };
1561
- throw error;
1562
- }
1563
- try {
1564
- return { ok: true, value: parse(text2) ?? null };
1565
- } catch (error) {
1566
- return { ok: false, message: `${name}: ${errorMessage(error)}` };
1567
- }
1568
- }
1569
- function readConfigData(root) {
1570
- return readYamlFile(path8.join(root, CONFIG_FILE));
1571
- }
1572
- function parseConfig(data) {
1573
- const result = configSchema.safeParse(data);
1574
- if (!result.success) return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error)}` };
1575
- return { ok: true, value: result.data };
1576
- }
1577
- async function readManifest(dir) {
1578
- const loaded = await readYamlFile(path8.join(dir, MANIFEST_FILE));
1579
- if (!loaded.ok) return loaded;
1580
- if (!Array.isArray(loaded.value)) return { ok: false, message: `${MANIFEST_FILE}: must be a list of rules` };
1581
- return { ok: true, value: loaded.value };
1582
- }
1583
-
1584
- // src/core/version.ts
1585
- var VERSION2 = "0.1.1";
1586
- function parseVersion(text2) {
1587
- const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text2);
1588
- return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
1589
- }
1590
- function isOlder(running, minimum) {
1591
- const a = parseVersion(running);
1592
- const b = parseVersion(minimum);
1593
- if (!a || !b) return false;
1594
- for (let i = 0; i < 3; i++) {
1595
- if (a[i] !== b[i]) return a[i] < b[i];
1596
- }
1597
- return false;
1598
- }
1599
-
1600
- // src/core/delivery/render-agent.ts
1601
- var GROUP_FROM = 3;
1602
- var GROUP_MIN_LITERAL = 40;
1603
- var MAX_LOCATION_PAD = 40;
1604
- var WRAP = 96;
1605
- var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1606
- function header(delivery) {
1607
- if (delivery.findings.length > 0) {
1608
- const rules = new Set(delivery.findings.map((finding) => finding.rule)).size + delivery.omitted.rules;
1609
- const files = new Set(delivery.findings.map((finding) => finding.file));
1610
- const where = files.size === 1 && delivery.omitted.rules === 0 ? ` in ${[...files][0]}` : "";
1611
- return `rulecast: ${plural(rules, "rule")} violated${where}`;
1612
- }
1613
- if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
1614
- return "rulecast: conventions for the files you are working on";
1615
- }
1616
- return null;
1617
- }
1618
- var repeatOf = (finding) => finding.count > 1 ? ` (\xD7${finding.count})` : "";
1619
- function findingLines(finding) {
1620
- const lines = finding.message.split("\n").map((line) => ` ${line}`);
1621
- lines[lines.length - 1] += repeatOf(finding);
1622
- return lines;
1623
- }
1624
- var locationOf = (finding, withLine) => withLine ? `${finding.file}:${finding.line}` : finding.file;
1625
- function bindingValue(value) {
1626
- const text2 = (value ?? "").replace(/\s+/g, " ").trim();
1627
- if (text2 === "") return "\u2014";
1628
- return /\s/.test(text2) && !/^".*"$/.test(text2) ? `"${text2}"` : text2;
1629
- }
1630
- function packed(items) {
1631
- const lines = [];
1632
- for (const item of items) {
1633
- const last = lines.at(-1);
1634
- if (last !== void 0 && last.length + 2 + item.length <= WRAP) lines[lines.length - 1] = `${last}, ${item}`;
1635
- else lines.push(` ${item}`);
1636
- }
1637
- return lines;
1638
- }
1639
- function groupedLines(findings, template) {
1640
- const names = templateBindings(template);
1641
- const withLine = templateVariables(template).includes("line");
1642
- const at = (finding) => locationOf(finding, withLine);
1643
- const out = templateSkeleton(template).split("\n").map((line) => ` ${line}`);
1644
- out.push("");
1645
- if (names.length === 0) {
1646
- out.push(...packed(findings.map((finding) => `${at(finding)}${repeatOf(finding)}`)));
1647
- return out;
1648
- }
1649
- const width = Math.min(MAX_LOCATION_PAD, Math.max(...findings.map((finding) => at(finding).length)));
1650
- for (const finding of findings) {
1651
- const values = names.map((name) => bindingValue(finding.captures?.[name])).join(" ");
1652
- out.push(` ${at(finding).padEnd(width)} ${values}${repeatOf(finding)}`.trimEnd());
1653
- }
1654
- return out;
1655
- }
1656
- function referenceLines(reference) {
1657
- switch (reference.state) {
1658
- case "full":
1659
- return [`--- ${reference.ref} ---`, ...(reference.content ?? "").split("\n"), ""];
1660
- case "pointer":
1661
- return [`--- ${reference.ref} (provided earlier in this session) ---`];
1662
- case "missing":
1663
- return [`--- ${reference.ref} (missing) ---`];
1664
- case "read": {
1665
- const what = reference.location === void 0 ? "read this" : `read ${reference.location}`;
1666
- return reference.reason === "mode" ? [`--- ${reference.ref}: ${what} before continuing ---`] : [`--- ${reference.ref}: ${what} before continuing (not included, too long for this message) ---`];
1667
- }
1668
- }
1669
- }
1670
- function omittedFor(omitted, rule) {
1671
- const entry = omitted.findings.find((finding) => finding.rule === rule);
1672
- return { count: entry?.count ?? 0, files: entry?.files ?? 0 };
1673
- }
1674
- function ruleBlock(rule, findings, template, dropped, options) {
1675
- const shown = findings.slice(0, options.maxMatchesPerRule);
1676
- const hidden = findings.slice(options.maxMatchesPerRule);
1677
- const total = findings.length + dropped.count;
1678
- const grouped = template !== void 0 && total >= GROUP_FROM && templateLiteralLength(template) >= GROUP_MIN_LITERAL;
1679
- const severity = findings[0].severity;
1680
- const files = new Set(findings.map((finding) => finding.file)).size + dropped.files;
1681
- const out = [grouped ? `${severity} ${rule} \u2014 ${total} in ${plural(files, "file")}` : `${severity} ${rule}`];
1682
- if (grouped && template !== void 0) out.push(...groupedLines(shown, template));
1683
- else for (const finding of shown) out.push(...findingLines(finding));
1684
- const more = hidden.length + dropped.count;
1685
- if (more > 0) {
1686
- const moreFiles = new Set(hidden.map((finding) => finding.file)).size + dropped.files;
1687
- out.push(` \u2026and ${more} more in ${plural(moreFiles, "file")}`);
1688
- }
1689
- return out;
1690
- }
1691
- function measureRuleBlock(rule, findings, template, options) {
1692
- return ruleBlock(rule, findings, template, { count: 0, files: 0 }, options).join("\n").length + 1;
1693
- }
1694
- function renderAgentText(delivery, options) {
1695
- const title = header(delivery);
1696
- if (title === null && delivery.warnings.length === 0) return "";
1697
- const out = [];
1698
- if (title !== null) out.push(title, "");
1699
- const byRule = /* @__PURE__ */ new Map();
1700
- for (const finding of delivery.findings) byRule.set(finding.rule, [...byRule.get(finding.rule) ?? [], finding]);
1701
- for (const [rule, findings] of byRule) {
1702
- out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
1703
- out.push("");
1704
- }
1705
- for (const summary of delivery.preexistingSummary) {
1706
- out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
1707
- }
1708
- if (delivery.omitted.preexisting > 0) {
1709
- out.push(`\u2026and ${delivery.omitted.preexisting} more pre-existing (not blocking)`);
1710
- }
1711
- if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) out.push("");
1712
- for (const reference of delivery.references) out.push(...referenceLines(reference));
1713
- if (out.length > 0 && out.at(-1) !== "") out.push("");
1714
- if (delivery.warnings.length > 0) {
1715
- out.push("rulecast warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`));
1716
- }
1717
- if (delivery.overflowPath !== null) {
1718
- if (out.at(-1) !== "") out.push("");
1719
- out.push(
1720
- `\u2026${plural(delivery.omitted.rules, "rule")} and the conventions they cite did not fit here.`,
1721
- "Every finding, with the doc sections it cites, is in:",
1722
- ` ${delivery.overflowPath}`
1723
- );
1724
- }
1725
- while (out.at(-1) === "") out.pop();
1726
- return out.join("\n");
1727
- }
1728
-
1729
- // src/adapters/claude-code/parse.ts
1730
- import { z as z10 } from "zod";
1731
- var common = z10.object({
1732
- hook_event_name: z10.string(),
1733
- session_id: z10.string().min(1),
1734
- cwd: z10.string().min(1),
1735
- agent_id: z10.string().min(1).optional(),
1736
- agent_type: z10.string().optional(),
1737
- source: z10.string().optional(),
1738
- prompt: z10.string().optional()
1739
- });
1740
- var fileTool = z10.object({
1741
- tool_name: z10.string(),
1742
- tool_input: z10.object({ file_path: z10.string().min(1) })
1743
- });
1744
- var readResponse = z10.object({
1745
- tool_response: z10.object({
1746
- file: z10.object({ startLine: z10.number(), numLines: z10.number(), totalLines: z10.number() })
1747
- })
1748
- });
1749
- var writeInput = z10.object({
1750
- tool_input: z10.object({
1751
- file_path: z10.string().min(1),
1752
- content: z10.string().optional(),
1753
- old_string: z10.string().optional(),
1754
- new_string: z10.string().optional(),
1755
- replace_all: z10.boolean().optional()
1756
- })
1757
- });
1758
- function parseClaudeCode(input) {
1759
- const head = common.safeParse(input);
1760
- if (!head.success) return null;
1761
- const {
1762
- hook_event_name: hook,
1763
- session_id: id,
1764
- cwd,
1765
- agent_id: agentId,
1766
- agent_type: agentType,
1767
- source,
1768
- prompt
1769
- } = head.data;
1770
- const session = agentId === void 0 ? { id } : { id, agentId };
1771
- const result = (kind, files = [], completeRead) => ({
1772
- cwd,
1773
- event: kind === null ? null : { kind, files, cwd, session, ...completeRead === void 0 ? {} : { completeRead } },
1774
- warmup: false
1775
- });
1776
- switch (hook) {
1777
- case "PreToolUse": {
1778
- const tool = fileTool.safeParse(input);
1779
- if (!tool.success) return result(null);
1780
- const { tool_name: name } = tool.data;
1781
- if (name !== "Edit" && name !== "Write") return result(null);
1782
- const write = writeInput.safeParse(input);
1783
- if (!write.success) return result(null);
1784
- const {
1785
- file_path: file,
1786
- content,
1787
- old_string: find,
1788
- new_string: replace,
1789
- replace_all: all
1790
- } = write.data.tool_input;
1791
- const intent = content !== void 0 ? { content } : find !== void 0 && replace !== void 0 ? { edit: { find, replace, all: all === true } } : null;
1792
- if (intent === null) return result(null);
1793
- return { ...result("guard", [file]), event: { kind: "guard", files: [file], cwd, session, intent } };
1794
- }
1795
- case "PostToolUse": {
1796
- const tool = fileTool.safeParse(input);
1797
- if (!tool.success) return result(null);
1798
- const { tool_name: name, tool_input: toolInput } = tool.data;
1799
- if (name === "Edit" || name === "Write") return result("edit", [toolInput.file_path]);
1800
- if (name !== "Read") return result(null);
1801
- const read = readResponse.safeParse(input);
1802
- const file = read.success ? read.data.tool_response.file : null;
1803
- return result(
1804
- "touch",
1805
- [toolInput.file_path],
1806
- file !== null && file.startLine === 1 && file.numLines === file.totalLines
1807
- );
1808
- }
1809
- case "Stop":
1810
- return result("verify");
1811
- case "SubagentStop":
1812
- return result(agentType === "" ? null : "verify");
1813
- case "UserPromptSubmit":
1814
- return result(prompt?.startsWith("<task-notification>") ? null : "prompt");
1815
- case "SessionStart":
1816
- if (source === "compact") return result("reset");
1817
- return { ...result(null), warmup: source === "startup" || source === "resume" };
1818
- default:
1819
- return result(null);
1820
- }
1821
- }
1822
-
1823
- // src/adapters/claude-code/settings.ts
1824
- var SettingsError = class extends Error {
1825
- };
1826
- var RULECAST_HOOK = /\brulecast\b.*\bhook claude-code\b/;
1827
- var isObject3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1828
- var isRulecastHook = (hook) => isObject3(hook) && typeof hook.command === "string" && RULECAST_HOOK.test(hook.command);
1829
- function hookGroups(verifyMs) {
1830
- const verifyTimeout = Math.ceil(verifyMs / 1e3) + 10;
1831
- return [
1832
- // Before the write, for refuse_write rules. It answers nothing at all unless the project has
1833
- // one, so the cost on an ordinary edit is starting rulecast and compiling the config.
1834
- { event: "PreToolUse", matcher: "Edit|Write", timeout: 5 },
1835
- { event: "PostToolUse", matcher: "Read", timeout: 5 },
1836
- { event: "PostToolUse", matcher: "Edit|Write", timeout: 5 },
1837
- { event: "Stop", timeout: verifyTimeout },
1838
- { event: "SubagentStop", timeout: verifyTimeout },
1839
- { event: "UserPromptSubmit", timeout: 5 },
1840
- { event: "SessionStart", matcher: "startup|resume|compact", timeout: 5 }
1841
- ];
1842
- }
1843
- function installed(groups, matcher2) {
1844
- return groups.some(
1845
- (group) => isObject3(group) && group.matcher === matcher2 && Array.isArray(group.hooks) && group.hooks.some(isRulecastHook)
1846
- );
1847
- }
1848
- function hooksOf(settings) {
1849
- if (!isObject3(settings)) throw new SettingsError("settings must be a JSON object");
1850
- const hooks = settings.hooks ?? {};
1851
- if (!isObject3(hooks)) throw new SettingsError('"hooks" must be an object');
1852
- return hooks;
1853
- }
1854
- function mergeHooks(settings, command, verifyMs) {
1855
- const merged = { ...hooksOf(settings) };
1856
- const added = [];
1857
- for (const { event, matcher: matcher2, timeout } of hookGroups(verifyMs)) {
1858
- const groups = merged[event] ?? [];
1859
- if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
1860
- if (installed(groups, matcher2)) continue;
1861
- const hook = { type: "command", command, timeout };
1862
- merged[event] = [...groups, matcher2 === void 0 ? { hooks: [hook] } : { matcher: matcher2, hooks: [hook] }];
1863
- added.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
1864
- }
1865
- return { settings: { ...settings, hooks: merged }, added };
1866
- }
1867
- function removeHooks(settings) {
1868
- const hooks = hooksOf(settings);
1869
- const source = settings;
1870
- const kept = {};
1871
- const removed = [];
1872
- for (const [event, groups] of Object.entries(hooks)) {
1873
- if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
1874
- const remaining = [];
1875
- for (const group of groups) {
1876
- if (!isObject3(group) || !Array.isArray(group.hooks) || !group.hooks.some(isRulecastHook)) {
1877
- remaining.push(group);
1878
- continue;
1879
- }
1880
- removed.push(typeof group.matcher === "string" ? `${event} (${group.matcher})` : event);
1881
- const others = group.hooks.filter((hook) => !isRulecastHook(hook));
1882
- if (others.length > 0) remaining.push({ ...group, hooks: others });
1883
- }
1884
- if (remaining.length > 0 || groups.length === 0) kept[event] = remaining;
1885
- }
1886
- if (removed.length === 0) return { settings: source, removed };
1887
- const result = {};
1888
- for (const [key, value] of Object.entries(source)) {
1889
- if (key !== "hooks") result[key] = value;
1890
- else if (Object.keys(kept).length > 0) result[key] = kept;
1891
- }
1892
- return { settings: result, removed };
1893
- }
1894
-
1895
- // src/adapters/claude-code/adapter.ts
1896
- var CONTEXT_LIMIT = 1e4;
1897
- var CONTEXT_BUDGET = 9e3;
1898
- var BLOCK_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish.";
1899
- var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
1900
- 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.";
1901
- function cutNotice(delivery, event) {
1902
- if (delivery.overflowPath !== null) {
1903
- return `
1904
-
1905
- \u2026cut to fit Claude Code's hook output limit. The whole delivery is in ${delivery.overflowPath}.`;
1906
- }
1907
- const session = event.session ? `--session ${event.session.id} ` : "";
1908
- return `
1909
-
1910
- \u2026cut to fit Claude Code's hook output limit. Run \`rulecast run ${session}--format agent\` for the full list.`;
1911
- }
1912
- function withinLimit(text2, delivery, event) {
1913
- if (text2.length <= CONTEXT_LIMIT) return text2;
1914
- const notice = cutNotice(delivery, event);
1915
- const room = CONTEXT_LIMIT - notice.length;
1916
- const cut = text2.slice(0, room);
1917
- const lastLine = cut.lastIndexOf("\n");
1918
- return (lastLine > room / 2 ? cut.slice(0, lastLine) : cut.trimEnd()) + notice;
1919
- }
1920
- var NONE = { stdout: "", exitCode: 0 };
1921
- var json2 = (value) => ({ stdout: JSON.stringify(value), exitCode: 0 });
1922
- var claudeCodeAdapter = {
1923
- name: "claude-code",
1924
- label: "Claude Code",
1925
- maxContextChars: CONTEXT_BUDGET,
1926
- /** Recorded from 2.1.278: /compact re-attaches the 5 most recently read, edited or written files. */
1927
- restoredFiles: 5,
1928
- parse: parseClaudeCode,
1929
- format(delivery, event, options) {
1930
- const text2 = renderAgentText(delivery, options);
1931
- if (text2 === "") return NONE;
1932
- switch (event.kind) {
1933
- case "guard":
1934
- return json2({
1935
- hookSpecificOutput: {
1936
- hookEventName: "PreToolUse",
1937
- permissionDecision: "deny",
1938
- permissionDecisionReason: withinLimit(`${DENY_PREAMBLE}
1939
-
1940
- ${text2}`, delivery, event)
1941
- }
1942
- });
1943
- case "touch":
1944
- case "edit":
1945
- return json2({
1946
- hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
1947
- });
1948
- case "reset":
1949
- return json2({
1950
- hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: withinLimit(text2, delivery, event) }
1951
- });
1952
- case "verify":
1953
- if (delivery.stop === "block")
1954
- return json2({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
1955
-
1956
- ${text2}`, delivery, event) });
1957
- if (delivery.stop === "capReached")
1958
- return json2({ systemMessage: withinLimit(`${CAP_PREAMBLE}
1959
-
1960
- ${text2}`, delivery, event) });
1961
- return NONE;
1962
- default:
1963
- return NONE;
1964
- }
1965
- },
1966
- install: {
1967
- markers: [".claude/", "CLAUDE.md"],
1968
- scopes: [
1969
- { scope: "shared", file: ".claude/settings.json" },
1970
- { scope: "personal", file: ".claude/settings.local.json" }
1971
- ],
1972
- command: (local) => local ? '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast hook claude-code' : "rulecast hook claude-code",
1973
- merge: mergeHooks,
1974
- remove: removeHooks
1975
- }
1976
- };
1977
-
1978
- // src/adapters/index.ts
1979
- var ADAPTERS = [claudeCodeAdapter];
1980
- function adapterByName(name) {
1981
- return ADAPTERS.find((adapter) => adapter.name === name);
1982
- }
1983
-
1984
- // src/core/home.ts
1985
- import { createHash as createHash2 } from "crypto";
1986
- import { appendFileSync, existsSync, mkdirSync, realpathSync as realpathSync3, writeFileSync } from "fs";
1987
- import { homedir } from "os";
1988
- import path9 from "path";
1989
- function cacheHome(env) {
1990
- if (env.RULECAST_HOME) return path9.resolve(env.RULECAST_HOME);
1991
- if (env.XDG_CACHE_HOME) return path9.join(path9.resolve(env.XDG_CACHE_HOME), "rulecast");
1992
- return path9.join(homedir(), ".cache", "rulecast");
1993
- }
1994
- function projectStateDir(home, root) {
1995
- const hash = createHash2("sha256").update(realpathSync3(root)).digest("hex").slice(0, 16);
1996
- return path9.join(home, "projects", hash);
1997
- }
1998
- function ensureProjectState(home, root) {
1999
- const dir = projectStateDir(home, root);
2000
- const file = path9.join(dir, "root");
2001
- if (!existsSync(file)) {
2002
- mkdirSync(dir, { recursive: true });
2003
- writeFileSync(file, `${realpathSync3(root)}
2004
- `);
2005
- }
2006
- return dir;
2007
- }
2008
- function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
2009
- const file = path9.join(stateDir, "debug.log");
2010
- return (line) => {
2011
- try {
2012
- appendFileSync(file, `${now().toISOString()} ${line}
2013
- `);
2014
- } catch {
2015
- }
2016
- };
2017
- }
2018
-
2019
- // src/core/compile/project.ts
2020
- import { readFile as readFile3 } from "fs/promises";
2021
-
2022
- // src/core/files.ts
2023
- import path10 from "path";
2024
- var TYPE_TABLE = {
2025
- ".py": ["text", "python"],
2026
- ".pyi": ["text", "python", "pyi"],
2027
- ".ts": ["text", "ts"],
2028
- ".mts": ["text", "ts"],
2029
- ".cts": ["text", "ts"],
2030
- ".tsx": ["text", "tsx"],
2031
- ".js": ["text", "javascript"],
2032
- ".mjs": ["text", "javascript"],
2033
- ".cjs": ["text", "javascript"],
2034
- ".jsx": ["text", "jsx"],
2035
- ".md": ["text", "markdown"],
2036
- ".mdx": ["text", "mdx"],
2037
- ".yaml": ["text", "yaml"],
2038
- ".yml": ["text", "yaml"],
2039
- ".json": ["text", "json"],
2040
- ".toml": ["text", "toml"],
2041
- ".css": ["text", "css"],
2042
- ".scss": ["text", "scss"],
2043
- ".html": ["text", "html"],
2044
- ".sh": ["text", "shell"],
2045
- ".sql": ["text", "sql"],
2046
- ".go": ["text", "go"],
2047
- ".rs": ["text", "rust"],
2048
- ".txt": ["text", "plain-text"]
2049
- };
2050
- var KNOWN_TAGS = /* @__PURE__ */ new Set(["file", ...Object.values(TYPE_TABLE).flat()]);
2051
- var FILE_ONLY = /* @__PURE__ */ new Set(["file"]);
2052
- function tagsOf(file) {
2053
- const tags = TYPE_TABLE[path10.posix.extname(file).toLowerCase()];
2054
- return tags ? /* @__PURE__ */ new Set(["file", ...tags]) : FILE_ONLY;
2055
- }
2056
- function compileRegex(key, source) {
2057
- try {
2058
- return new RegExp(source);
2059
- } catch (error) {
2060
- return `${key}: invalid regex: ${errorMessage(error)}`;
2061
- }
2062
- }
2063
- function compileFilter(input) {
2064
- const files = compileRegex("files", input.files);
2065
- if (typeof files === "string") return files;
2066
- const exclude = compileRegex("exclude", input.exclude);
2067
- if (typeof exclude === "string") return exclude;
2068
- const unknown = [...input.types, ...input.typesOr, ...input.excludeTypes].find((tag) => !KNOWN_TAGS.has(tag));
2069
- if (unknown !== void 0) return `unknown file type "${unknown}"`;
2070
- const { types, typesOr, excludeTypes } = input;
2071
- return (file) => {
2072
- if (!files.test(file) || exclude.test(file)) return false;
2073
- const tags = tagsOf(file);
2074
- return types.every((tag) => tags.has(tag)) && (typesOr.length === 0 || typesOr.some((tag) => tags.has(tag))) && !excludeTypes.some((tag) => tags.has(tag));
2075
- };
2076
- }
2077
-
2078
- // src/core/compile/rule.ts
2079
- import path11 from "path";
2080
-
2081
- // src/core/anchors.ts
2082
- var FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
2083
- var FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
2084
- var ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
2085
- var SETEXT_1 = /^ {0,3}=+[ \t]*$/;
2086
- var SETEXT_2 = /^ {0,3}-+[ \t]*$/;
2087
- var LIST_ITEM = /^ {0,3}[-*+][ \t]/;
2088
- function linesOf(markdown2) {
2089
- return markdown2.split(/\r?\n/);
2090
- }
2091
- function slugify(text2) {
2092
- return text2.trim().toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "").replace(/ /g, "-");
2093
- }
2094
- function scanHeadings(markdown2) {
2095
- const lines = linesOf(markdown2);
2096
- const headings = [];
2097
- const seen = /* @__PURE__ */ new Map();
2098
- let fence = null;
2099
- const add = (level, text2, line) => {
2100
- const base = slugify(text2);
2101
- const count = seen.get(base) ?? 0;
2102
- seen.set(base, count + 1);
2103
- headings.push({ level, text: text2, slug: count === 0 ? base : `${base}-${count}`, line });
2104
- };
2105
- for (let i = 0; i < lines.length; i++) {
2106
- const line = lines[i];
2107
- if (fence) {
2108
- const close = line.match(FENCE_CLOSE);
2109
- if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
2110
- continue;
2111
- }
2112
- const open2 = line.match(FENCE_OPEN);
2113
- if (open2) {
2114
- fence = { char: open2[1][0], length: open2[1].length };
2115
- continue;
2116
- }
2117
- const atx = line.match(ATX);
2118
- if (atx) {
2119
- add(atx[1].length, (atx[2] ?? "").trim(), i + 1);
2120
- continue;
2121
- }
2122
- const next = lines[i + 1];
2123
- if (next === void 0 || line.trim() === "") continue;
2124
- if (SETEXT_1.test(next)) {
2125
- add(1, line.trim(), i + 1);
2126
- i++;
2127
- } else if (SETEXT_2.test(next) && !LIST_ITEM.test(line)) {
2128
- add(2, line.trim(), i + 1);
2129
- i++;
2130
- }
2131
- }
2132
- return headings;
2133
- }
2134
- function sectionRange(markdown2, slug) {
2135
- const headings = scanHeadings(markdown2);
2136
- const index = headings.findIndex((heading2) => heading2.slug === slug);
2137
- if (index === -1) return null;
2138
- const heading = headings[index];
2139
- const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
2140
- return { slug, start: heading.line, end: next ? next.line - 1 : linesOf(markdown2).length };
2141
- }
2142
- function sectionText(markdown2, section) {
2143
- return linesOf(markdown2).slice(section.start - 1, section.end).join("\n").trimEnd();
2144
- }
2145
- function sectionContains(markdown2, parentSlug, childSlug) {
2146
- const parent = sectionRange(markdown2, parentSlug);
2147
- const child = sectionRange(markdown2, childSlug);
2148
- if (!parent || !child) return false;
2149
- return child.start >= parent.start && child.end <= parent.end;
2150
- }
2151
-
2152
- // src/core/compile/rule.ts
2153
- function fileOf(spec) {
2154
- return spec.anchor === null ? spec.ref : spec.ref.slice(0, spec.ref.length - spec.anchor.length - 1);
2155
- }
2156
- async function compileRule(input, context) {
2157
- const { data } = input;
2158
- const minimum = data.minimum_rulecast_version;
2159
- if (minimum !== void 0 && isOlder(VERSION2, minimum)) {
2160
- return `requires rulecast ${minimum} or newer (running ${VERSION2})`;
2161
- }
2162
- let detector = null;
2163
- let defaultStages = ["touch"];
2164
- if (data.detect) {
2165
- const [kind, rawConfig] = Object.entries(data.detect)[0];
2166
- const implementation = context.registry.get(kind);
2167
- if (!implementation) return `unknown detector "${kind}"`;
2168
- const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
2169
- if (!parsed.success) return `detect.${kind}: ${formatZodError(parsed.error)}`;
2170
- detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
2171
- defaultStages = implementation.events(parsed.data);
2172
- }
2173
- const stages = data.stages ?? context.config.defaultStages ?? defaultStages;
2174
- if (detector) {
2175
- if (data.message === void 0) return "rules with detect need a message";
2176
- if (!stages.includes("edit") && !stages.includes("verify")) {
2177
- return "stages [touch] never run the detector: add edit or verify, or remove detect";
2178
- }
2179
- if (data.refuse_write) {
2180
- if (context.registry.get(detector.kind)?.guards !== true) {
2181
- return `refuse_write needs a detector that can judge a file before it is written; ${detector.kind} cannot`;
2182
- }
2183
- if (!stages.includes("edit")) return "refuse_write needs edit among the rule's stages";
2184
- }
2185
- const known = /* @__PURE__ */ new Set([...CORE_VARIABLES, ...detector.captures]);
2186
- const unknown = templateVariables(data.message).find((name) => !known.has(name));
2187
- if (unknown) return `unknown template variable "${unknown}"`;
2188
- } else {
2189
- if (data.refuse_write) return "refuse_write needs detect: there is nothing to refuse a write for";
2190
- if (stages.some((stage) => stage !== "touch")) return "rules without detect need stages: [touch]";
2191
- if (data.message !== void 0) return "message needs detect";
2192
- if (!data.context?.length) return "rules without detect need context";
2193
- }
2194
- const filter = compileFilter({
2195
- files: data.files ?? "",
2196
- exclude: data.exclude ?? "^$",
2197
- types: data.types ?? ["file"],
2198
- typesOr: data.types_or ?? [],
2199
- excludeTypes: data.exclude_types ?? []
2200
- });
2201
- if (typeof filter === "string") return filter;
2202
- const references = [];
2203
- for (const reference of data.context ?? []) {
2204
- let spec;
2205
- try {
2206
- spec = parseReference(reference, context.config.context.mode, input.contextRoot);
2207
- } catch (error) {
2208
- if (error instanceof ReferenceSyntaxError) return error.message;
2209
- throw error;
2210
- }
2211
- const text2 = await context.read(path11.resolve(input.contextRoot.dir, spec.path));
2212
- if (text2 === null) return `referenced file not found: ${fileOf(spec)}`;
2213
- if (spec.anchor !== null && !sectionRange(text2, spec.anchor)) {
2214
- return `anchor "#${spec.anchor}" not found in ${fileOf(spec)}`;
2215
- }
2216
- references.push(spec);
2217
- }
2218
- const { global } = context;
2219
- return {
2220
- id: data.alias ?? data.id,
2221
- name: data.name,
2222
- description: data.description ?? null,
2223
- source: input.source,
2224
- severity: data.severity ?? "error",
2225
- refuseWrite: data.refuse_write === true,
2226
- stages,
2227
- matches: (file) => global(file) && filter(file),
2228
- detector,
2229
- message: data.message ?? null,
2230
- context: references
2231
- };
2232
- }
2233
-
2234
- // src/core/compile/project.ts
2235
- function diagnosticText(diagnostic) {
2236
- const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
2237
- return `${diagnostic.source}${rule}: ${diagnostic.message}`;
2238
- }
2239
- function createReader() {
2240
- const texts = /* @__PURE__ */ new Map();
2241
- return (file) => {
2242
- let text2 = texts.get(file);
2243
- if (!text2) {
2244
- text2 = readFile3(file, "utf8").catch((error) => {
2245
- if (isNotFound(error)) return null;
2246
- throw error;
2247
- });
2248
- texts.set(file, text2);
2249
- }
2250
- return text2;
2251
- };
2252
- }
2253
- function idOf(raw) {
2254
- const id = raw?.id;
2255
- return typeof id === "string" ? id : null;
2256
- }
2257
- function isBranchLike(rev) {
2258
- return !rev.includes(".") && !/^[0-9a-f]+$/i.test(rev);
2259
- }
2260
- function uniqueRules(compiled, diagnostics, message) {
2261
- const counts = /* @__PURE__ */ new Map();
2262
- for (const { rule } of compiled) counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1);
2263
- const rules = [];
2264
- for (const { rule, source } of compiled) {
2265
- if (counts.get(rule.id) === 1) rules.push(rule);
2266
- else diagnostics.push({ source, rule: rule.id, message: message(rule.id), level: "error" });
2267
- }
2268
- return rules;
2269
- }
2270
- async function compile(options) {
2271
- const { root } = options;
2272
- const configFailure = (message, config2 = defaultConfig()) => ({
2273
- root,
2274
- config: config2,
2275
- rules: [],
2276
- diagnostics: [{ source: CONFIG_FILE, rule: null, message, level: "error" }]
2277
- });
2278
- const data = options.configData !== void 0 ? { ok: true, value: options.configData } : await readConfigData(root);
2279
- if (!data.ok) return configFailure(data.message);
2280
- const parsed = parseConfig(data.value);
2281
- if (!parsed.ok) return configFailure(parsed.message);
2282
- const config = parsed.value;
2283
- if (config.minimumRulecastVersion !== null && isOlder(VERSION2, config.minimumRulecastVersion)) {
2284
- return configFailure(`requires rulecast ${config.minimumRulecastVersion} or newer (running ${VERSION2})`, config);
2285
- }
2286
- const global = compileFilter({
2287
- files: config.files,
2288
- exclude: config.exclude,
2289
- types: [],
2290
- typesOr: [],
2291
- excludeTypes: []
2292
- });
2293
- if (typeof global === "string") return configFailure(global, config);
2294
- const diagnostics = [];
2295
- const compiled = [];
2296
- const context = { config, registry: options.registry, global, read: createReader() };
2297
- const project = { dir: root, label: null };
2298
- const error = (source, rule, message, hint) => diagnostics.push({ source, rule, message, level: "error", ...hint === void 0 ? {} : { hint } });
2299
- const add = async (input, source) => {
2300
- const result = await compileRule(input, context);
2301
- if (typeof result === "string") error(source, input.data.alias ?? input.data.id, result);
2302
- else compiled.push({ rule: result, source });
2303
- };
2304
- for (const [index, entry] of config.repos.entries()) {
2305
- if (entry.repo === "local") {
2306
- if (entry.rev !== void 0) {
2307
- error(CONFIG_FILE, null, `repos[${index}]: local repos take no rev`);
2308
- continue;
2309
- }
2310
- for (const raw of entry.rules) {
2311
- const rule = ruleSchema.safeParse(raw);
2312
- if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
2313
- else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
2314
- }
2315
- continue;
2316
- }
2317
- if (entry.rev === void 0) {
2318
- error(CONFIG_FILE, null, `repos[${index}] ${entry.repo}: rev is required`);
2319
- continue;
2320
- }
2321
- const source = `${entry.repo}@${entry.rev}`;
2322
- if (isBranchLike(entry.rev)) {
2323
- diagnostics.push({
2324
- source,
2325
- rule: null,
2326
- message: `rev "${entry.rev}" looks like a branch: pin a tag or a full commit SHA`,
2327
- level: "warning"
2328
- });
2329
- }
2330
- const checkout = await options.repos.checkout(entry.repo, entry.rev);
2331
- if (!checkout.ok) {
2332
- error(source, null, checkout.message, checkout.missing ? "rulecast install" : void 0);
2333
- continue;
2334
- }
2335
- const manifest = await readManifest(checkout.dir);
2336
- if (!manifest.ok) {
2337
- error(source, null, manifest.message);
2338
- continue;
2339
- }
2340
- const byId = /* @__PURE__ */ new Map();
2341
- for (const raw of manifest.value) {
2342
- const id = idOf(raw);
2343
- if (id !== null) byId.set(id, [...byId.get(id) ?? [], raw]);
2344
- }
2345
- const repoRoot = { dir: checkout.dir, label: checkout.label };
2346
- for (const raw of entry.rules) {
2347
- const override = overrideSchema.safeParse(raw);
2348
- if (!override.success) {
2349
- error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
2350
- continue;
2351
- }
2352
- const { id } = override.data;
2353
- const candidates = byId.get(id) ?? [];
2354
- if (candidates.length !== 1) {
2355
- error(source, id, candidates.length === 0 ? "not in the manifest" : "defined more than once in the manifest");
2356
- continue;
2357
- }
2358
- const base = ruleSchema.safeParse(candidates[0]);
2359
- if (!base.success) {
2360
- error(source, id, `manifest: ${formatZodError(base.error)}`);
2361
- continue;
2362
- }
2363
- const data2 = { ...base.data, ...override.data };
2364
- const contextRoot = "context" in override.data ? project : repoRoot;
2365
- await add({ data: data2, source: checkout.label, contextRoot }, source);
2366
- }
2367
- }
2368
- const rules = uniqueRules(
2369
- compiled,
2370
- diagnostics,
2371
- (id) => `rule "${id}" is configured more than once: give one an alias`
2372
- );
2373
- return { root, config, rules, diagnostics };
2374
- }
2375
- async function compileManifest(dir, registry) {
2376
- const manifest = await readManifest(dir);
2377
- if (!manifest.ok) {
2378
- return {
2379
- rules: [],
2380
- diagnostics: [{ source: MANIFEST_FILE, rule: null, message: manifest.message, level: "error" }]
2381
- };
2382
- }
2383
- const diagnostics = [];
2384
- const compiled = [];
2385
- const context = { config: defaultConfig(), registry, global: () => true, read: createReader() };
2386
- for (const raw of manifest.value) {
2387
- const rule = ruleSchema.safeParse(raw);
2388
- if (!rule.success) {
2389
- diagnostics.push({ source: MANIFEST_FILE, rule: idOf(raw), message: formatZodError(rule.error), level: "error" });
2390
- continue;
2391
- }
2392
- const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
2393
- if (typeof result === "string") {
2394
- diagnostics.push({
2395
- source: MANIFEST_FILE,
2396
- rule: rule.data.alias ?? rule.data.id,
2397
- message: result,
2398
- level: "error"
2399
- });
2400
- } else compiled.push({ rule: result, source: MANIFEST_FILE });
2401
- }
2402
- const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
2403
- return { rules, diagnostics };
2404
- }
2405
-
2406
- // src/core/detection/check.ts
2407
- function checkableKinds(project, registry) {
2408
- const kinds = /* @__PURE__ */ new Set();
2409
- for (const rule of project.rules) {
2410
- const kind = rule.detector?.kind;
2411
- if (kind !== void 0 && registry.get(kind)?.check !== void 0) kinds.add(kind);
2412
- }
2413
- return [...kinds];
2414
- }
2415
- async function checkDetectors(options) {
2416
- const { project, registry } = options;
2417
- const kinds = checkableKinds(project, registry);
2418
- const signal = AbortSignal.timeout(options.timeoutMs);
2419
- const perKind = await Promise.all(
2420
- kinds.map(async (kind) => {
2421
- const detector = registry.get(kind);
2422
- const rules = project.rules.filter((rule) => rule.detector?.kind === kind).map((rule) => ({ id: rule.id, config: rule.detector.config }));
2423
- try {
2424
- const results = await detector.check({
2425
- rules,
2426
- settings: options.settings,
2427
- env: options.env,
2428
- cwd: project.root,
2429
- signal
2430
- });
2431
- return results.map((result) => ({ kind, ...result }));
2432
- } catch (error) {
2433
- return [
2434
- {
2435
- kind,
2436
- what: kind,
2437
- level: "error",
2438
- detail: errorMessage(error),
2439
- rules: rules.map((rule) => rule.id)
2440
- }
2441
- ];
2442
- }
2443
- })
2444
- );
2445
- return perKind.flat();
2446
- }
2447
-
2448
- // src/core/repos/fetch.ts
2449
- import { randomUUID } from "crypto";
2450
- import { existsSync as existsSync2 } from "fs";
2451
- import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
2452
- import path14 from "path";
2453
-
2454
- // src/core/git.ts
2455
- import { execFile as execFile4 } from "child_process";
2456
- import { promisify as promisify4 } from "util";
2457
- var exec4 = promisify4(execFile4);
2458
- var INHERITED = [
2459
- "GIT_DIR",
2460
- "GIT_WORK_TREE",
2461
- "GIT_INDEX_FILE",
2462
- "GIT_COMMON_DIR",
2463
- "GIT_OBJECT_DIRECTORY",
2464
- "GIT_ALTERNATE_OBJECT_DIRECTORIES",
2465
- "GIT_PREFIX"
2466
- ];
2467
- function environment(extra) {
2468
- const env = { ...process.env, ...extra };
2469
- for (const name of INHERITED) delete env[name];
2470
- return env;
2471
- }
2472
- async function git(cwd, args, env) {
2473
- try {
2474
- const { stdout } = await exec4("git", args, {
2475
- cwd,
2476
- maxBuffer: 64 * 1024 * 1024,
2477
- env: environment(env)
2478
- });
2479
- return { ok: true, stdout };
2480
- } catch (error) {
2481
- const failure = error;
2482
- if (failure.code === "ENOENT") throw new Error("git is not installed or not on PATH");
2483
- if (typeof failure.code === "number") return { ok: false, stderr: failure.stderr ?? "" };
2484
- throw error;
2485
- }
2486
- }
2487
- async function headCommit(cwd) {
2488
- const result = await git(cwd, ["rev-parse", "--verify", "-q", "HEAD"]);
2489
- return result.ok ? result.stdout.trim() : null;
2490
- }
2491
- async function fileAtCommit(cwd, commit, file) {
2492
- const result = await git(cwd, ["show", `${commit}:./${file}`]);
2493
- return result.ok ? result.stdout : null;
2494
- }
2495
- async function mergeBase(cwd, ref, other = "HEAD") {
2496
- const result = await git(cwd, ["merge-base", other, ref]);
2497
- if (!result.ok) throw new Error(`cannot find merge base with ${ref}: ${result.stderr.trim()}`);
2498
- return result.stdout.trim();
2499
- }
2500
- async function changedFilesSince(cwd, commit) {
2501
- const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
2502
- if (!diff.ok) throw new Error(`git diff against ${commit} failed: ${diff.stderr.trim()}`);
2503
- const untracked = await git(cwd, ["ls-files", "--others", "--exclude-standard"]);
2504
- if (!untracked.ok) throw new Error(`git ls-files failed: ${untracked.stderr.trim()}`);
2505
- const files = [...diff.stdout.split("\n"), ...untracked.stdout.split("\n")].filter(Boolean);
2506
- return [...new Set(files)].sort();
2507
- }
2508
- function nulSeparated(stdout) {
2509
- return [...new Set(stdout.split("\0").filter(Boolean))].sort();
2510
- }
2511
- async function allFiles(cwd) {
2512
- const result = await git(cwd, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
2513
- if (!result.ok) throw new Error(`git ls-files failed: ${result.stderr.trim()}`);
2514
- return nulSeparated(result.stdout);
2515
- }
2516
- async function stagedFiles(cwd) {
2517
- const result = await git(cwd, ["diff", "--cached", "--name-only", "-z", "--relative", "--diff-filter=d"]);
2518
- if (!result.ok) throw new Error(`git diff --cached failed: ${result.stderr.trim()}`);
2519
- return nulSeparated(result.stdout);
2520
- }
2521
- async function changedFilesBetween(cwd, from, to) {
2522
- const result = await git(cwd, ["diff", "--name-only", "-z", "--relative", "--diff-filter=d", from, to]);
2523
- if (!result.ok) throw new Error(`git diff ${from} ${to} failed: ${result.stderr.trim()}`);
2524
- return nulSeparated(result.stdout);
2525
- }
2526
-
2527
- // src/core/session/lock.ts
2528
- import { mkdir, rm, stat } from "fs/promises";
2529
- import path12 from "path";
2530
- var LockTimeoutError = class extends Error {
2531
- };
2532
- var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
2533
- function isAlreadyExists(error) {
2534
- return error instanceof Error && error.code === "EEXIST";
2535
- }
2536
- async function withLock(dir, fn, options = DEFAULTS) {
2537
- const lock = path12.join(dir, ".lock");
2538
- await mkdir(dir, { recursive: true });
2539
- const started = Date.now();
2540
- for (; ; ) {
2541
- try {
2542
- await mkdir(lock);
2543
- break;
2544
- } catch (error) {
2545
- if (!isAlreadyExists(error)) throw error;
2546
- }
2547
- try {
2548
- const { mtimeMs } = await stat(lock);
2549
- if (Date.now() - mtimeMs > options.staleMs) {
2550
- await rm(lock, { recursive: true, force: true });
2551
- continue;
2552
- }
2553
- } catch (error) {
2554
- if (!isNotFound(error)) throw error;
2555
- continue;
2556
- }
2557
- if (Date.now() - started > options.waitMs)
2558
- throw new LockTimeoutError(`could not lock ${dir} within ${options.waitMs} ms`);
2559
- await new Promise((resolve) => setTimeout(resolve, options.retryMs));
2560
- }
2561
- try {
2562
- return await fn();
2563
- } finally {
2564
- await rm(lock, { recursive: true, force: true });
2565
- }
2566
- }
2567
-
2568
- // src/core/repos/layout.ts
2569
- import { createHash as createHash3 } from "crypto";
2570
- import path13 from "path";
2571
- import { fileURLToPath as fileURLToPath2 } from "url";
2572
- var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
2573
- var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
2574
- function segmentsOf(repoPath) {
2575
- return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
2576
- }
2577
- function localName(file) {
2578
- return path13.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
2579
- }
2580
- function parseUrl(url) {
2581
- if (SCHEME.test(url)) {
2582
- const parsed = new URL(url);
2583
- if (parsed.protocol === "file:") return { kind: "local", name: localName(fileURLToPath2(parsed)) };
2584
- return { kind: "hosted", host: parsed.hostname, segments: segmentsOf(decodeURIComponent(parsed.pathname)) };
2585
- }
2586
- const scp = url.match(SCP_LIKE);
2587
- if (scp) return { kind: "hosted", host: scp[1], segments: segmentsOf(scp[2]) };
2588
- return { kind: "local", name: localName(url) };
2589
- }
2590
- function safeSegment(value) {
2591
- const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
2592
- return /^\.*$/.test(segment) ? `_${segment}` : segment;
2593
- }
2594
- function repoName(url) {
2595
- const parsed = parseUrl(url);
2596
- return parsed.kind === "hosted" ? parsed.segments.join("/") || parsed.host : parsed.name;
2597
- }
2598
- function repoSlug(url) {
2599
- const parsed = parseUrl(url);
2600
- if (parsed.kind === "hosted") return safeSegment([parsed.host, ...parsed.segments].join("_"));
2601
- const hash = createHash3("sha256").update(url).digest("hex").slice(0, 8);
2602
- return safeSegment(`local_${parsed.name}_${hash}`);
2603
- }
2604
- function repoDir(home, url, rev) {
2605
- return path13.join(home, "repos", repoSlug(url), safeSegment(rev));
2606
- }
2607
- function repoLabel(url, rev) {
2608
- return `${repoName(url)}@${rev}`;
2609
- }
2610
-
2611
- // src/core/repos/fetch.ts
2612
- var RepoFetchError = class extends Error {
2613
- };
2614
- var NO_PROMPT = { GIT_TERMINAL_PROMPT: "0" };
2615
- var FETCH_LOCK = { waitMs: 12e4, staleMs: 5 * 6e4, retryMs: 50 };
2616
- async function run(cwd, args) {
2617
- const result = await git(cwd, args, NO_PROMPT);
2618
- if (!result.ok) throw new RepoFetchError(result.stderr.trim() || `git ${args[0]} failed`);
2619
- }
2620
- async function fetchCheckout(url, rev, into) {
2621
- await mkdir2(into, { recursive: true });
2622
- await run(into, ["init", "-q"]);
2623
- await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
2624
- await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
2625
- await rm2(path14.join(into, ".git"), { recursive: true, force: true });
2626
- }
2627
- function cachedRepo(home, url, rev) {
2628
- const dir = repoDir(home, url, rev);
2629
- return existsSync2(dir) ? dir : null;
2630
- }
2631
- async function ensureRepo(home, url, rev) {
2632
- const dir = repoDir(home, url, rev);
2633
- if (existsSync2(dir)) return dir;
2634
- const parent = path14.dirname(dir);
2635
- await withLock(
2636
- parent,
2637
- async () => {
2638
- if (existsSync2(dir)) return;
2639
- const temp = path14.join(parent, `.tmp-${randomUUID()}`);
2640
- try {
2641
- await fetchCheckout(url, rev, temp);
2642
- await rename(temp, dir);
2643
- } finally {
2644
- await rm2(temp, { recursive: true, force: true });
2645
- }
2646
- },
2647
- FETCH_LOCK
2648
- );
2649
- return dir;
2650
- }
2651
-
2652
- // src/core/repos/provider.ts
2653
- function cachedRepos(home) {
2654
- return {
2655
- async checkout(url, rev) {
2656
- const dir = cachedRepo(home, url, rev);
2657
- if (dir === null) return { ok: false, missing: true, message: "not in the cache" };
2658
- return { ok: true, dir, label: repoLabel(url, rev) };
2659
- }
2660
- };
2661
- }
2662
- function fetchingRepos(home) {
2663
- return {
2664
- async checkout(url, rev) {
2665
- try {
2666
- return { ok: true, dir: await ensureRepo(home, url, rev), label: repoLabel(url, rev) };
2667
- } catch (error) {
2668
- return { ok: false, missing: false, message: `fetch failed: ${errorMessage(error)}` };
2669
- }
2670
- }
2671
- };
2672
- }
2673
- function fixedRepo(dir, label) {
2674
- return {
2675
- async checkout() {
2676
- return { ok: true, dir, label };
2677
- }
2678
- };
2679
- }
2680
-
2681
- // src/core/baseline/changes.ts
2682
- import { diffArrays } from "diff";
2683
- function changedLines(before, after) {
2684
- const ranges = [];
2685
- const mark = (start, end) => {
2686
- const previous = ranges.at(-1);
2687
- if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
2688
- else ranges.push([start, end]);
2689
- };
2690
- let line = 1;
2691
- for (const part of diffArrays(Array.from(before), Array.from(after))) {
2692
- const count = part.count ?? part.value.length;
2693
- if (part.added) {
2694
- mark(line, line + count - 1);
2695
- line += count;
2696
- } else if (part.removed) {
2697
- if (after.length > 0) {
2698
- const at = Math.min(line, after.length);
2699
- mark(at, at);
2700
- }
2701
- } else {
2702
- line += count;
2703
- }
2704
- }
2705
- return ranges;
2706
- }
2707
-
2708
- // src/core/baseline/hash.ts
2709
- var OFFSET_BASIS = 2166136261;
2710
- var PRIME = 16777619;
2711
- function fnv1a(text2) {
2712
- let hash = OFFSET_BASIS;
2713
- for (let i = 0; i < text2.length; i++) {
2714
- hash ^= text2.charCodeAt(i);
2715
- hash = Math.imul(hash, PRIME);
2716
- }
2717
- return hash >>> 0;
2718
- }
2719
- function hashLines(text2) {
2720
- const lines = text2.split(/\r?\n/);
2721
- const hashes = new Uint32Array(lines.length);
2722
- lines.forEach((line, index) => {
2723
- hashes[index] = fnv1a(line.trim());
2724
- });
2725
- return hashes;
2726
- }
2727
- function hashOfHashes(lines) {
2728
- let hash = OFFSET_BASIS;
2729
- for (const value of lines) {
2730
- for (let shift = 0; shift < 32; shift += 8) {
2731
- hash ^= value >>> shift & 255;
2732
- hash = Math.imul(hash, PRIME);
2733
- }
2734
- }
2735
- return hash >>> 0;
2736
- }
2737
- function snapshotOf(text2) {
2738
- const lines = hashLines(text2);
2739
- return { fileHash: hashOfHashes(lines), lines };
2740
- }
2741
-
2742
- // src/core/baseline/baseline.ts
2743
- async function computeChanges(root, files, sources) {
2744
- const changes = /* @__PURE__ */ new Map();
2745
- for (const file of files) {
2746
- if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
2747
- let before = sources.snapshots.get(file) ?? null;
2748
- if (!before && sources.fallbackCommit) {
2749
- const text2 = await fileAtCommit(root, sources.fallbackCommit, file);
2750
- before = text2 === null ? null : snapshotOf(text2);
2751
- }
2752
- if (!before) continue;
2753
- const current = await readSourceFile(root, file);
2754
- if (current === null) continue;
2755
- const after = snapshotOf(current);
2756
- changes.set(file, {
2757
- changedLines: after.fileHash === before.fileHash ? [] : changedLines(before.lines, after.lines)
2758
- });
2759
- }
2760
- return changes;
2761
- }
2762
- function isNew(match, changes) {
2763
- const change = changes.get(match.file);
2764
- if (!change) return true;
2765
- return change.changedLines.some(([start, end]) => match.line <= end && match.endLine >= start);
2766
- }
2767
-
2768
- // src/core/baseline/store.ts
2769
- import path16 from "path";
2770
-
2771
- // src/core/jsonl.ts
2772
- import { mkdir as mkdir3, open, readFile as readFile4 } from "fs/promises";
2773
- import path15 from "path";
2774
- var CorruptStoreError = class extends Error {
2775
- };
2776
- async function appendRecords(file, records) {
2777
- if (records.length === 0) return;
2778
- await mkdir3(path15.dirname(file), { recursive: true });
2779
- const handle = await open(file, "a+");
2780
- try {
2781
- await dropPartialRecord(handle);
2782
- await handle.appendFile(records.map((record) => `${JSON.stringify(record)}
2783
- `).join(""));
2784
- } finally {
2785
- await handle.close();
2786
- }
2787
- }
2788
- var TAIL_CHUNK = 64 * 1024;
2789
- async function dropPartialRecord(handle) {
2790
- const { size } = await handle.stat();
2791
- let end = size;
2792
- while (end > 0) {
2793
- const start = Math.max(0, end - TAIL_CHUNK);
2794
- const chunk = Buffer.alloc(end - start);
2795
- await handle.read(chunk, 0, chunk.length, start);
2796
- const newline = chunk.lastIndexOf(10);
2797
- if (newline !== -1) {
2798
- end = start + newline + 1;
2799
- break;
2800
- }
2801
- end = start;
2802
- }
2803
- if (end < size) await handle.truncate(end);
2804
- }
2805
- async function readRecords(file) {
2806
- let text2;
2807
- try {
2808
- text2 = await readFile4(file, "utf8");
2809
- } catch (error) {
2810
- if (isNotFound(error)) return [];
2811
- throw error;
2812
- }
2813
- const lines = text2.split("\n");
2814
- const records = [];
2815
- for (let i = 0; i < lines.length; i++) {
2816
- const line = lines[i];
2817
- if (line.trim() === "") continue;
2818
- try {
2819
- records.push(JSON.parse(line));
2820
- } catch {
2821
- if (i === lines.length - 1) break;
2822
- throw new CorruptStoreError(`${file}: unparseable record on line ${i + 1}`);
2823
- }
2824
- }
2825
- return records;
2826
- }
2827
-
2828
- // src/core/baseline/store.ts
2829
- function storeFile(sessionDir2) {
2830
- return path16.join(sessionDir2, "baseline.jsonl");
2831
- }
2832
- function encodeLines(lines) {
2833
- return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
2834
- }
2835
- function decodeLines(encoded) {
2836
- const bytes = Buffer.from(encoded, "base64");
2837
- const aligned = new Uint8Array(bytes.length);
2838
- aligned.set(bytes);
2839
- return new Uint32Array(aligned.buffer);
2840
- }
2841
- function startRecord(commit) {
2842
- return { t: "start", commit };
2843
- }
2844
- function snapshotRecord(file, snapshot) {
2845
- return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
2846
- }
2847
- async function appendBaseline(sessionDir2, records) {
2848
- await appendRecords(storeFile(sessionDir2), records);
2849
- }
2850
- async function readBaseline(sessionDir2) {
2851
- const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map() };
2852
- for (const record of await readRecords(storeFile(sessionDir2))) {
2853
- if (record.t === "start" && !state.started) {
2854
- state.started = true;
2855
- state.startCommit = record.commit;
2856
- } else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
2857
- state.snapshots.set(record.file, { fileHash: record.fileHash, lines: decodeLines(record.lines) });
2858
- }
2859
- }
2860
- return state;
2861
- }
2862
-
2863
- // src/core/delivery/persist.ts
2864
- import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
2865
- import path17 from "path";
2866
- var KEEP = 3;
2867
- var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
2868
- var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
2869
- function markdown(delivery, now) {
2870
- const body = renderAgentText({ ...delivery, overflowPath: null }, UNLIMITED);
2871
- return [
2872
- `# rulecast delivery \u2014 ${now.toISOString()}`,
2873
- "",
2874
- "Too much to fit in one hook message. Everything rulecast found for the files in this session,",
2875
- "with the doc section each rule cites:",
2876
- "",
2877
- body,
2878
- ""
2879
- ].join("\n");
2880
- }
2881
- function writeOverflow(stateDir, session, delivery, now = /* @__PURE__ */ new Date()) {
2882
- const prefix = safeSegment2(session ?? "no-session");
2883
- const dir = path17.join(stateDir, "deliveries");
2884
- const file = path17.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
2885
- try {
2886
- mkdirSync2(dir, { recursive: true });
2887
- writeFileSync2(file, markdown(delivery, now));
2888
- const mine = readdirSync(dir).filter((name) => name.startsWith(`${prefix}-`)).sort();
2889
- for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path17.join(dir, old), { force: true });
2890
- return file;
2891
- } catch {
2892
- return null;
2893
- }
2894
- }
2895
-
2896
- // src/core/delivery/resolve.ts
2897
- function createReferenceResolver(root) {
2898
- const texts = /* @__PURE__ */ new Map();
2899
- const read = (path21) => {
2900
- let text2 = texts.get(path21);
2901
- if (!text2) {
2902
- text2 = readSourceFile(root, path21);
2903
- texts.set(path21, text2);
2904
- }
2905
- return text2;
2906
- };
2907
- const contentOf = async (path21, anchor) => {
2908
- const text2 = await read(path21);
2909
- if (text2 === null) return null;
2910
- if (anchor === null) return text2.trimEnd();
2911
- const section = sectionRange(text2, anchor);
2912
- return section ? sectionText(text2, section) : null;
2913
- };
2914
- return {
2915
- async resolve(spec) {
2916
- const content = await contentOf(spec.path, spec.anchor);
2917
- if (content === null) return { spec, found: false };
2918
- return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
2919
- },
2920
- async currentHash(path21, anchor) {
2921
- const content = await contentOf(path21, anchor);
2922
- return content === null ? null : fnv1a(content);
2923
- },
2924
- async contains(path21, parent, child) {
2925
- if (parent === null) return true;
2926
- if (child === null) return false;
2927
- const text2 = await read(path21);
2928
- return text2 !== null && sectionContains(text2, parent, child);
2929
- }
2930
- };
2931
- }
2932
-
2933
- // src/core/detection/budget.ts
2934
- function applyFileBudget(kind, selections, maxFiles, recent) {
2935
- const ofKind = selections.filter((selection) => selection.rule.detector?.kind === kind);
2936
- const files = [...new Set(ofKind.flatMap((selection) => selection.files))];
2937
- if (files.length <= maxFiles) return { selections, skipped: [] };
2938
- const rank = new Map(recent.map((file, index) => [file, index]));
2939
- const ordered = [...files].sort((a, b) => (rank.get(a) ?? recent.length) - (rank.get(b) ?? recent.length));
2940
- const keep = new Set(ordered.slice(0, maxFiles));
2941
- return {
2942
- selections: selections.map(
2943
- (selection) => selection.rule.detector?.kind === kind ? { ...selection, files: selection.files.filter((file) => keep.has(file)) } : selection
2944
- ).filter((selection) => selection.files.length > 0),
2945
- skipped: ordered.slice(maxFiles)
2946
- };
2947
- }
2948
-
2949
- // src/core/detection/cache.ts
2950
- import { createHash as createHash4, randomUUID as randomUUID2 } from "crypto";
2951
- import { mkdir as mkdir4, readFile as readFile5, rename as rename2, writeFile } from "fs/promises";
2952
- import path18 from "path";
2953
- function memoryCache() {
2954
- const values = /* @__PURE__ */ new Map();
2955
- return {
2956
- async get(key) {
2957
- return values.has(key) ? structuredClone(values.get(key)) : void 0;
2958
- },
2959
- async set(key, value) {
2960
- values.set(key, structuredClone(value));
2961
- }
2962
- };
2963
- }
2964
- function diskCache(dir) {
2965
- const fileFor = (key) => path18.join(dir, `${createHash4("sha256").update(key).digest("hex")}.json`);
2966
- return {
2967
- async get(key) {
2968
- try {
2969
- return JSON.parse(await readFile5(fileFor(key), "utf8"));
2970
- } catch (error) {
2971
- if (isNotFound(error)) return void 0;
2972
- throw error;
2973
- }
2974
- },
2975
- async set(key, value) {
2976
- await mkdir4(dir, { recursive: true });
2977
- const target = fileFor(key);
2978
- const temp = `${target}.${randomUUID2()}.tmp`;
2979
- await writeFile(temp, JSON.stringify(value));
2980
- await rename2(temp, target);
2981
- }
2982
- };
2983
- }
2984
- function detectorCacheDir(stateDir, kind) {
2985
- return path18.join(stateDir, "cache", kind);
2986
- }
2987
-
2988
- // src/core/detection/run.ts
2989
- var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
2990
- async function runDetection(input) {
2991
- const output = { findings: [], errors: [], timedOut: [] };
2992
- const byKind = /* @__PURE__ */ new Map();
2993
- for (const selection of input.selections) {
2994
- const kind = selection.rule.detector.kind;
2995
- byKind.set(kind, [...byKind.get(kind) ?? [], selection]);
2996
- }
2997
- const controller = new AbortController();
2998
- const deadline = new Promise((resolve) => {
2999
- controller.signal.addEventListener("abort", () => resolve(TIMED_OUT), { once: true });
3000
- });
3001
- const timer = setTimeout(() => controller.abort(), input.timeoutMs);
3002
- const perKind = await Promise.all(
3003
- [...byKind].map(async ([kind, selections]) => {
3004
- const detector = input.registry.get(kind);
3005
- if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
3006
- try {
3007
- const rules = await Promise.all(
3008
- selections.map(async ({ rule, files }) => ({
3009
- id: rule.id,
3010
- config: rule.detector.config,
3011
- files,
3012
- context: await input.contextFor(rule)
3013
- }))
3014
- );
3015
- const result = await Promise.race([
3016
- detector.run({
3017
- event: input.event,
3018
- rules,
3019
- read: input.read,
3020
- changes: input.changes,
3021
- cache: input.cacheFor(kind),
3022
- settings: input.settings,
3023
- cwd: input.root,
3024
- signal: controller.signal
3025
- }),
3026
- deadline
3027
- ]);
3028
- if (result === TIMED_OUT) return { status: "timedOut", kind, selections };
3029
- return { status: "ok", kind, selections, result };
3030
- } catch (error) {
3031
- if (controller.signal.aborted) return { status: "timedOut", kind, selections };
3032
- return { status: "error", kind, selections, message: errorMessage(error) };
3033
- }
3034
- })
3035
- );
3036
- clearTimeout(timer);
3037
- for (const outcome of perKind) {
3038
- const ids = outcome.selections.map((selection) => selection.rule.id);
3039
- if (outcome.status === "timedOut") {
3040
- output.timedOut.push({ kind: outcome.kind, rules: ids });
3041
- continue;
3042
- }
3043
- if (outcome.status === "error") {
3044
- output.errors.push({ kind: outcome.kind, rules: ids, message: outcome.message });
3045
- continue;
3046
- }
3047
- const result = outcome.result;
3048
- const rulesById = new Map(outcome.selections.map((selection) => [selection.rule.id, selection.rule]));
3049
- const stray = result.findings.find((finding) => !rulesById.has(finding.rule));
3050
- const wholeRun = result.errors.find((error) => error.rule === null);
3051
- if (stray || wholeRun) {
3052
- const message = stray ? `detector reported unknown rule "${stray.rule}"` : wholeRun.message;
3053
- output.errors.push({ kind: outcome.kind, rules: ids, message });
3054
- continue;
3055
- }
3056
- const failed = /* @__PURE__ */ new Map();
3057
- for (const error of result.errors) failed.set(error.rule, error.message);
3058
- for (const finding of result.findings) {
3059
- const rule = rulesById.get(finding.rule);
3060
- const missing = rule.detector.captures.find((name) => typeof finding.match.captures[name] !== "string");
3061
- if (missing && !failed.has(rule.id)) failed.set(rule.id, `match is missing declared capture "${missing}"`);
3062
- }
3063
- for (const [rule, message] of failed) output.errors.push({ kind: outcome.kind, rules: [rule], message });
3064
- for (const finding of result.findings) {
3065
- if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
3066
- }
3067
- }
3068
- return output;
3069
- }
3070
-
3071
- // src/core/detection/select.ts
3072
- function selectDetectorRules(rules, event, files, disabled) {
3073
- return rules.filter((rule) => rule.detector !== null && rule.stages.includes(event) && !disabled.has(rule.id)).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
3074
- }
3075
- function selectTouchRules(rules, files, touched, disabled) {
3076
- return rules.filter(
3077
- (rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
3078
- );
3079
- }
3080
-
3081
- // src/core/detection/proposal.ts
3082
- function propose(current, intent) {
3083
- if (intent.content !== void 0) {
3084
- return { content: intent.content, inserts: intent.content === "" ? [] : [[0, intent.content.length]] };
3085
- }
3086
- if (intent.edit === void 0 || current === null) return null;
3087
- const { find, replace, all } = intent.edit;
3088
- if (find === "") return null;
3089
- const first = current.indexOf(find);
3090
- if (first === -1) return null;
3091
- if (!all && current.indexOf(find, first + find.length) !== -1) return null;
3092
- let content = "";
3093
- let rest = current;
3094
- const inserts = [];
3095
- for (; ; ) {
3096
- const at = rest.indexOf(find);
3097
- if (at === -1) break;
3098
- const start = content.length + at;
3099
- content += rest.slice(0, at) + replace;
3100
- if (replace !== "") inserts.push([start, start + replace.length]);
3101
- rest = rest.slice(at + find.length);
3102
- if (!all) break;
3103
- }
3104
- return { content: content + rest, inserts };
3105
- }
3106
- function isInserted(proposal, match, detector) {
3107
- if (detector === "path") return true;
3108
- const starts = lineStarts(proposal.content);
3109
- const lineStart = starts[match.line - 1];
3110
- if (lineStart === void 0) return false;
3111
- const start = lineStart + match.column - 1;
3112
- const end = start + match.text.length;
3113
- return proposal.inserts.some(([from, to]) => start < to && end > from);
3114
- }
3115
-
3116
- // src/core/session/decide.ts
3117
- import path19 from "path";
3118
-
3119
- // src/core/session/state.ts
3120
- function preexistingKey(rule, file) {
3121
- return `${rule} ${file}`;
3122
- }
3123
- var refusalKey = preexistingKey;
3124
- function emptyWork() {
3125
- return { edited: [], stopBlocks: /* @__PURE__ */ new Map(), refusals: /* @__PURE__ */ new Map(), disabled: /* @__PURE__ */ new Map(), accessed: /* @__PURE__ */ new Map() };
3126
- }
3127
- function emptyContext() {
3128
- return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
3129
- }
3130
- function foldWork(records) {
3131
- const state = emptyWork();
3132
- for (const record of records) {
3133
- switch (record.t) {
3134
- case "edited":
3135
- state.edited = [...state.edited.filter((file) => file !== record.file), record.file];
3136
- break;
3137
- case "accessed": {
3138
- const files = state.accessed.get(record.agent) ?? [];
3139
- state.accessed.set(record.agent, [...files.filter((file) => file !== record.file), record.file]);
3140
- break;
3141
- }
3142
- case "stopBlock":
3143
- state.stopBlocks.set(record.agent, (state.stopBlocks.get(record.agent) ?? 0) + 1);
3144
- break;
3145
- case "refused": {
3146
- const key = refusalKey(record.rule, record.file);
3147
- state.refusals.set(key, (state.refusals.get(key) ?? 0) + 1);
3148
- break;
3149
- }
3150
- case "prompt":
3151
- state.stopBlocks.delete(record.agent);
3152
- break;
3153
- case "disabled":
3154
- if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
3155
- break;
3156
- }
3157
- }
3158
- return state;
3159
- }
3160
- function foldContext(records) {
3161
- let state = emptyContext();
3162
- for (const record of records) {
3163
- switch (record.t) {
3164
- case "reset":
3165
- state = emptyContext();
3166
- break;
3167
- case "delivered":
3168
- state.delivered.push({ path: record.path, anchor: record.anchor, hash: record.hash });
3169
- break;
3170
- case "touched":
3171
- state.touched.add(record.rule);
3172
- break;
3173
- case "preexisting":
3174
- state.preexisting.add(preexistingKey(record.rule, record.file));
3175
- break;
3176
- case "warned":
3177
- state.warned.add(record.key);
3178
- break;
3179
- }
3180
- }
3181
- return state;
3182
- }
3183
-
3184
- // src/core/session/decide.ts
3185
- var ITEM_OVERHEAD = 64;
3186
- var HEADER_OVERHEAD = 128;
3187
- var OVERFLOW_NOTICE = 256;
3188
- var REFERENCE_OVERHEAD = 96;
3189
- function locationOf2(spec) {
3190
- return path19.isAbsolute(spec.path) ? { location: spec.path } : {};
3191
- }
3192
- function capturesFor(rule, match) {
3193
- const names = templateBindings(rule.message ?? "");
3194
- if (names.length === 0) return void 0;
3195
- const values = {};
3196
- for (const name of names) {
3197
- const value = name === "text" ? match.text : match.captures[name];
3198
- if (value !== void 0) values[name] = value;
3199
- }
3200
- return values;
3201
- }
3202
- function renderFindings(findings) {
3203
- const merged = /* @__PURE__ */ new Map();
3204
- for (const { rule, match } of findings) {
3205
- const message = renderTemplate(rule.message ?? "", {
3206
- ...match.captures,
3207
- file: match.file,
3208
- line: String(match.line),
3209
- column: String(match.column),
3210
- text: match.text,
3211
- rule: rule.id
3212
- });
3213
- const key = `${rule.id} ${match.file} ${message}`;
3214
- const existing = merged.get(key);
3215
- if (existing) existing.count++;
3216
- else
3217
- merged.set(key, {
3218
- rule: rule.id,
3219
- severity: rule.severity,
3220
- status: "new",
3221
- file: match.file,
3222
- line: match.line,
3223
- column: match.column,
3224
- message,
3225
- count: 1,
3226
- captures: capturesFor(rule, match)
3227
- });
3228
- }
3229
- return [...merged.values()].sort(
3230
- (a, b) => Number(a.severity === "warning") - Number(b.severity === "warning") || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0) || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line
3231
- );
3232
- }
3233
- function referencesInOrder(rules) {
3234
- const seen = /* @__PURE__ */ new Set();
3235
- const specs = [];
3236
- for (const rule of rules) {
3237
- for (const spec of rule.context) {
3238
- if (seen.has(spec.ref)) continue;
3239
- seen.add(spec.ref);
3240
- specs.push(spec);
3241
- }
3242
- }
3243
- return specs;
3244
- }
3245
- async function isCovered(spec, delivered, resolver) {
3246
- for (const entry of delivered) {
3247
- if (entry.path !== spec.path) continue;
3248
- if (!await resolver.contains(spec.path, entry.anchor, spec.anchor)) continue;
3249
- if (await resolver.currentHash(entry.path, entry.anchor) === entry.hash) return true;
3250
- }
3251
- return false;
3252
- }
3253
- async function decide(input) {
3254
- const delivery = emptyDelivery();
3255
- const work = [];
3256
- const context = [];
3257
- const fresh = input.findings.filter((finding) => finding.status === "new");
3258
- delivery.findings = renderFindings(fresh);
3259
- const rulesById = new Map(fresh.map(({ rule }) => [rule.id, rule]));
3260
- for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
3261
- const summaries = /* @__PURE__ */ new Map();
3262
- for (const { rule, match, status } of input.findings) {
3263
- if (status !== "preexisting") continue;
3264
- const key = preexistingKey(rule.id, match.file);
3265
- if (input.context.preexisting.has(key)) continue;
3266
- const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
3267
- summary.count++;
3268
- summaries.set(key, summary);
3269
- }
3270
- delivery.preexistingSummary = [...summaries.values()];
3271
- for (const warning of input.warnings) {
3272
- if (input.context.warned.has(warning.key)) continue;
3273
- delivery.warnings.push(warning.text);
3274
- context.push({ t: "warned", key: warning.key });
3275
- }
3276
- delivery.touches = input.touches.map((rule) => rule.id);
3277
- for (const rule of input.touches) context.push({ t: "touched", rule: rule.id });
3278
- const delivered = [...input.context.delivered];
3279
- if (input.agentRead !== null) {
3280
- const hash = await input.resolver.currentHash(input.agentRead, null);
3281
- if (hash !== null) {
3282
- delivered.push({ path: input.agentRead, anchor: null, hash });
3283
- context.push({ t: "delivered", path: input.agentRead, anchor: null, hash });
3284
- }
3285
- }
3286
- const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...input.touches];
3287
- const candidates = [];
3288
- for (const spec of referencesInOrder(ruleOrder)) {
3289
- const resolved = await input.resolver.resolve(spec);
3290
- if (!resolved.found) {
3291
- delivery.references.push({ ref: spec.ref, state: "missing" });
3292
- } else if (await isCovered(spec, delivered, input.resolver)) {
3293
- delivery.references.push({ ref: spec.ref, state: "pointer" });
3294
- } else if (spec.mode === "read") {
3295
- delivery.references.push({ ref: spec.ref, state: "read", reason: "mode", ...locationOf2(spec) });
3296
- } else if (resolved.bytes > input.maxBytes) {
3297
- delivery.references.push({ ref: spec.ref, state: "read", reason: "tooLarge", ...locationOf2(spec) });
3298
- } else {
3299
- candidates.push({ index: delivery.references.length, resolved });
3300
- delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
3301
- }
3302
- }
3303
- const limit = input.maxContextChars;
3304
- const render = { maxMatchesPerRule: input.maxMatchesPerRule };
3305
- const complete = {
3306
- ...delivery,
3307
- findings: [...delivery.findings],
3308
- preexistingSummary: [...delivery.preexistingSummary],
3309
- references: [...delivery.references],
3310
- omitted: { findings: [], rules: 0, preexisting: 0 }
3311
- };
3312
- const byRule = /* @__PURE__ */ new Map();
3313
- for (const finding of delivery.findings) byRule.set(finding.rule, [...byRule.get(finding.rule) ?? [], finding]);
3314
- let used = HEADER_OVERHEAD + delivery.warnings.reduce((sum, warning) => sum + warning.length + ITEM_OVERHEAD, 0);
3315
- const fits = (size) => limit === null || used + size <= limit;
3316
- const kept = /* @__PURE__ */ new Map();
3317
- const lineCost = (ref) => ref.length + REFERENCE_OVERHEAD;
3318
- const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
3319
- const charged = /* @__PURE__ */ new Set();
3320
- const orphaned = /* @__PURE__ */ new Set();
3321
- const floorWithin = (cap) => {
3322
- const spent = { chars: 0, kept: /* @__PURE__ */ new Map(), refs: /* @__PURE__ */ new Set(), dropped: 0 };
3323
- const uncharged = (rule) => [
3324
- ...new Set(rule.context.map((spec) => spec.ref).filter((ref) => resolvedRefs.has(ref) && !spent.refs.has(ref)))
3325
- ];
3326
- for (const [id, findings] of byRule) {
3327
- const rule = rulesById.get(id);
3328
- const refs = rule === void 0 ? [] : uncharged(rule);
3329
- const size = measureRuleBlock(id, [findings[0]], delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
3330
- if (used + spent.chars + size > cap) {
3331
- spent.dropped++;
3332
- continue;
3333
- }
3334
- spent.chars += size;
3335
- for (const ref of refs) spent.refs.add(ref);
3336
- spent.kept.set(id, [findings[0]]);
3337
- }
3338
- for (const touch of input.touches) {
3339
- for (const ref of uncharged(touch)) {
3340
- spent.chars += lineCost(ref);
3341
- spent.refs.add(ref);
3342
- }
3343
- }
3344
- return spent;
3345
- };
3346
- if (limit !== null) {
3347
- let floor = floorWithin(limit);
3348
- if (floor.dropped > 0) floor = floorWithin(limit - OVERFLOW_NOTICE);
3349
- used += floor.chars + (floor.dropped > 0 ? OVERFLOW_NOTICE : 0);
3350
- delivery.omitted.rules = floor.dropped;
3351
- for (const [id, findings] of floor.kept) kept.set(id, findings);
3352
- for (const ref of floor.refs) charged.add(ref);
3353
- for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
3354
- }
3355
- for (const { index, resolved } of candidates) {
3356
- const size = resolved.content.length;
3357
- if (orphaned.has(resolved.spec.ref)) continue;
3358
- if (!fits(size)) {
3359
- delivery.references[index] = {
3360
- ref: resolved.spec.ref,
3361
- state: "read",
3362
- reason: "budget",
3363
- ...locationOf2(resolved.spec)
3364
- };
3365
- continue;
3366
- }
3367
- used += size;
3368
- context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
3369
- }
3370
- const summariesKept = [];
3371
- for (const summary of delivery.preexistingSummary) {
3372
- if (!fits(summary.rule.length + summary.file.length + ITEM_OVERHEAD)) {
3373
- delivery.omitted.preexisting++;
3374
- continue;
3375
- }
3376
- used += summary.rule.length + summary.file.length + ITEM_OVERHEAD;
3377
- summariesKept.push(summary);
3378
- context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
3379
- }
3380
- delivery.preexistingSummary = summariesKept;
3381
- if (orphaned.size > 0) delivery.references = delivery.references.filter((one) => !orphaned.has(one.ref));
3382
- if (limit !== null) {
3383
- for (let added = true; added; ) {
3384
- added = false;
3385
- for (const [rule, findings] of byRule) {
3386
- const shown = kept.get(rule);
3387
- if (shown === void 0 || shown.length >= Math.min(findings.length, input.maxMatchesPerRule)) continue;
3388
- const template = delivery.templates[rule];
3389
- const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
3390
- if (!fits(delta)) continue;
3391
- used += delta;
3392
- shown.push(findings[shown.length]);
3393
- added = true;
3394
- }
3395
- }
3396
- for (const [rule, findings] of byRule) {
3397
- const shown = kept.get(rule) ?? [];
3398
- if (shown.length === 0 || shown.length === findings.length) continue;
3399
- const shownFiles = new Set(shown.map((finding) => finding.file));
3400
- const dropped = findings.slice(shown.length);
3401
- delivery.omitted.findings.push({
3402
- rule,
3403
- count: dropped.length,
3404
- files: new Set(dropped.map((finding) => finding.file).filter((file) => !shownFiles.has(file))).size
3405
- });
3406
- }
3407
- delivery.findings = delivery.findings.filter((finding) => kept.get(finding.rule)?.includes(finding) === true);
3408
- }
3409
- if (input.stopGate) {
3410
- const newErrors = delivery.findings.some((finding) => finding.severity === "error");
3411
- const blocks = input.work.stopBlocks.get(input.agent) ?? 0;
3412
- if (!newErrors) delivery.stop = "allow";
3413
- else if (blocks < input.maxBlocks) {
3414
- delivery.stop = "block";
3415
- work.push({ t: "stopBlock", agent: input.agent });
3416
- } else delivery.stop = "capReached";
3417
- if (delivery.stop !== "block") return { delivery, overflow: null, work, context: [] };
3418
- }
3419
- return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context };
3420
- }
3421
-
3422
- // src/core/guard.ts
3423
- async function guardWrite(input) {
3424
- const file = input.event.files[0];
3425
- const intent = input.event.intent;
3426
- if (file === void 0 || intent === void 0) return emptyDelivery();
3427
- const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
3428
- if (rules.length === 0) return emptyDelivery();
3429
- const proposal = propose(await readSourceFile(input.root, file), intent);
3430
- if (proposal === null) {
3431
- input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
3432
- return emptyDelivery();
3433
- }
3434
- if (proposal.inserts.length === 0) return emptyDelivery();
3435
- const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
3436
- if (selections.length === 0) return emptyDelivery();
3437
- const output = await runDetection({
3438
- root: input.root,
3439
- event: "edit",
3440
- selections,
3441
- changes: /* @__PURE__ */ new Map(),
3442
- // The proposed file for the one being written; anything else a detector reaches for is real.
3443
- read: async (name) => name === file ? proposal.content : readSourceFile(input.root, name),
3444
- registry: input.registry,
3445
- cacheFor: (kind) => diskCache(detectorCacheDir(input.stateDir, kind)),
3446
- contextFor: async (rule) => {
3447
- const references = [];
3448
- for (const spec of rule.context) {
3449
- const resolved = await input.resolver.resolve(spec);
3450
- if (resolved.found) references.push({ ref: spec.ref, content: resolved.content });
3451
- }
3452
- return references;
3453
- },
3454
- settings: { llm: input.config.llm },
3455
- timeoutMs: input.config.timeouts.editDeadlineMs
3456
- });
3457
- const evidence = output.findings.filter(({ rule, match }) => isInserted(proposal, match, rule.detector.kind));
3458
- if (evidence.length === 0) return emptyDelivery();
3459
- const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
3460
- const refusing = evidence.filter(({ rule }) => !spent(rule.id));
3461
- if (refusing.length === 0) {
3462
- input.log(`guard: ${file} has spent its refusals; allowing the write`);
3463
- return emptyDelivery();
3464
- }
3465
- const { delivery } = await decide({
3466
- agent: "main",
3467
- findings: refusing.map(({ rule, match }) => ({ rule, match, status: "new" })),
3468
- touches: [],
3469
- agentRead: null,
3470
- warnings: [],
3471
- work: input.work,
3472
- // An empty context, so the sections come with the refusal even if they were delivered earlier:
3473
- // the message has to stand on its own, and nothing here is recorded as delivered because the
3474
- // write it explains never happened.
3475
- context: emptyContext(),
3476
- resolver: input.resolver,
3477
- maxBytes: input.config.context.maxBytes,
3478
- maxBlocks: input.config.stopGate.maxBlocks,
3479
- maxContextChars: input.maxContextChars,
3480
- maxMatchesPerRule: input.config.maxMatchesPerRule,
3481
- stopGate: false
3482
- });
3483
- await input.record(
3484
- [...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
3485
- );
3486
- return delivery;
3487
- }
3488
-
3489
- // src/core/session/session.ts
3490
- import path20 from "path";
3491
- function safeSegment3(value) {
3492
- const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
3493
- return /^\.*$/.test(segment) ? `_${segment}` : segment;
3494
- }
3495
- function sessionDir(stateDir, sessionId) {
3496
- return path20.join(stateDir, "sessions", safeSegment3(sessionId));
3497
- }
3498
- var workFile = (dir) => path20.join(dir, "work.jsonl");
3499
- var contextFile = (dir, agent) => path20.join(dir, `context.${safeSegment3(agent)}.jsonl`);
3500
- async function appendWork(dir, records) {
3501
- await appendRecords(workFile(dir), records);
3502
- }
3503
- async function appendContext(dir, agent, records) {
3504
- await appendRecords(contextFile(dir, agent), records);
3505
- }
3506
- async function openSession(dir, agent) {
3507
- const [work, context] = await Promise.all([
3508
- readRecords(workFile(dir)),
3509
- readRecords(contextFile(dir, agent))
3510
- ]);
3511
- return { work: foldWork(work), context: foldContext(context) };
3512
- }
3513
- async function commitSession(dir, agent, decideWith) {
3514
- return withLock(dir, async () => {
3515
- const decision = await decideWith(await openSession(dir, agent));
3516
- await appendWork(dir, decision.work);
3517
- await appendContext(dir, agent, decision.context);
3518
- return decision;
3519
- });
3520
- }
3521
-
3522
- // src/core/pipeline.ts
3523
- async function runPipeline(options) {
3524
- const { project, stateDir, event, registry } = options;
3525
- const { root, config } = project;
3526
- const log = options.log ?? (() => {
3527
- });
3528
- const errors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error");
3529
- const warnings = errors.map((diagnostic) => ({
3530
- key: `diagnostic:${diagnostic.source}:${diagnostic.rule ?? ""}:${diagnostic.message}`,
3531
- text: `${diagnostic.source}${diagnostic.rule ? ` (${diagnostic.rule})` : ""}: ${diagnostic.message} (run ${diagnostic.hint ?? "rulecast validate"})`
3532
- }));
3533
- let failed = errors.length > 0;
3534
- const deadlineMissed = [];
3535
- const session = event.session ? { dir: sessionDir(stateDir, event.session.id), agent: event.session.agentId ?? "main" } : null;
3536
- const restoredFiles = options.restoredFiles ?? 0;
3537
- if (event.kind === "prompt") {
3538
- if (session) await appendWork(session.dir, [{ t: "prompt", agent: session.agent }]);
3539
- return { delivery: emptyDelivery(), failed, deadlineMissed };
3540
- }
3541
- if (event.kind === "reset") {
3542
- if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
3543
- if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
3544
- }
3545
- let baseline = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map() };
3546
- if (session && event.kind !== "reset" && event.kind !== "guard") {
3547
- baseline = await readBaseline(session.dir);
3548
- if (!baseline.started) {
3549
- await appendBaseline(session.dir, [startRecord(await headCommit(root))]);
3550
- baseline = await readBaseline(session.dir);
3551
- }
3552
- }
3553
- const view = session ? await openSession(session.dir, session.agent) : { work: emptyWork(), context: emptyContext() };
3554
- const disabled = new Set(view.work.disabled.keys());
3555
- const resolver = createReferenceResolver(root);
3556
- const workRecords = [];
3557
- const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
3558
- if (event.kind === "guard") {
3559
- const delivery = await guardWrite({
3560
- root,
3561
- config,
3562
- registry,
3563
- stateDir,
3564
- event,
3565
- rules: project.rules,
3566
- disabled,
3567
- work: view.work,
3568
- resolver,
3569
- maxContextChars: options.maxContextChars,
3570
- log,
3571
- record: session ? (records) => appendWork(session.dir, records) : async () => {
3572
- }
3573
- });
3574
- return { delivery, failed, deadlineMissed };
3575
- }
3576
- let touches = [];
3577
- let findings = [];
3578
- let agentRead = null;
3579
- if (event.kind === "touch" || event.kind === "edit") {
3580
- touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
3581
- if (session) {
3582
- await appendWork(
3583
- session.dir,
3584
- event.files.map((file) => ({ t: "accessed", agent: session.agent, file }))
3585
- );
3586
- }
3587
- }
3588
- if (event.kind === "reset" && session) {
3589
- const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
3590
- touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
3591
- }
3592
- if (event.kind === "touch") {
3593
- if (event.completeRead) agentRead = event.files[0] ?? null;
3594
- if (session) {
3595
- const records = [];
3596
- for (const file of relevant(event.files)) {
3597
- if (baseline.snapshots.has(file)) continue;
3598
- const text2 = await readSourceFile(root, file);
3599
- if (text2 !== null) records.push(snapshotRecord(file, snapshotOf(text2)));
3600
- }
3601
- await appendBaseline(session.dir, records);
3602
- }
3603
- }
3604
- if (event.kind === "edit" || event.kind === "verify") {
3605
- let files = relevant(event.files);
3606
- let snapshots = session ? baseline.snapshots : /* @__PURE__ */ new Map();
3607
- let fallbackCommit = session ? baseline.startCommit : null;
3608
- if (event.kind === "edit" && session) {
3609
- await appendWork(
3610
- session.dir,
3611
- files.map((file) => ({ t: "edited", file }))
3612
- );
3613
- }
3614
- if (event.kind === "verify") {
3615
- if (event.baseCommit) {
3616
- fallbackCommit = event.baseCommit;
3617
- snapshots = /* @__PURE__ */ new Map();
3618
- }
3619
- if (event.files.length === 0 && session) files = relevant(view.work.edited);
3620
- }
3621
- const changes = await computeChanges(root, files, { snapshots, fallbackCommit });
3622
- if (event.kind === "verify" && session && !event.baseCommit) {
3623
- files = files.filter((file) => changes.get(file)?.changedLines.length !== 0);
3624
- }
3625
- const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
3626
- let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
3627
- (selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
3628
- );
3629
- let overBudget = [];
3630
- if (event.kind === "verify") {
3631
- const budgeted = applyFileBudget("llm", selections, config.llm.maxFilesPerVerify, [...view.work.edited].reverse());
3632
- selections = budgeted.selections;
3633
- overBudget = budgeted.skipped;
3634
- }
3635
- const output = await runDetection({
3636
- root,
3637
- event: event.kind,
3638
- selections,
3639
- changes,
3640
- read: (file) => readSourceFile(root, file),
3641
- registry,
3642
- cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
3643
- contextFor: async (rule) => {
3644
- const references = [];
3645
- for (const spec of rule.context) {
3646
- const resolved = await resolver.resolve(spec);
3647
- if (resolved.found) references.push({ ref: spec.ref, content: resolved.content });
3648
- }
3649
- return references;
3650
- },
3651
- settings: { llm: config.llm },
3652
- timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
3653
- });
3654
- findings = output.findings.map(({ rule, match }) => ({
3655
- rule,
3656
- match,
3657
- status: isNew(match, changes) ? "new" : "preexisting"
3658
- }));
3659
- for (const error of output.errors) {
3660
- failed = true;
3661
- for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
3662
- warnings.push({
3663
- key: `detector:${error.kind}:${error.rules.join(",")}:${error.message}`,
3664
- text: `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. Disabled for this session.`
3665
- });
3666
- }
3667
- if (overBudget.length > 0) {
3668
- warnings.push({
3669
- key: `llm-budget:${overBudget.join(",")}`,
3670
- text: `llm rules checked ${config.llm.maxFilesPerVerify} files; ${overBudget.length} were not checked: ${overBudget.join(", ")}. Raise llm.max_files_per_verify, or run rulecast run --files on them.`
3671
- });
3672
- }
3673
- for (const timeout of output.timedOut) {
3674
- if (event.kind === "edit") {
3675
- log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
3676
- deadlineMissed.push(timeout.kind);
3677
- } else {
3678
- failed = true;
3679
- warnings.push({
3680
- key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
3681
- // Naming the lever matters: dogfooding measured a single haiku call on a 135-line file
3682
- // at 89 s against a 60 s default, and "timed out" alone leaves nobody knowing what to do.
3683
- text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${timeout.kind === "llm" ? " (an llm rule on a large file can need 120000 or more)" : ""}.`
3684
- });
3685
- }
3686
- }
3687
- }
3688
- const inputFor = (state) => ({
3689
- agent: session?.agent ?? "main",
3690
- findings,
3691
- touches: touches.filter((rule) => !state.context.touched.has(rule.id)),
3692
- agentRead,
3693
- warnings,
3694
- work: state.work,
3695
- context: state.context,
3696
- resolver,
3697
- maxBytes: config.context.maxBytes,
3698
- maxBlocks: config.stopGate.maxBlocks,
3699
- maxContextChars: options.maxContextChars,
3700
- maxMatchesPerRule: config.maxMatchesPerRule,
3701
- stopGate: options.stopGate === true && event.kind === "verify" && session !== null
3702
- });
3703
- const persisted = (decision) => {
3704
- if (decision.overflow !== null) {
3705
- decision.delivery.overflowPath = writeOverflow(stateDir, event.session?.id ?? null, decision.overflow);
3706
- }
3707
- return decision.delivery;
3708
- };
3709
- if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
3710
- await appendWork(session.dir, workRecords);
3711
- try {
3712
- const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
3713
- return { delivery: persisted(decision), failed, deadlineMissed };
3714
- } catch (error) {
3715
- if (!(error instanceof LockTimeoutError)) throw error;
3716
- warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
3717
- const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
3718
- return { delivery: persisted(decision), failed, deadlineMissed };
3719
- }
3720
- }
3721
-
3722
- export {
3723
- createRegistry,
3724
- isNotFound,
3725
- errorMessage,
3726
- perRule,
3727
- readSourceFile,
3728
- MODEL_ALIASES,
3729
- resolveModel,
3730
- LlmUnavailableError,
3731
- providerByName,
3732
- builtinDetectors,
3733
- defaultDetectorSettings,
3734
- emptyDelivery,
3735
- defaultConfig,
3736
- CONFIG_FILE,
3737
- MANIFEST_FILE,
3738
- readConfigData,
3739
- parseConfig,
3740
- readManifest,
3741
- git,
3742
- mergeBase,
3743
- changedFilesSince,
3744
- allFiles,
3745
- stagedFiles,
3746
- changedFilesBetween,
3747
- VERSION2 as VERSION,
3748
- parseVersion,
3749
- LockTimeoutError,
3750
- withLock,
3751
- repoLabel,
3752
- RepoFetchError,
3753
- NO_PROMPT,
3754
- fetchCheckout,
3755
- cachedRepo,
3756
- ensureRepo,
3757
- renderAgentText,
3758
- claudeCodeAdapter,
3759
- ADAPTERS,
3760
- adapterByName,
3761
- cacheHome,
3762
- projectStateDir,
3763
- ensureProjectState,
3764
- debugLogger,
3765
- tagsOf,
3766
- scanHeadings,
3767
- diagnosticText,
3768
- compile,
3769
- compileManifest,
3770
- memoryCache,
3771
- diskCache,
3772
- detectorCacheDir,
3773
- checkableKinds,
3774
- checkDetectors,
3775
- runDetection,
3776
- cachedRepos,
3777
- fetchingRepos,
3778
- fixedRepo,
3779
- runPipeline
3780
- };