@syv-ai/rulecast 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,937 @@
1
+ // src/core/detection/per-rule.ts
2
+ import { readFile, stat } from "fs/promises";
3
+ import path from "path";
4
+
5
+ // src/core/errors.ts
6
+ var DeadlineError = class extends Error {
7
+ };
8
+ function isNotFound(error) {
9
+ return error instanceof Error && error.code === "ENOENT";
10
+ }
11
+ function errorMessage(error) {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ function formatZodError(error) {
15
+ return error.issues.map((issue) => `${issue.path.length ? issue.path.join(".") : "(root)"}: ${issue.message}`).join("; ");
16
+ }
17
+
18
+ // src/core/detection/per-rule.ts
19
+ function perRule(detect) {
20
+ return async (input) => {
21
+ const result = { findings: [], errors: [] };
22
+ await Promise.all(
23
+ input.rules.map(async (rule) => {
24
+ try {
25
+ for (const match of await detect(rule, input)) result.findings.push({ rule: rule.id, match });
26
+ } catch (error) {
27
+ if (error instanceof DeadlineError || input.signal.aborted || Date.now() > input.deadlineAt) throw error;
28
+ result.errors.push({ rule: rule.id, message: errorMessage(error) });
29
+ }
30
+ })
31
+ );
32
+ return result;
33
+ };
34
+ }
35
+ async function readSourceFile(cwd, file) {
36
+ try {
37
+ return await readFile(path.resolve(cwd, file), "utf8");
38
+ } catch (error) {
39
+ if (isNotFound(error)) return null;
40
+ throw error;
41
+ }
42
+ }
43
+ async function fileBytes(cwd, file) {
44
+ try {
45
+ return (await stat(path.resolve(cwd, file))).size;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // src/detectors/llm/models.ts
52
+ var ALIASES = {
53
+ haiku: {
54
+ "claude-code": "haiku",
55
+ anthropic: "claude-haiku-4-5-20251001",
56
+ opencode: "anthropic/claude-haiku-4-5-20251001"
57
+ },
58
+ sonnet: { "claude-code": "sonnet", anthropic: "claude-sonnet-5", opencode: "anthropic/claude-sonnet-5" },
59
+ opus: { "claude-code": "opus", anthropic: "claude-opus-5", opencode: "anthropic/claude-opus-5" },
60
+ fable: { "claude-code": "fable", anthropic: "claude-fable-5-1", opencode: "anthropic/claude-fable-5-1" }
61
+ };
62
+ var MODEL_ALIASES = Object.keys(ALIASES);
63
+ function resolveModel(model, provider) {
64
+ const alias = ALIASES[model];
65
+ if (alias === void 0) return model;
66
+ return alias[provider] ?? null;
67
+ }
68
+
69
+ // src/detectors/llm/providers/types.ts
70
+ import { z } from "zod";
71
+ var responseSchema = z.object({
72
+ findings: z.array(
73
+ z.object({
74
+ rule: z.string(),
75
+ line: z.number().int().positive(),
76
+ /** The model's quote of the line. Asked for because quoting sharpens the line number; never rendered. */
77
+ text: z.string().optional(),
78
+ reason: z.string()
79
+ })
80
+ )
81
+ });
82
+ var FINDINGS_SCHEMA = {
83
+ type: "object",
84
+ properties: {
85
+ findings: {
86
+ type: "array",
87
+ items: {
88
+ type: "object",
89
+ properties: {
90
+ rule: { type: "string" },
91
+ line: { type: "integer" },
92
+ text: { type: "string" },
93
+ reason: { type: "string" }
94
+ },
95
+ required: ["rule", "line", "reason"]
96
+ }
97
+ }
98
+ },
99
+ required: ["findings"]
100
+ };
101
+ var LlmUnavailableError = class extends Error {
102
+ };
103
+
104
+ // src/detectors/llm/providers/extract.ts
105
+ function extractFindingsJson(text) {
106
+ let found = null;
107
+ for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
108
+ const end = objectEnd(text, start);
109
+ if (end === -1) continue;
110
+ let parsed;
111
+ try {
112
+ parsed = JSON.parse(text.slice(start, end));
113
+ } catch {
114
+ continue;
115
+ }
116
+ if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.findings)) {
117
+ found = parsed;
118
+ start = end - 1;
119
+ }
120
+ }
121
+ return found;
122
+ }
123
+ function objectEnd(text, start) {
124
+ let depth = 0;
125
+ let inString = false;
126
+ for (let i = start; i < text.length; i++) {
127
+ const char = text[i];
128
+ if (inString) {
129
+ if (char === "\\") i++;
130
+ else if (char === '"') inString = false;
131
+ continue;
132
+ }
133
+ if (char === '"') inString = true;
134
+ else if (char === "{") depth++;
135
+ else if (char === "}" && --depth === 0) return i + 1;
136
+ }
137
+ return -1;
138
+ }
139
+
140
+ // src/detectors/llm/providers/http.ts
141
+ function endpoint(baseUrl, fallback, path7) {
142
+ return `${(baseUrl ?? fallback).replace(/\/+$/, "")}${path7}`;
143
+ }
144
+ function apiKey(request) {
145
+ const key = request.env[request.settings.apiKeyEnv];
146
+ if (!key) {
147
+ throw new LlmUnavailableError(
148
+ `${request.settings.apiKeyEnv} is not set; set it, or use the claude-code provider, which needs no API key`
149
+ );
150
+ }
151
+ return key;
152
+ }
153
+ async function postJson(url, headers, body, signal) {
154
+ const response = await fetch(url, {
155
+ method: "POST",
156
+ headers: { "content-type": "application/json", ...headers },
157
+ body: JSON.stringify(body),
158
+ signal
159
+ });
160
+ if (!response.ok) {
161
+ const detail = (await response.text().catch(() => "")).slice(0, 200);
162
+ const message = `${url} returned ${response.status}: ${detail}`;
163
+ if (response.status === 401 || response.status === 403) throw new LlmUnavailableError(message);
164
+ throw new Error(message);
165
+ }
166
+ return response.json();
167
+ }
168
+ function keyAvailability(settings, env, fallback, path7) {
169
+ return env[settings.apiKeyEnv] ? { ok: true, detail: endpoint(settings.baseUrl, fallback, path7) } : { ok: false, detail: `$${settings.apiKeyEnv} is not set` };
170
+ }
171
+
172
+ // src/detectors/llm/providers/anthropic.ts
173
+ var API = "https://api.anthropic.com";
174
+ var VERSION = "2023-06-01";
175
+ var anthropicProvider = {
176
+ name: "anthropic",
177
+ async ask(request) {
178
+ const key = apiKey(request);
179
+ const reply = await postJson(
180
+ endpoint(request.settings.baseUrl, API, "/v1/messages"),
181
+ { "x-api-key": key, "anthropic-version": VERSION },
182
+ {
183
+ model: request.model,
184
+ max_tokens: 4096,
185
+ messages: [{ role: "user", content: request.prompt }]
186
+ },
187
+ request.signal
188
+ );
189
+ const parts = reply.content ?? [];
190
+ const text = parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("");
191
+ const scanned = responseSchema.safeParse(extractFindingsJson(text));
192
+ if (scanned.success) return scanned.data.findings;
193
+ throw new Error(`the anthropic API returned no usable JSON: ${text.trim().slice(0, 200)}`);
194
+ },
195
+ available: async ({ settings, env }) => keyAvailability(settings, env, API, "/v1/messages")
196
+ };
197
+
198
+ // src/detectors/llm/providers/cli.ts
199
+ import { spawn } from "child_process";
200
+ import { access, constants } from "fs/promises";
201
+ import path2 from "path";
202
+
203
+ // src/core/which.ts
204
+ import { execFile } from "child_process";
205
+ import { promisify } from "util";
206
+ var exec = promisify(execFile);
207
+ async function onPath(command, signal) {
208
+ try {
209
+ await exec("/bin/sh", ["-c", 'command -v "$1" > /dev/null', "sh", command], { signal });
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ // src/detectors/llm/providers/cli.ts
217
+ async function resolveCli(name, root) {
218
+ const local = path2.join(root, "node_modules", ".bin", name);
219
+ try {
220
+ await access(local, constants.X_OK);
221
+ return local;
222
+ } catch {
223
+ return name;
224
+ }
225
+ }
226
+ function runCli(command, args, options) {
227
+ return new Promise((resolve, reject) => {
228
+ const child = spawn(command, args, {
229
+ cwd: options.cwd,
230
+ env: options.env,
231
+ signal: options.signal,
232
+ stdio: ["pipe", "pipe", "pipe"]
233
+ });
234
+ const stdout = [];
235
+ const stderr = [];
236
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
237
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
238
+ child.stdin.on("error", () => {
239
+ });
240
+ child.stdin.end(options.input);
241
+ child.on("error", (error) => {
242
+ reject(isNotFound(error) ? new LlmUnavailableError(`${command} is not installed`) : error);
243
+ });
244
+ child.on("close", (code) => {
245
+ resolve({
246
+ stdout: Buffer.concat(stdout).toString("utf8"),
247
+ stderr: Buffer.concat(stderr).toString("utf8"),
248
+ code
249
+ });
250
+ });
251
+ });
252
+ }
253
+ async function cliAvailable(name, cwd, signal) {
254
+ const resolved = await resolveCli(name, cwd);
255
+ if (resolved !== name) return { ok: true, detail: resolved };
256
+ return await onPath(name, signal) ? { ok: true, detail: `${name} (PATH)` } : { ok: false, detail: `${name} is not installed` };
257
+ }
258
+
259
+ // src/detectors/llm/providers/claude-code.ts
260
+ 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.";
261
+ var claudeCodeProvider = {
262
+ name: "claude-code",
263
+ async ask(request) {
264
+ const command = await resolveCli("claude", request.cwd);
265
+ const args = [
266
+ "-p",
267
+ "--model",
268
+ request.model,
269
+ "--output-format",
270
+ "json",
271
+ "--json-schema",
272
+ JSON.stringify(FINDINGS_SCHEMA),
273
+ "--system-prompt",
274
+ SYSTEM_PROMPT,
275
+ "--tools",
276
+ "",
277
+ "--restricted",
278
+ "--strict-mcp-config",
279
+ "--no-session-persistence"
280
+ ];
281
+ const result = await runCli(command, args, {
282
+ cwd: request.cwd,
283
+ input: request.prompt,
284
+ env: request.env,
285
+ signal: request.signal
286
+ });
287
+ const envelope = parseJson(result.stdout);
288
+ if (isRecord(envelope)) {
289
+ if (envelope.is_error === true) {
290
+ throw new Error(`claude reported an error: ${firstLine(String(envelope.result ?? result.stderr))}`);
291
+ }
292
+ const structured = responseSchema.safeParse(envelope.structured_output);
293
+ if (structured.success) return structured.data.findings;
294
+ if (typeof envelope.result === "string") {
295
+ const fromResult = responseSchema.safeParse(parseJson(envelope.result));
296
+ if (fromResult.success) return fromResult.data.findings;
297
+ const scannedResult = responseSchema.safeParse(extractFindingsJson(envelope.result));
298
+ if (scannedResult.success) return scannedResult.data.findings;
299
+ }
300
+ }
301
+ const scanned = responseSchema.safeParse(extractFindingsJson(result.stdout));
302
+ if (scanned.success) return scanned.data.findings;
303
+ throw new Error(
304
+ `claude returned no usable JSON (exit ${result.code}): ${firstLine(result.stderr || result.stdout)}`
305
+ );
306
+ },
307
+ available: ({ cwd, signal }) => cliAvailable("claude", cwd, signal)
308
+ };
309
+ function parseJson(text) {
310
+ try {
311
+ return JSON.parse(text);
312
+ } catch {
313
+ return void 0;
314
+ }
315
+ }
316
+ function isRecord(value) {
317
+ return typeof value === "object" && value !== null;
318
+ }
319
+ function firstLine(text) {
320
+ return text.trim().split("\n")[0]?.slice(0, 200) ?? "";
321
+ }
322
+
323
+ // src/detectors/llm/providers/openai.ts
324
+ var API2 = "https://api.openai.com/v1";
325
+ var openaiCompatibleProvider = {
326
+ name: "openai-compatible",
327
+ async ask(request) {
328
+ const key = apiKey(request);
329
+ const reply = await postJson(
330
+ endpoint(request.settings.baseUrl, API2, "/chat/completions"),
331
+ { authorization: `Bearer ${key}` },
332
+ { model: request.model, messages: [{ role: "user", content: request.prompt }] },
333
+ request.signal
334
+ );
335
+ const content = reply.choices?.[0]?.message?.content;
336
+ const text = typeof content === "string" ? content : "";
337
+ const scanned = responseSchema.safeParse(extractFindingsJson(text));
338
+ if (scanned.success) return scanned.data.findings;
339
+ throw new Error(`the ${request.settings.provider} API returned no usable JSON: ${text.trim().slice(0, 200)}`);
340
+ },
341
+ available: async ({ settings, env }) => keyAvailability(settings, env, API2, "/chat/completions")
342
+ };
343
+
344
+ // src/detectors/llm/providers/opencode.ts
345
+ var PROMPT_LIMIT_BYTES = 128 * 1024;
346
+ var opencodeProvider = {
347
+ name: "opencode",
348
+ async ask(request) {
349
+ const size = Buffer.byteLength(request.prompt, "utf8");
350
+ if (size > PROMPT_LIMIT_BYTES) {
351
+ throw new Error(
352
+ `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.`
353
+ );
354
+ }
355
+ const command = await resolveCli("opencode", request.cwd);
356
+ const result = await runCli(command, ["run", "--model", request.model, "--format", "default", request.prompt], {
357
+ cwd: request.cwd,
358
+ input: "",
359
+ env: request.env,
360
+ signal: request.signal
361
+ });
362
+ const scanned = responseSchema.safeParse(extractFindingsJson(result.stdout));
363
+ if (scanned.success) return scanned.data.findings;
364
+ throw new Error(
365
+ `opencode returned no usable JSON (exit ${result.code}): ${firstLine2(result.stderr || result.stdout)}`
366
+ );
367
+ },
368
+ available: ({ cwd, signal }) => cliAvailable("opencode", cwd, signal)
369
+ };
370
+ function firstLine2(text) {
371
+ return text.trim().split("\n")[0]?.slice(0, 200) ?? "";
372
+ }
373
+
374
+ // src/detectors/llm/providers/index.ts
375
+ var PROVIDERS = {
376
+ "claude-code": claudeCodeProvider,
377
+ opencode: opencodeProvider,
378
+ anthropic: anthropicProvider,
379
+ "openai-compatible": openaiCompatibleProvider
380
+ };
381
+ function providerByName(name) {
382
+ const provider = PROVIDERS[name];
383
+ if (!provider) throw new LlmUnavailableError(`the ${name} provider is not available in this build`);
384
+ return provider;
385
+ }
386
+
387
+ // src/core/types.ts
388
+ var LLM_PROVIDERS = ["claude-code", "opencode", "anthropic", "openai-compatible"];
389
+ function defaultDetectorSettings() {
390
+ return { llm: { provider: "claude-code", baseUrl: null, apiKeyEnv: "ANTHROPIC_API_KEY", maxFilesPerVerify: 10 } };
391
+ }
392
+ function emptyDelivery() {
393
+ return {
394
+ findings: [],
395
+ preexistingSummary: [],
396
+ references: [],
397
+ touches: [],
398
+ stop: null,
399
+ warnings: [],
400
+ templates: {},
401
+ omitted: { findings: [], rules: 0, preexisting: 0 },
402
+ overflowPath: null
403
+ };
404
+ }
405
+
406
+ // src/core/version.ts
407
+ var VERSION2 = "0.2.0";
408
+ function parseVersion(text) {
409
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(text);
410
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
411
+ }
412
+ function isOlder(running, minimum) {
413
+ const a = parseVersion(running);
414
+ const b = parseVersion(minimum);
415
+ if (!a || !b) return false;
416
+ for (let i = 0; i < 3; i++) {
417
+ if (a[i] !== b[i]) return a[i] < b[i];
418
+ }
419
+ return false;
420
+ }
421
+
422
+ // src/core/template.ts
423
+ var VARIABLE = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
424
+ var CORE_VARIABLES = ["file", "line", "column", "text", "rule"];
425
+ var UnknownTemplateVariable = class extends Error {
426
+ constructor(variable) {
427
+ super(`template variable "${variable}" has no value`);
428
+ this.variable = variable;
429
+ }
430
+ variable;
431
+ };
432
+ function templateVariables(template) {
433
+ return [...new Set([...template.matchAll(VARIABLE)].map((match) => match[1]))];
434
+ }
435
+ function oneLine(value) {
436
+ return value.replace(/\s+/g, " ").trim();
437
+ }
438
+ function renderTemplate(template, values) {
439
+ return template.replace(VARIABLE, (_, name) => {
440
+ const value = values[name];
441
+ if (value === void 0) throw new UnknownTemplateVariable(name);
442
+ return oneLine(value);
443
+ });
444
+ }
445
+ function templateSkeleton(template) {
446
+ return template.replace(VARIABLE, (_, name) => `{${name}}`);
447
+ }
448
+ function templateLiteralLength(template) {
449
+ return oneLine(template.replace(VARIABLE, "")).length;
450
+ }
451
+ var LOCATION_VARIABLES = /* @__PURE__ */ new Set(["file", "line", "column", "rule"]);
452
+ function templateBindings(template) {
453
+ return templateVariables(template).filter((name) => !LOCATION_VARIABLES.has(name));
454
+ }
455
+
456
+ // src/core/delivery/render-agent.ts
457
+ var GROUP_FROM = 3;
458
+ var GROUP_MIN_LITERAL = 40;
459
+ var MAX_LOCATION_PAD = 40;
460
+ var WRAP = 96;
461
+ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
462
+ function header(delivery) {
463
+ if (delivery.findings.length > 0) {
464
+ const rules = new Set(delivery.findings.map((finding) => finding.rule)).size + delivery.omitted.rules;
465
+ const files = new Set(delivery.findings.map((finding) => finding.file));
466
+ const where = files.size === 1 && delivery.omitted.rules === 0 ? ` in ${[...files][0]}` : "";
467
+ return `rulecast: ${plural(rules, "rule")} violated${where}`;
468
+ }
469
+ if (delivery.references.length > 0 || delivery.preexistingSummary.length > 0) {
470
+ return "rulecast: conventions for the files you are working on";
471
+ }
472
+ return null;
473
+ }
474
+ var repeatOf = (finding) => finding.count > 1 ? ` (\xD7${finding.count})` : "";
475
+ function findingLines(finding) {
476
+ const lines = finding.message.split("\n").map((line) => ` ${line}`);
477
+ lines[lines.length - 1] += repeatOf(finding);
478
+ return lines;
479
+ }
480
+ var locationOf = (finding, withLine) => withLine ? `${finding.file}:${finding.line}` : finding.file;
481
+ function bindingValue(value) {
482
+ const text = (value ?? "").replace(/\s+/g, " ").trim();
483
+ if (text === "") return "\u2014";
484
+ return /\s/.test(text) && !/^".*"$/.test(text) ? `"${text}"` : text;
485
+ }
486
+ function packed(items) {
487
+ const lines = [];
488
+ for (const item of items) {
489
+ const last = lines.at(-1);
490
+ if (last !== void 0 && last.length + 2 + item.length <= WRAP) lines[lines.length - 1] = `${last}, ${item}`;
491
+ else lines.push(` ${item}`);
492
+ }
493
+ return lines;
494
+ }
495
+ function groupedLines(findings, template) {
496
+ const names = templateBindings(template);
497
+ const withLine = templateVariables(template).includes("line");
498
+ const at = (finding) => locationOf(finding, withLine);
499
+ const out = templateSkeleton(template).split("\n").map((line) => ` ${line}`);
500
+ out.push("");
501
+ if (names.length === 0) {
502
+ out.push(...packed(findings.map((finding) => `${at(finding)}${repeatOf(finding)}`)));
503
+ return out;
504
+ }
505
+ const width = Math.min(MAX_LOCATION_PAD, Math.max(...findings.map((finding) => at(finding).length)));
506
+ for (const finding of findings) {
507
+ const values = names.map((name) => bindingValue(finding.captures?.[name])).join(" ");
508
+ out.push(` ${at(finding).padEnd(width)} ${values}${repeatOf(finding)}`.trimEnd());
509
+ }
510
+ return out;
511
+ }
512
+ function referenceLines(reference) {
513
+ switch (reference.state) {
514
+ case "full":
515
+ return [`--- ${reference.ref} ---`, ...(reference.content ?? "").split("\n"), ""];
516
+ case "pointer":
517
+ return [`--- ${reference.ref} (provided earlier in this session) ---`];
518
+ case "missing":
519
+ return [`--- ${reference.ref} (missing) ---`];
520
+ case "read": {
521
+ const what = reference.location === void 0 ? "read this" : `read ${reference.location}`;
522
+ return reference.reason === "mode" ? [`--- ${reference.ref}: ${what} before continuing ---`] : [`--- ${reference.ref}: ${what} before continuing (not included, too long for this message) ---`];
523
+ }
524
+ }
525
+ }
526
+ function omittedFor(omitted, rule) {
527
+ const entry = omitted.findings.find((finding) => finding.rule === rule);
528
+ return { count: entry?.count ?? 0, files: entry?.files ?? 0 };
529
+ }
530
+ function ruleBlock(rule, findings, template, dropped, options) {
531
+ const shown = findings.slice(0, options.maxMatchesPerRule);
532
+ const hidden = findings.slice(options.maxMatchesPerRule);
533
+ const total = findings.length + dropped.count;
534
+ const grouped = template !== void 0 && total >= GROUP_FROM && templateLiteralLength(template) >= GROUP_MIN_LITERAL;
535
+ const severity = findings[0].severity;
536
+ const files = new Set(findings.map((finding) => finding.file)).size + dropped.files;
537
+ const out = [grouped ? `${severity} ${rule} \u2014 ${total} in ${plural(files, "file")}` : `${severity} ${rule}`];
538
+ if (grouped && template !== void 0) out.push(...groupedLines(shown, template));
539
+ else for (const finding of shown) out.push(...findingLines(finding));
540
+ const more = hidden.length + dropped.count;
541
+ if (more > 0) {
542
+ const moreFiles = new Set(hidden.map((finding) => finding.file)).size + dropped.files;
543
+ out.push(` \u2026and ${more} more in ${plural(moreFiles, "file")}`);
544
+ }
545
+ return out;
546
+ }
547
+ function measureRuleBlock(rule, findings, template, options) {
548
+ return ruleBlock(rule, findings, template, { count: 0, files: 0 }, options).join("\n").length + 1;
549
+ }
550
+ function renderAgentText(delivery, options) {
551
+ const title = header(delivery);
552
+ if (title === null && delivery.warnings.length === 0) return "";
553
+ const out = [];
554
+ if (title !== null) out.push(title, "");
555
+ const byRule = /* @__PURE__ */ new Map();
556
+ for (const finding of delivery.findings) {
557
+ const group = byRule.get(finding.rule);
558
+ if (group === void 0) byRule.set(finding.rule, [finding]);
559
+ else group.push(finding);
560
+ }
561
+ for (const [rule, findings] of byRule) {
562
+ out.push(...ruleBlock(rule, findings, delivery.templates[rule], omittedFor(delivery.omitted, rule), options));
563
+ out.push("");
564
+ }
565
+ for (const summary of delivery.preexistingSummary) {
566
+ out.push(`pre-existing (not blocking): ${summary.rule} \xD7${summary.count} in ${summary.file}`);
567
+ }
568
+ if (delivery.omitted.preexisting > 0) {
569
+ out.push(`\u2026and ${delivery.omitted.preexisting} more pre-existing (not blocking)`);
570
+ }
571
+ if (delivery.preexistingSummary.length > 0 || delivery.omitted.preexisting > 0) out.push("");
572
+ for (const reference of delivery.references) out.push(...referenceLines(reference));
573
+ if (out.length > 0 && out.at(-1) !== "") out.push("");
574
+ if (delivery.warnings.length > 0) {
575
+ out.push("rulecast warnings:", ...delivery.warnings.map((warning) => ` - ${warning}`));
576
+ }
577
+ if (delivery.overflowPath !== null) {
578
+ if (out.at(-1) !== "") out.push("");
579
+ out.push(
580
+ `\u2026${plural(delivery.omitted.rules, "rule")} and the conventions they cite did not fit here.`,
581
+ "Every finding, with the doc sections it cites, is in:",
582
+ ` ${delivery.overflowPath}`
583
+ );
584
+ }
585
+ while (out.at(-1) === "") out.pop();
586
+ return out.join("\n");
587
+ }
588
+
589
+ // src/core/compile/rule.ts
590
+ import path5 from "path";
591
+
592
+ // src/core/anchors.ts
593
+ var FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/;
594
+ var FENCE_CLOSE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
595
+ var ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
596
+ var SETEXT_1 = /^ {0,3}=+[ \t]*$/;
597
+ var SETEXT_2 = /^ {0,3}-+[ \t]*$/;
598
+ var LIST_ITEM = /^ {0,3}[-*+][ \t]/;
599
+ function linesOf(markdown) {
600
+ return markdown.split(/\r?\n/);
601
+ }
602
+ function slugify(text) {
603
+ return text.trim().toLowerCase().replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "").replace(/ /g, "-");
604
+ }
605
+ function scanHeadings(markdown) {
606
+ const lines = linesOf(markdown);
607
+ const headings = [];
608
+ const seen = /* @__PURE__ */ new Map();
609
+ let fence = null;
610
+ const add = (level, text, line) => {
611
+ const base = slugify(text);
612
+ const count = seen.get(base) ?? 0;
613
+ seen.set(base, count + 1);
614
+ headings.push({ level, text, slug: count === 0 ? base : `${base}-${count}`, line });
615
+ };
616
+ for (let i = 0; i < lines.length; i++) {
617
+ const line = lines[i];
618
+ if (fence) {
619
+ const close = line.match(FENCE_CLOSE);
620
+ if (close && close[1][0] === fence.char && close[1].length >= fence.length) fence = null;
621
+ continue;
622
+ }
623
+ const open = line.match(FENCE_OPEN);
624
+ if (open) {
625
+ fence = { char: open[1][0], length: open[1].length };
626
+ continue;
627
+ }
628
+ const atx = line.match(ATX);
629
+ if (atx) {
630
+ add(atx[1].length, (atx[2] ?? "").trim(), i + 1);
631
+ continue;
632
+ }
633
+ const next = lines[i + 1];
634
+ if (next === void 0 || line.trim() === "") continue;
635
+ if (SETEXT_1.test(next)) {
636
+ add(1, line.trim(), i + 1);
637
+ i++;
638
+ } else if (SETEXT_2.test(next) && !LIST_ITEM.test(line)) {
639
+ add(2, line.trim(), i + 1);
640
+ i++;
641
+ }
642
+ }
643
+ return headings;
644
+ }
645
+ function sectionRange(markdown, slug) {
646
+ const headings = scanHeadings(markdown);
647
+ const index = headings.findIndex((heading2) => heading2.slug === slug);
648
+ if (index === -1) return null;
649
+ const heading = headings[index];
650
+ const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
651
+ return { slug, start: heading.line, end: next ? next.line - 1 : linesOf(markdown).length };
652
+ }
653
+ function sectionText(markdown, section) {
654
+ return linesOf(markdown).slice(section.start - 1, section.end).join("\n").trimEnd();
655
+ }
656
+ function sectionContains(markdown, parentSlug, childSlug) {
657
+ const parent = sectionRange(markdown, parentSlug);
658
+ const child = sectionRange(markdown, childSlug);
659
+ if (!parent || !child) return false;
660
+ return child.start >= parent.start && child.end <= parent.end;
661
+ }
662
+
663
+ // src/core/files.ts
664
+ import path3 from "path";
665
+ var TYPE_TABLE = {
666
+ ".py": ["text", "python"],
667
+ ".pyi": ["text", "python", "pyi"],
668
+ ".ts": ["text", "ts"],
669
+ ".mts": ["text", "ts"],
670
+ ".cts": ["text", "ts"],
671
+ ".tsx": ["text", "tsx"],
672
+ ".js": ["text", "javascript"],
673
+ ".mjs": ["text", "javascript"],
674
+ ".cjs": ["text", "javascript"],
675
+ ".jsx": ["text", "jsx"],
676
+ ".md": ["text", "markdown"],
677
+ ".mdx": ["text", "mdx"],
678
+ ".yaml": ["text", "yaml"],
679
+ ".yml": ["text", "yaml"],
680
+ ".json": ["text", "json"],
681
+ ".toml": ["text", "toml"],
682
+ ".css": ["text", "css"],
683
+ ".scss": ["text", "scss"],
684
+ ".html": ["text", "html"],
685
+ ".sh": ["text", "shell"],
686
+ ".sql": ["text", "sql"],
687
+ ".go": ["text", "go"],
688
+ ".rs": ["text", "rust"],
689
+ ".txt": ["text", "plain-text"]
690
+ };
691
+ var KNOWN_TAGS = /* @__PURE__ */ new Set(["file", ...Object.values(TYPE_TABLE).flat()]);
692
+ var FILE_ONLY = /* @__PURE__ */ new Set(["file"]);
693
+ function tagsOf(file) {
694
+ const tags = TYPE_TABLE[path3.posix.extname(file).toLowerCase()];
695
+ return tags ? /* @__PURE__ */ new Set(["file", ...tags]) : FILE_ONLY;
696
+ }
697
+ function compileRegex(key, source) {
698
+ try {
699
+ return new RegExp(source);
700
+ } catch (error) {
701
+ return `${key}: invalid regex: ${errorMessage(error)}`;
702
+ }
703
+ }
704
+ function compileFilter(input) {
705
+ const files = compileRegex("files", input.files);
706
+ if (typeof files === "string") return files;
707
+ const exclude = compileRegex("exclude", input.exclude);
708
+ if (typeof exclude === "string") return exclude;
709
+ const unknown = [...input.types, ...input.typesOr, ...input.excludeTypes].find((tag) => !KNOWN_TAGS.has(tag));
710
+ if (unknown !== void 0) return `unknown file type "${unknown}"`;
711
+ const { types, typesOr, excludeTypes } = input;
712
+ return (file) => {
713
+ if (!files.test(file) || exclude.test(file)) return false;
714
+ const tags = tagsOf(file);
715
+ return types.every((tag) => tags.has(tag)) && (typesOr.length === 0 || typesOr.some((tag) => tags.has(tag))) && !excludeTypes.some((tag) => tags.has(tag));
716
+ };
717
+ }
718
+
719
+ // src/core/references.ts
720
+ import path4 from "path";
721
+ import { z as z2 } from "zod";
722
+ var referenceInputSchema = z2.union([
723
+ z2.string(),
724
+ z2.object({ path: z2.string(), mode: z2.enum(["inject", "read"]).optional() }).strict()
725
+ ]);
726
+ var ReferenceSyntaxError = class extends Error {
727
+ };
728
+ function parseReference(input, defaultMode, root) {
729
+ const raw = typeof input === "string" ? input : input.path;
730
+ const mode = typeof input === "string" ? defaultMode : input.mode ?? defaultMode;
731
+ if (!raw.startsWith("@")) throw new ReferenceSyntaxError(`reference "${raw}" must start with "@"`);
732
+ const body = raw.slice(1);
733
+ const hash = body.indexOf("#");
734
+ const file = hash === -1 ? body : body.slice(0, hash);
735
+ const anchor = hash === -1 ? null : body.slice(hash + 1);
736
+ if (!file) throw new ReferenceSyntaxError(`reference "${raw}" has no path`);
737
+ if (anchor === "") throw new ReferenceSyntaxError(`reference "${raw}" has an empty anchor`);
738
+ if (anchor !== null && !/\.mdx?$/.test(file)) {
739
+ throw new ReferenceSyntaxError(`anchors are only supported in .md and .mdx files: "${raw}"`);
740
+ }
741
+ const relative = path4.posix.normalize(file);
742
+ if (path4.posix.isAbsolute(relative) || relative === ".." || relative.startsWith("../")) {
743
+ throw new ReferenceSyntaxError(`reference "${raw}" leaves its root`);
744
+ }
745
+ const suffix = anchor === null ? "" : `#${anchor}`;
746
+ if (root.label === null) return { ref: `${relative}${suffix}`, path: relative, anchor, mode };
747
+ return { ref: `${root.label}:${relative}${suffix}`, path: path4.join(root.dir, relative), anchor, mode };
748
+ }
749
+
750
+ // src/core/compile/rule.ts
751
+ function isDetectorRule(rule) {
752
+ return rule.detector !== null;
753
+ }
754
+ function fileOf(spec) {
755
+ return spec.anchor === null ? spec.ref : spec.ref.slice(0, spec.ref.length - spec.anchor.length - 1);
756
+ }
757
+ async function compileRule(input, context) {
758
+ const { data } = input;
759
+ const minimum = data.minimum_rulecast_version;
760
+ if (minimum !== void 0 && isOlder(VERSION2, minimum)) {
761
+ return `requires rulecast ${minimum} or newer (running ${VERSION2})`;
762
+ }
763
+ let detector = null;
764
+ let defaultStages = ["touch"];
765
+ if (data.detect) {
766
+ const [kind, rawConfig] = Object.entries(data.detect)[0];
767
+ const implementation = context.registry.get(kind);
768
+ if (!implementation) return `unknown detector "${kind}"`;
769
+ const parsed = await implementation.schema.safeParseAsync(rawConfig ?? {});
770
+ if (!parsed.success) return `detect.${kind}: ${formatZodError(parsed.error)}`;
771
+ detector = { kind, config: parsed.data, captures: implementation.captures(parsed.data) };
772
+ defaultStages = implementation.events(parsed.data);
773
+ }
774
+ const stages = data.stages ?? context.config.defaultStages ?? defaultStages;
775
+ if (detector) {
776
+ if (data.message === void 0) return "rules with detect need a message";
777
+ if (!stages.includes("edit") && !stages.includes("verify")) {
778
+ return "stages [touch] never run the detector: add edit or verify, or remove detect";
779
+ }
780
+ if (data.refuse_write) {
781
+ if (context.registry.get(detector.kind)?.guards !== true) {
782
+ return `refuse_write needs a detector that can judge a file before it is written; ${detector.kind} cannot`;
783
+ }
784
+ if (!stages.includes("edit")) return "refuse_write needs edit among the rule's stages";
785
+ }
786
+ const known = /* @__PURE__ */ new Set([...CORE_VARIABLES, ...detector.captures]);
787
+ const unknown = templateVariables(data.message).find((name) => !known.has(name));
788
+ if (unknown) return `unknown template variable "${unknown}"`;
789
+ } else {
790
+ if (data.refuse_write) return "refuse_write needs detect: there is nothing to refuse a write for";
791
+ if (stages.some((stage) => stage !== "touch")) return "rules without detect need stages: [touch]";
792
+ if (data.message !== void 0) return "message needs detect";
793
+ if (!data.context?.length) return "rules without detect need context";
794
+ }
795
+ const filter = compileFilter({
796
+ files: data.files ?? "",
797
+ exclude: data.exclude ?? "^$",
798
+ types: data.types ?? ["file"],
799
+ typesOr: data.types_or ?? [],
800
+ excludeTypes: data.exclude_types ?? []
801
+ });
802
+ if (typeof filter === "string") return filter;
803
+ const references = [];
804
+ for (const reference of data.context ?? []) {
805
+ let spec;
806
+ try {
807
+ spec = parseReference(reference, context.config.context.mode, input.contextRoot);
808
+ } catch (error) {
809
+ if (error instanceof ReferenceSyntaxError) return error.message;
810
+ throw error;
811
+ }
812
+ const text = await context.read(path5.resolve(input.contextRoot.dir, spec.path));
813
+ if (text === null) return `referenced file not found: ${fileOf(spec)}`;
814
+ if (spec.anchor !== null && !sectionRange(text, spec.anchor)) {
815
+ return `anchor "#${spec.anchor}" not found in ${fileOf(spec)}`;
816
+ }
817
+ references.push(spec);
818
+ }
819
+ const { global } = context;
820
+ return {
821
+ id: data.alias ?? data.id,
822
+ name: data.name,
823
+ description: data.description ?? null,
824
+ source: input.source,
825
+ severity: data.severity ?? "error",
826
+ refuseWrite: data.refuse_write === true,
827
+ stages,
828
+ matches: (file) => global(file) && filter(file),
829
+ detector,
830
+ message: data.message ?? null,
831
+ context: references
832
+ };
833
+ }
834
+
835
+ // src/core/detection/cache.ts
836
+ import { createHash, randomUUID } from "crypto";
837
+ import { mkdir, readFile as readFile2, rename, writeFile } from "fs/promises";
838
+ import path6 from "path";
839
+ function memoryCache() {
840
+ const values = /* @__PURE__ */ new Map();
841
+ return {
842
+ async get(key) {
843
+ return values.has(key) ? structuredClone(values.get(key)) : void 0;
844
+ },
845
+ async set(key, value) {
846
+ values.set(key, structuredClone(value));
847
+ }
848
+ };
849
+ }
850
+ function diskCache(dir) {
851
+ const fileFor = (key) => path6.join(dir, `${createHash("sha256").update(key).digest("hex")}.json`);
852
+ return {
853
+ async get(key) {
854
+ try {
855
+ return JSON.parse(await readFile2(fileFor(key), "utf8"));
856
+ } catch (error) {
857
+ if (isNotFound(error)) return void 0;
858
+ throw error;
859
+ }
860
+ },
861
+ async set(key, value) {
862
+ await mkdir(dir, { recursive: true });
863
+ const target = fileFor(key);
864
+ const temp = `${target}.${randomUUID()}.tmp`;
865
+ await writeFile(temp, JSON.stringify(value));
866
+ await rename(temp, target);
867
+ }
868
+ };
869
+ }
870
+ function detectorCacheDir(stateDir, kind) {
871
+ return path6.join(stateDir, "cache", kind);
872
+ }
873
+
874
+ // src/core/detection/positions.ts
875
+ function lineStarts(text) {
876
+ const starts = [0];
877
+ for (let i = 0; i < text.length; i++) {
878
+ if (text.charCodeAt(i) === 10) starts.push(i + 1);
879
+ }
880
+ return starts;
881
+ }
882
+ function positionAt(starts, offset) {
883
+ let low = 0;
884
+ let high = starts.length - 1;
885
+ while (low < high) {
886
+ const mid = low + high + 1 >> 1;
887
+ if (starts[mid] <= offset) low = mid;
888
+ else high = mid - 1;
889
+ }
890
+ return { line: low + 1, column: offset - starts[low] + 1 };
891
+ }
892
+ function offsetAt(starts, line, column, length) {
893
+ const index = Math.min(Math.max(line, 1), starts.length) - 1;
894
+ const offset = starts[index] + Math.max(column, 1) - 1;
895
+ return Math.min(Math.max(offset, 0), length);
896
+ }
897
+
898
+ export {
899
+ DeadlineError,
900
+ isNotFound,
901
+ errorMessage,
902
+ formatZodError,
903
+ perRule,
904
+ readSourceFile,
905
+ fileBytes,
906
+ onPath,
907
+ CORE_VARIABLES,
908
+ renderTemplate,
909
+ templateBindings,
910
+ lineStarts,
911
+ positionAt,
912
+ offsetAt,
913
+ MODEL_ALIASES,
914
+ resolveModel,
915
+ LlmUnavailableError,
916
+ providerByName,
917
+ referenceInputSchema,
918
+ LLM_PROVIDERS,
919
+ defaultDetectorSettings,
920
+ emptyDelivery,
921
+ VERSION2 as VERSION,
922
+ parseVersion,
923
+ isOlder,
924
+ measureRuleBlock,
925
+ renderAgentText,
926
+ tagsOf,
927
+ compileFilter,
928
+ scanHeadings,
929
+ sectionRange,
930
+ sectionText,
931
+ sectionContains,
932
+ isDetectorRule,
933
+ compileRule,
934
+ memoryCache,
935
+ diskCache,
936
+ detectorCacheDir
937
+ };