@syv-ai/rulecast 0.2.0 → 0.4.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.
@@ -8,13 +8,14 @@ import {
8
8
  lineStarts,
9
9
  offsetAt,
10
10
  onPath,
11
+ pastDeadline,
11
12
  perRule,
12
13
  positionAt,
13
14
  providerByName,
14
- readSourceFile,
15
15
  renderAgentText,
16
- resolveModel
17
- } from "./chunk-ZEFEQKG5.js";
16
+ resolveModel,
17
+ sourceReader
18
+ } from "./chunk-DQUCE2IK.js";
18
19
 
19
20
  // src/core/detection/registry.ts
20
21
  function createRegistry(detectors) {
@@ -29,6 +30,61 @@ function createRegistry(detectors) {
29
30
  };
30
31
  }
31
32
 
33
+ // src/core/detection/tool.ts
34
+ import { execFile } from "child_process";
35
+ import { promisify } from "util";
36
+ var exec = promisify(execFile);
37
+ async function runTool(command, args, options) {
38
+ try {
39
+ const running = exec(command, [...args], {
40
+ cwd: options.cwd,
41
+ signal: options.signal,
42
+ // A whole-repository run of a linter on a large project prints megabytes of JSON.
43
+ maxBuffer: 64 * 1024 * 1024
44
+ });
45
+ running.child.stdin?.on("error", () => {
46
+ });
47
+ if (options.stdin === void 0) running.child.stdin?.end();
48
+ else running.child.stdin?.end(options.stdin);
49
+ const result = await running;
50
+ return result.stdout;
51
+ } catch (error) {
52
+ if (options.signal.aborted) throw error;
53
+ if (isNotFound(error)) throw new Error(options.notFound);
54
+ const failure = error;
55
+ if (typeof failure.stdout !== "string") throw error;
56
+ return failure.stdout;
57
+ }
58
+ }
59
+
60
+ // src/core/detection/paths.ts
61
+ import { realpathSync } from "fs";
62
+ import path from "path";
63
+ import { fileURLToPath } from "url";
64
+ function repoRelativeTo(cwd) {
65
+ const roots = rootsOf(cwd);
66
+ return (file) => {
67
+ const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
68
+ if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
69
+ for (const root of roots) {
70
+ const relative = path.relative(root, withoutScheme);
71
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
72
+ }
73
+ return path.relative(cwd, withoutScheme).split(path.sep).join("/");
74
+ };
75
+ }
76
+ function repoRelative(file, cwd) {
77
+ return repoRelativeTo(cwd)(file);
78
+ }
79
+ function rootsOf(root) {
80
+ try {
81
+ const real = realpathSync(root);
82
+ return real === root ? [root] : [root, real];
83
+ } catch {
84
+ return [root];
85
+ }
86
+ }
87
+
32
88
  // src/detectors/ast-grep/languages.ts
33
89
  var LANGUAGES = {
34
90
  css: { builtin: "Css" },
@@ -180,7 +236,7 @@ var astGrepDetector = {
180
236
  if (source === null) continue;
181
237
  root = parser.parse(source).root();
182
238
  } catch (error) {
183
- if (input.signal.aborted) throw error;
239
+ if (pastDeadline(error, input)) throw error;
184
240
  const message = errorMessage(error);
185
241
  for (const rule of rules) {
186
242
  if (rule.files.includes(file) && !result.errors.some((existing) => existing.rule === rule.id)) {
@@ -196,7 +252,7 @@ var astGrepDetector = {
196
252
  result.findings.push({ rule: rule.id, match: matchOf(file, node, variables.get(rule.id)) });
197
253
  }
198
254
  } catch (error) {
199
- if (input.signal.aborted) throw error;
255
+ if (pastDeadline(error, input)) throw error;
200
256
  if (!result.errors.some((existing) => existing.rule === rule.id)) {
201
257
  result.errors.push({ rule: rule.id, message: errorMessage(error) });
202
258
  }
@@ -215,15 +271,10 @@ var astGrepDetector = {
215
271
  };
216
272
 
217
273
  // src/detectors/command/detector.ts
218
- import { execFile } from "child_process";
219
274
  import { access, constants } from "fs/promises";
220
275
  import path2 from "path";
221
- import { promisify } from "util";
222
276
 
223
277
  // src/detectors/command/results.ts
224
- import { realpathSync } from "fs";
225
- import path from "path";
226
- import { fileURLToPath } from "url";
227
278
  var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
228
279
  function parseJson(output) {
229
280
  try {
@@ -232,23 +283,6 @@ function parseJson(output) {
232
283
  throw new Error("output is not JSON");
233
284
  }
234
285
  }
235
- function repoRelative(file, cwd) {
236
- const withoutScheme = file.startsWith("file://") ? fileURLToPath(file) : file;
237
- if (!path.isAbsolute(withoutScheme)) return withoutScheme.split(path.sep).join("/");
238
- for (const root of rootsOf(cwd)) {
239
- const relative = path.relative(root, withoutScheme);
240
- if (!relative.startsWith("..") && !path.isAbsolute(relative)) return relative.split(path.sep).join("/");
241
- }
242
- return path.relative(cwd, withoutScheme).split(path.sep).join("/");
243
- }
244
- function rootsOf(root) {
245
- try {
246
- const real = realpathSync(root);
247
- return real === root ? [root] : [root, real];
248
- } catch {
249
- return [root];
250
- }
251
- }
252
286
  function capturesOf2(source, names, where) {
253
287
  const captures = {};
254
288
  for (const name of names) {
@@ -259,6 +293,7 @@ function capturesOf2(source, names, where) {
259
293
  return captures;
260
294
  }
261
295
  function matchesFromJson(output, captures, cwd) {
296
+ const relative = repoRelativeTo(cwd);
262
297
  const parsed = parseJson(output);
263
298
  if (!Array.isArray(parsed)) throw new Error("output is not a JSON array of results");
264
299
  return parsed.map((result, index) => {
@@ -267,7 +302,7 @@ function matchesFromJson(output, captures, cwd) {
267
302
  if (typeof result.file !== "string") throw new Error(`${where}: "file" must be a string`);
268
303
  if (typeof result.line !== "number") throw new Error(`${where}: "line" must be a number`);
269
304
  return {
270
- file: repoRelative(result.file, cwd),
305
+ file: relative(result.file),
271
306
  line: result.line,
272
307
  endLine: typeof result.endLine === "number" ? result.endLine : result.line,
273
308
  column: typeof result.column === "number" ? result.column : 1,
@@ -277,6 +312,7 @@ function matchesFromJson(output, captures, cwd) {
277
312
  });
278
313
  }
279
314
  function matchesFromSarif(output, captures, cwd) {
315
+ const relative = repoRelativeTo(cwd);
280
316
  const parsed = parseJson(output);
281
317
  if (!isObject(parsed) || !Array.isArray(parsed.runs)) throw new Error("output is not SARIF 2.1.0");
282
318
  const matches = [];
@@ -296,7 +332,7 @@ function matchesFromSarif(output, captures, cwd) {
296
332
  const message = isObject(result.message) && typeof result.message.text === "string" ? result.message.text : "";
297
333
  const source = isObject(result.properties) ? { ...result, ...result.properties } : result;
298
334
  matches.push({
299
- file: repoRelative(artifact.uri, cwd),
335
+ file: relative(artifact.uri),
300
336
  line,
301
337
  endLine: typeof region.endLine === "number" ? region.endLine : line,
302
338
  column: typeof region.startColumn === "number" ? region.startColumn : 1,
@@ -325,7 +361,6 @@ var commandSchema = z2.object({
325
361
  }).strict();
326
362
 
327
363
  // src/detectors/command/detector.ts
328
- var exec = promisify(execFile);
329
364
  var FILES = "{{files}}";
330
365
  function argvFor(run, files) {
331
366
  if (!run.includes(FILES)) return [...run, ...files];
@@ -356,17 +391,11 @@ var commandDetector = {
356
391
  run: perRule(async (rule, input) => {
357
392
  if (rule.files.length === 0) return [];
358
393
  const [command, ...rest] = argvFor(rule.config.run, rule.files);
359
- let stdout;
360
- try {
361
- const result = await exec(command, rest, { cwd: input.cwd, signal: input.signal, maxBuffer: 64 * 1024 * 1024 });
362
- stdout = result.stdout;
363
- } catch (error) {
364
- if (input.signal.aborted) throw error;
365
- if (isNotFound(error)) throw new Error(`command not found: ${command}`);
366
- const failure = error;
367
- if (typeof failure.stdout !== "string") throw error;
368
- stdout = failure.stdout;
369
- }
394
+ const stdout = await runTool(command, rest, {
395
+ cwd: input.cwd,
396
+ signal: input.signal,
397
+ notFound: `command not found: ${command}`
398
+ });
370
399
  return rule.config.output === "sarif" ? matchesFromSarif(stdout, rule.config.captures, input.cwd) : matchesFromJson(stdout, rule.config.captures, input.cwd);
371
400
  }),
372
401
  async check(input) {
@@ -384,10 +413,6 @@ var commandDetector = {
384
413
  }
385
414
  };
386
415
 
387
- // src/detectors/linter/detector.ts
388
- import { execFile as execFile2 } from "child_process";
389
- import { promisify as promisify2 } from "util";
390
-
391
416
  // src/detectors/linter/resolve.ts
392
417
  import { access as access2, constants as constants2 } from "fs/promises";
393
418
  import path3 from "path";
@@ -432,8 +457,6 @@ var linterSchema = z3.object({
432
457
  }).strict();
433
458
 
434
459
  // src/detectors/linter/tools.ts
435
- import { realpathSync as realpathSync2 } from "fs";
436
- import path4 from "path";
437
460
  var isObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
438
461
  function json(tool, stdout) {
439
462
  try {
@@ -442,22 +465,6 @@ function json(tool, stdout) {
442
465
  throw new Error(`${tool} output is not JSON`);
443
466
  }
444
467
  }
445
- function relativeTo(roots, file) {
446
- if (!path4.isAbsolute(file)) return file.split(path4.sep).join("/");
447
- for (const root of roots) {
448
- const relative = path4.relative(root, file);
449
- if (!relative.startsWith("..") && !path4.isAbsolute(relative)) return relative.split(path4.sep).join("/");
450
- }
451
- return path4.relative(roots[0], file).split(path4.sep).join("/");
452
- }
453
- function rootsOf2(root) {
454
- try {
455
- const real = realpathSync2(root);
456
- return real === root ? [root] : [root, real];
457
- } catch {
458
- return [root];
459
- }
460
- }
461
468
  var number = (value, fallback) => typeof value === "number" ? value : fallback;
462
469
  var text = (value) => typeof value === "string" ? value : "";
463
470
  function oxlintRuleId(code) {
@@ -468,16 +475,17 @@ var TOOLS = {
468
475
  ruff: {
469
476
  events: ["edit", "verify"],
470
477
  args: (files) => ["check", "--output-format", "json", "--force-exclude", "--", ...files],
478
+ stdinArgs: (file) => ["check", "--output-format", "json", "--force-exclude", "--stdin-filename", file, "-"],
471
479
  parse(stdout, root) {
472
480
  const parsed = json("ruff", stdout);
473
481
  if (!Array.isArray(parsed)) throw new Error("ruff output is not a JSON array of diagnostics");
474
- const roots = rootsOf2(root);
482
+ const relative = repoRelativeTo(root);
475
483
  return parsed.filter(isObject2).map((result) => {
476
484
  const start = isObject2(result.location) ? result.location : {};
477
485
  const end = isObject2(result.end_location) ? result.end_location : {};
478
486
  const line = number(start.row, 1);
479
487
  return {
480
- file: relativeTo(roots, text(result.filename)),
488
+ file: relative(text(result.filename)),
481
489
  line,
482
490
  column: number(start.column, 1),
483
491
  endLine: number(end.row, line),
@@ -496,13 +504,13 @@ var TOOLS = {
496
504
  if (!isObject2(parsed) || !Array.isArray(parsed.diagnostics)) {
497
505
  throw new Error("oxlint output is not JSON with diagnostics");
498
506
  }
499
- const roots = rootsOf2(root);
507
+ const relative = repoRelativeTo(root);
500
508
  return parsed.diagnostics.filter(isObject2).map((diagnostic) => {
501
509
  const label = Array.isArray(diagnostic.labels) ? diagnostic.labels[0] : void 0;
502
510
  const span = isObject2(label) && isObject2(label.span) ? label.span : {};
503
511
  const line = number(span.line, 1);
504
512
  return {
505
- file: relativeTo(roots, text(diagnostic.filename)),
513
+ file: relative(text(diagnostic.filename)),
506
514
  line,
507
515
  column: number(span.column, 1),
508
516
  endLine: line,
@@ -517,17 +525,18 @@ var TOOLS = {
517
525
  // eslint is the slow one: spec §6 keeps it off the edit hook by default.
518
526
  events: ["verify"],
519
527
  args: (files) => ["--format=json", "--no-error-on-unmatched-pattern", "--", ...files],
528
+ stdinArgs: (file) => ["--format=json", "--stdin", "--stdin-filename", file],
520
529
  parse(stdout, root) {
521
530
  const parsed = json("eslint", stdout);
522
531
  if (!Array.isArray(parsed)) throw new Error("eslint output is not a JSON array of file results");
523
- const roots = rootsOf2(root);
532
+ const relative = repoRelativeTo(root);
524
533
  const findings = [];
525
534
  for (const file of parsed.filter(isObject2)) {
526
535
  const messages = Array.isArray(file.messages) ? file.messages : [];
527
536
  for (const message of messages.filter(isObject2)) {
528
537
  const line = number(message.line, 1);
529
538
  findings.push({
530
- file: relativeTo(roots, text(file.filePath)),
539
+ file: relative(text(file.filePath)),
531
540
  line,
532
541
  column: number(message.column, 1),
533
542
  endLine: number(message.endLine, line),
@@ -543,18 +552,6 @@ var TOOLS = {
543
552
  };
544
553
 
545
554
  // src/detectors/linter/detector.ts
546
- var exec2 = promisify2(execFile2);
547
- function sourceReader(cwd) {
548
- const cache = /* @__PURE__ */ new Map();
549
- return (file) => {
550
- let source = cache.get(file);
551
- if (source === void 0) {
552
- source = readSourceFile(cwd, file);
553
- cache.set(file, source);
554
- }
555
- return source;
556
- };
557
- }
558
555
  function excerpt(source, finding) {
559
556
  if (source === null) return "";
560
557
  const starts = lineStarts(source);
@@ -568,41 +565,45 @@ function excerpt(source, finding) {
568
565
  }
569
566
  return source.slice(from, Math.max(from, to)).replace(/\n$/, "");
570
567
  }
571
- async function runTool(tool, files, cwd, signal) {
568
+ async function runLinter(tool, files, cwd, signal) {
572
569
  const resolved = await resolveTool(tool, cwd);
573
570
  const args = [...resolved.prefix, ...TOOLS[tool].args(files)];
574
- let stdout;
575
- try {
576
- const result = await exec2(resolved.command, args, { cwd, signal, maxBuffer: 64 * 1024 * 1024 });
577
- stdout = result.stdout;
578
- } catch (error) {
579
- if (signal.aborted) throw error;
580
- if (isNotFound(error)) throw new Error(`${tool} is not installed`);
581
- const failure = error;
582
- if (typeof failure.stdout !== "string") throw error;
583
- stdout = failure.stdout;
584
- }
571
+ const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed` });
585
572
  return TOOLS[tool].parse(stdout, cwd);
586
573
  }
574
+ async function runLinterOnContent(tool, files, read, cwd, signal) {
575
+ const resolved = await resolveTool(tool, cwd);
576
+ const findings = [];
577
+ for (const file of files) {
578
+ const stdin = await read(file);
579
+ if (stdin === null) continue;
580
+ const args = [...resolved.prefix, ...TOOLS[tool].stdinArgs(file)];
581
+ const stdout = await runTool(resolved.command, args, { cwd, signal, notFound: `${tool} is not installed`, stdin });
582
+ findings.push(...TOOLS[tool].parse(stdout, cwd));
583
+ }
584
+ return findings;
585
+ }
587
586
  var linterDetector = {
588
587
  kind: "linter",
589
588
  schema: linterSchema,
590
589
  captures: () => ["message", "ruleId"],
591
590
  // A copy: compileRule stores this as the rule's stages, and module-level data must not escape into it.
592
591
  events: (config) => [...TOOLS[config.tool].events],
592
+ takesContent: (config) => TOOLS[config.tool].stdinArgs !== void 0,
593
593
  async run(input) {
594
594
  const result = { findings: [], errors: [] };
595
595
  const byTool = /* @__PURE__ */ new Map();
596
596
  for (const rule of input.rules) {
597
597
  byTool.set(rule.config.tool, [...byTool.get(rule.config.tool) ?? [], rule]);
598
598
  }
599
- const read = sourceReader(input.cwd);
599
+ const onDisk = input.fromDisk !== false;
600
+ const read = onDisk ? sourceReader(input.cwd) : input.read;
600
601
  await Promise.all(
601
602
  [...byTool].map(async ([tool, rules]) => {
602
603
  const files = [...new Set(rules.flatMap((rule) => rule.files))].sort();
603
604
  if (files.length === 0) return;
604
605
  try {
605
- const findings = await runTool(tool, files, input.cwd, input.signal);
606
+ const findings = onDisk ? await runLinter(tool, files, input.cwd, input.signal) : await runLinterOnContent(tool, files, read, input.cwd, input.signal);
606
607
  for (const finding of findings) {
607
608
  const match = {
608
609
  file: finding.file,
@@ -619,7 +620,7 @@ var linterDetector = {
619
620
  }
620
621
  }
621
622
  } catch (error) {
622
- if (input.signal.aborted) throw error;
623
+ if (pastDeadline(error, input)) throw error;
623
624
  const message = errorMessage(error);
624
625
  for (const rule of rules) result.errors.push({ rule: rule.id, message });
625
626
  }
@@ -722,17 +723,6 @@ var llmSchema = z4.object({
722
723
  }).strict();
723
724
 
724
725
  // src/detectors/llm/detector.ts
725
- function sourceReader2(cwd) {
726
- const cache = /* @__PURE__ */ new Map();
727
- return (file) => {
728
- let source = cache.get(file);
729
- if (source === void 0) {
730
- source = readSourceFile(cwd, file);
731
- cache.set(file, source);
732
- }
733
- return source;
734
- };
735
- }
736
726
  function changedLinesOf(changes, file) {
737
727
  const change = changes.get(file);
738
728
  if (change === void 0) return null;
@@ -824,6 +814,15 @@ var llmDetector = {
824
814
  // verify only by default (spec §6): a model call is far too slow for an edit hook. A rule opts
825
815
  // in with `stages: [edit, verify]`.
826
816
  events: () => ["verify"],
817
+ // The prompt carries the file's text, so content that is not on disk needs no scratch copy.
818
+ takesContent: () => true,
819
+ // Spec §6, Consent: an llm rule sends file contents to a third party and costs money per file.
820
+ // Everything the core does differently for it follows from these, not from the name "llm".
821
+ metered: true,
822
+ cost: (config) => `llm \xB7 ${config.model} \xB7 sends file contents to your provider`,
823
+ fileBudget: (settings) => ({ max: settings.llm.maxFilesPerVerify, setting: "llm.max_files_per_verify" }),
824
+ // A field trial measured a single haiku call on a 135-line file at 89 s against a 60 s default.
825
+ timeoutHint: "an llm rule on a large file can need 120000 or more",
827
826
  async run(input) {
828
827
  const result = { findings: [], errors: [] };
829
828
  const settings = input.settings.llm;
@@ -833,13 +832,14 @@ var llmDetector = {
833
832
  } catch (error) {
834
833
  return { findings: [], errors: [{ rule: null, message: errorMessage(error) }] };
835
834
  }
836
- const calls = await groupCalls(input.rules, input.changes, sourceReader2(input.cwd));
835
+ const read = input.fromDisk === false ? input.read : sourceReader(input.cwd);
836
+ const calls = await groupCalls(input.rules, input.changes, read);
837
837
  const outcomes = await Promise.all(
838
838
  calls.map(async (call) => {
839
839
  try {
840
840
  return { call, findings: await runCall(call, provider, settings, input) };
841
841
  } catch (error) {
842
- if (input.signal.aborted) throw error;
842
+ if (pastDeadline(error, input)) throw error;
843
843
  return { call, error };
844
844
  }
845
845
  })
@@ -923,6 +923,8 @@ var pathDetector = {
923
923
  captures: () => [],
924
924
  events: () => ["edit", "verify"],
925
925
  guards: true,
926
+ // The match is the file itself, and writing it is what the rule forbids.
927
+ wholeFile: true,
926
928
  run: perRule(async (rule, input) => {
927
929
  const matches = [];
928
930
  for (const file of rule.files) {
@@ -1162,18 +1164,38 @@ function hooksOf(settings) {
1162
1164
  if (!isObject3(hooks)) throw new SettingsError('"hooks" must be an object');
1163
1165
  return hooks;
1164
1166
  }
1165
- function mergeHooks(settings, command, verifyMs) {
1167
+ function mergeHooks(settings, command, verifyMs, keep) {
1166
1168
  const merged = { ...hooksOf(settings) };
1167
1169
  const added = [];
1170
+ const updated = [];
1168
1171
  for (const { event, matcher: matcher2, timeout } of hookGroups(verifyMs)) {
1169
1172
  const groups = merged[event] ?? [];
1170
1173
  if (!Array.isArray(groups)) throw new SettingsError(`"hooks.${event}" must be an array`);
1171
- if (installed(groups, matcher2)) continue;
1174
+ if (installed(groups, matcher2)) {
1175
+ let changed = false;
1176
+ const rewritten = groups.map((group) => {
1177
+ if (!isObject3(group) || group.matcher !== matcher2 || !Array.isArray(group.hooks)) return group;
1178
+ return {
1179
+ ...group,
1180
+ hooks: group.hooks.map((hook2) => {
1181
+ const current = hook2.command;
1182
+ if (!isRulecastHook(hook2) || current === command || current === keep) return hook2;
1183
+ changed = true;
1184
+ return { ...hook2, command };
1185
+ })
1186
+ };
1187
+ });
1188
+ if (changed) {
1189
+ merged[event] = rewritten;
1190
+ updated.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
1191
+ }
1192
+ continue;
1193
+ }
1172
1194
  const hook = { type: "command", command, timeout };
1173
1195
  merged[event] = [...groups, matcher2 === void 0 ? { hooks: [hook] } : { matcher: matcher2, hooks: [hook] }];
1174
1196
  added.push(matcher2 === void 0 ? event : `${event} (${matcher2})`);
1175
1197
  }
1176
- return { settings: { ...settings, hooks: merged }, added };
1198
+ return { settings: { ...settings, hooks: merged }, added, updated };
1177
1199
  }
1178
1200
  function removeHooks(settings) {
1179
1201
  const hooks = hooksOf(settings);
@@ -1206,9 +1228,11 @@ function removeHooks(settings) {
1206
1228
  // src/adapters/claude-code/adapter.ts
1207
1229
  var CONTEXT_LIMIT = 1e4;
1208
1230
  var CONTEXT_BUDGET = 9e3;
1209
- var BLOCK_PREAMBLE = "This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish.";
1231
+ var BLOCK_PREAMBLE = `This project's rulecast rules (.rulecast-config.yaml) found problems in code changed in this session. Fix them before you finish. If you believe a finding is wrong, say so to the user rather than changing .rulecast-config.yaml; add a "rulecast-ignore: <rule> <reason>" comment only if they agree.`;
1210
1232
  var CAP_PREAMBLE = "rulecast: the agent stopped with these findings unresolved (stop gate limit reached).";
1211
1233
  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.";
1234
+ var NOT_INSTALLED = "rulecast is configured in this project but not installed. Run npm install (or pnpm install), then rulecast doctor.";
1235
+ var LOCAL_COMMAND = `B="$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast; [ -x "$B" ] && exec "$B" hook claude-code; grep -q '"hook_event_name": *"SessionStart"' && printf '%s' '${JSON.stringify({ systemMessage: NOT_INSTALLED })}'; exit 0`;
1212
1236
  function cutNotice(delivery, event) {
1213
1237
  if (delivery.overflowPath !== null) {
1214
1238
  return `
@@ -1238,8 +1262,12 @@ var claudeCodeAdapter = {
1238
1262
  restoredFiles: 5,
1239
1263
  parse: parseClaudeCode,
1240
1264
  format(delivery, event, options) {
1241
- const text2 = renderAgentText(delivery, options);
1242
- if (text2 === "") return NONE;
1265
+ const text2 = renderAgentText(delivery, { ...options, notices: false, warnings: false });
1266
+ const problems = delivery.warnings.length === 0 ? [] : ["rulecast problems:", ...delivery.warnings.map((w) => ` - ${w}`)];
1267
+ const notices = [...delivery.notices ?? [], ...problems].join("\n");
1268
+ const userOnly = event.kind === "guard" ? NONE : json2({ systemMessage: notices });
1269
+ if (text2 === "") return notices === "" ? NONE : userOnly;
1270
+ const withNotices = (value) => notices === "" ? value : { ...value, systemMessage: notices };
1243
1271
  switch (event.kind) {
1244
1272
  case "guard":
1245
1273
  return json2({
@@ -1253,23 +1281,36 @@ ${text2}`, delivery, event)
1253
1281
  });
1254
1282
  case "touch":
1255
1283
  case "edit":
1256
- return json2({
1257
- hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
1258
- });
1284
+ return json2(
1285
+ withNotices({
1286
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: withinLimit(text2, delivery, event) }
1287
+ })
1288
+ );
1259
1289
  case "reset":
1260
- return json2({
1261
- hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: withinLimit(text2, delivery, event) }
1262
- });
1290
+ return json2(
1291
+ withNotices({
1292
+ hookSpecificOutput: {
1293
+ hookEventName: "SessionStart",
1294
+ additionalContext: withinLimit(text2, delivery, event)
1295
+ }
1296
+ })
1297
+ );
1263
1298
  case "verify":
1264
1299
  if (delivery.stop === "block")
1265
- return json2({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
1300
+ return json2(
1301
+ withNotices({ decision: "block", reason: withinLimit(`${BLOCK_PREAMBLE}
1266
1302
 
1267
- ${text2}`, delivery, event) });
1268
- if (delivery.stop === "capReached")
1269
- return json2({ systemMessage: withinLimit(`${CAP_PREAMBLE}
1303
+ ${text2}`, delivery, event) })
1304
+ );
1305
+ if (delivery.stop === "capReached") {
1306
+ const cap = withinLimit(`${CAP_PREAMBLE}
1270
1307
 
1271
- ${text2}`, delivery, event) });
1272
- return NONE;
1308
+ ${text2}`, delivery, event);
1309
+ return json2({ systemMessage: notices === "" ? cap : `${notices}
1310
+
1311
+ ${cap}` });
1312
+ }
1313
+ return notices === "" ? NONE : json2({ systemMessage: notices });
1273
1314
  default:
1274
1315
  return NONE;
1275
1316
  }
@@ -1280,8 +1321,11 @@ ${text2}`, delivery, event) });
1280
1321
  { scope: "shared", file: ".claude/settings.json" },
1281
1322
  { scope: "personal", file: ".claude/settings.local.json" }
1282
1323
  ],
1283
- command: (local) => local ? '"$CLAUDE_PROJECT_DIR"/node_modules/.bin/rulecast hook claude-code' : "rulecast hook claude-code",
1284
- merge: mergeHooks,
1324
+ command: (local) => local ? LOCAL_COMMAND : "rulecast hook claude-code",
1325
+ // The guarded command is never rewritten to the bare one. It is right with or without
1326
+ // node_modules, and `install` in a clone that has not installed yet would otherwise swap the
1327
+ // committed command for one that exits 127 on every hook.
1328
+ merge: (settings, command, verifyMs) => mergeHooks(settings, command, verifyMs, LOCAL_COMMAND),
1285
1329
  remove: removeHooks
1286
1330
  }
1287
1331
  };
@@ -1292,10 +1336,74 @@ function adapterByName(name) {
1292
1336
  return ADAPTERS.find((adapter) => adapter.name === name);
1293
1337
  }
1294
1338
 
1339
+ // src/adapters/cli/backlog.ts
1340
+ function summarise(delivery) {
1341
+ const byRule = /* @__PURE__ */ new Map();
1342
+ const byFile = /* @__PURE__ */ new Map();
1343
+ const add = (rule, file, count) => {
1344
+ const entry = byRule.get(rule) ?? { violations: 0, files: /* @__PURE__ */ new Set() };
1345
+ entry.violations += count;
1346
+ entry.files.add(file);
1347
+ byRule.set(rule, entry);
1348
+ byFile.set(file, (byFile.get(file) ?? 0) + count);
1349
+ };
1350
+ for (const finding of delivery.findings) add(finding.rule, finding.file, finding.count);
1351
+ for (const summary of delivery.preexistingSummary) add(summary.rule, summary.file, summary.count);
1352
+ const rules = [...byRule].map(([rule, entry]) => ({ rule, violations: entry.violations, files: entry.files.size })).sort((a, b) => b.violations - a.violations || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0));
1353
+ const files = [...byFile].map(([file, violations]) => ({ file, violations })).sort((a, b) => b.violations - a.violations || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
1354
+ return {
1355
+ rules,
1356
+ files,
1357
+ totalViolations: rules.reduce((sum, entry) => sum + entry.violations, 0),
1358
+ totalFiles: files.length
1359
+ };
1360
+ }
1361
+ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
1362
+ function table(header, rows) {
1363
+ const widths = header.map((_, column) => Math.max(...[header, ...rows].map((row) => row[column].length)));
1364
+ const line = (row) => ` ${row.map((cell, column) => column === 0 ? cell.padEnd(widths[column]) : cell.padStart(widths[column])).join(" ")}`;
1365
+ return [line(header), ...rows.map(line)];
1366
+ }
1367
+ function renderBacklog(summary, options) {
1368
+ if (summary.totalViolations === 0) return "backlog: no violations";
1369
+ const out = [
1370
+ `backlog: ${plural(summary.totalViolations, "violation")} in ${plural(summary.totalFiles, "file")}`,
1371
+ "",
1372
+ ...table(
1373
+ ["rule", "violations", "files"],
1374
+ summary.rules.map((entry) => [entry.rule, String(entry.violations), String(entry.files)])
1375
+ )
1376
+ ];
1377
+ const shown = summary.files.slice(0, options.topFiles);
1378
+ if (shown.length > 0) {
1379
+ out.push(
1380
+ "",
1381
+ ...table(
1382
+ ["worst files", "violations"],
1383
+ shown.map((entry) => [entry.file, String(entry.violations)])
1384
+ )
1385
+ );
1386
+ const rest = summary.files.length - shown.length;
1387
+ if (rest > 0) out.push(` \u2026and ${plural(rest, "more file")}`);
1388
+ }
1389
+ if (options.note === false) return out.join("\n");
1390
+ out.push(
1391
+ "",
1392
+ "This is the stock, not a rate. Enforcement on edits does not reduce it.",
1393
+ "Remediate a file at a time: a file that already follows a rule rarely breaks it."
1394
+ );
1395
+ return out.join("\n");
1396
+ }
1397
+
1295
1398
  export {
1296
1399
  createRegistry,
1400
+ runTool,
1401
+ repoRelativeTo,
1402
+ repoRelative,
1297
1403
  builtinDetectors,
1298
1404
  claudeCodeAdapter,
1299
1405
  ADAPTERS,
1300
- adapterByName
1406
+ adapterByName,
1407
+ summarise,
1408
+ renderBacklog
1301
1409
  };