@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.
@@ -4,6 +4,7 @@ import {
4
4
  VERSION,
5
5
  compileFilter,
6
6
  compileRule,
7
+ deliveryCost,
7
8
  detectorCacheDir,
8
9
  diskCache,
9
10
  emptyDelivery,
@@ -23,7 +24,7 @@ import {
23
24
  sectionRange,
24
25
  sectionText,
25
26
  templateBindings
26
- } from "./chunk-ZEFEQKG5.js";
27
+ } from "./chunk-DQUCE2IK.js";
27
28
 
28
29
  // src/core/config/load.ts
29
30
  import { readFile } from "fs/promises";
@@ -34,9 +35,11 @@ import { parse } from "yaml";
34
35
  import { z } from "zod";
35
36
  var STAGES = ["touch", "edit", "verify"];
36
37
  var RULE_ID = /^[a-z0-9-]+(\/[a-z0-9-]+)*$/;
38
+ var RULE_SCOPES = ["instance", "container"];
37
39
  var idSchema = z.string().regex(RULE_ID, "must be lowercase segments separated by /");
38
40
  var versionSchema = z.string().regex(/^\d+\.\d+\.\d+$/, "must be a version like 0.2.0");
39
41
  var stagesSchema = z.array(z.enum(STAGES)).nonempty();
42
+ var exampleSchema = z.object({ path: z.string().min(1), code: z.string() }).strict();
40
43
  var ruleKeys = {
41
44
  alias: idSchema.optional(),
42
45
  name: z.string().min(1).optional(),
@@ -49,10 +52,20 @@ var ruleKeys = {
49
52
  stages: stagesSchema.optional(),
50
53
  minimum_rulecast_version: versionSchema.optional(),
51
54
  severity: z.enum(["error", "warning"]).optional(),
55
+ /**
56
+ * What the convention belongs to (§8). `instance`: the token the detector matched, classified by
57
+ * whether the agent's edit touched it. `container`: the node the match spans, classified by
58
+ * whether that node already violated the rule at the baseline.
59
+ */
60
+ scope: z.enum(RULE_SCOPES).optional(),
52
61
  refuse_write: z.boolean().optional(),
62
+ /** false: compiled and listed, selected for nothing. The way to switch a catalog rule off without deleting it. */
63
+ enabled: z.boolean().optional(),
53
64
  detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
54
65
  message: z.string().optional(),
55
- context: z.array(referenceInputSchema).optional()
66
+ context: z.array(referenceInputSchema).optional(),
67
+ /** Code the rule must flag (`bad`) and must not (`good`), run by `rulecast test`. */
68
+ examples: z.object({ good: z.array(exampleSchema).default([]), bad: z.array(exampleSchema).default([]) }).strict().optional()
56
69
  };
57
70
  var overrideSchema = z.object({ id: idSchema, ...ruleKeys }).strict();
58
71
  var ruleSchema = overrideSchema.extend({ name: z.string().min(1) });
@@ -139,13 +152,15 @@ function readConfigData(root) {
139
152
  }
140
153
  function parseConfig(data) {
141
154
  const result = configSchema.safeParse(data);
142
- if (!result.success) return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error)}` };
155
+ if (!result.success) {
156
+ return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error, { schema: configSchema })}` };
157
+ }
143
158
  return { ok: true, value: result.data };
144
159
  }
145
- async function readManifest(dir) {
146
- const loaded = await readYamlFile(path.join(dir, MANIFEST_FILE));
160
+ async function readManifest(dir, file = path.join(dir, MANIFEST_FILE)) {
161
+ const loaded = await readYamlFile(file);
147
162
  if (!loaded.ok) return loaded;
148
- if (!Array.isArray(loaded.value)) return { ok: false, message: `${MANIFEST_FILE}: must be a list of rules` };
163
+ if (!Array.isArray(loaded.value)) return { ok: false, message: `${path.basename(file)}: must be a list of rules` };
149
164
  return { ok: true, value: loaded.value };
150
165
  }
151
166
 
@@ -186,6 +201,7 @@ function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
186
201
 
187
202
  // src/core/compile/project.ts
188
203
  import { readFile as readFile2 } from "fs/promises";
204
+ import path3 from "path";
189
205
  function diagnosticText(diagnostic) {
190
206
  const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
191
207
  return `${diagnostic.source}${rule}: ${diagnostic.message}`;
@@ -263,7 +279,7 @@ async function compile(options) {
263
279
  }
264
280
  for (const raw of entry.rules) {
265
281
  const rule = ruleSchema.safeParse(raw);
266
- if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
282
+ if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error, { schema: ruleSchema }));
267
283
  else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
268
284
  }
269
285
  continue;
@@ -300,7 +316,7 @@ async function compile(options) {
300
316
  for (const raw of entry.rules) {
301
317
  const override = overrideSchema.safeParse(raw);
302
318
  if (!override.success) {
303
- error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
319
+ error(CONFIG_FILE, idOf(raw), formatZodError(override.error, { schema: overrideSchema }));
304
320
  continue;
305
321
  }
306
322
  const { id } = override.data;
@@ -311,7 +327,7 @@ async function compile(options) {
311
327
  }
312
328
  const base = ruleSchema.safeParse(candidates[0]);
313
329
  if (!base.success) {
314
- error(source, id, `manifest: ${formatZodError(base.error)}`);
330
+ error(source, id, formatZodError(base.error, { prefix: "manifest", schema: ruleSchema }));
315
331
  continue;
316
332
  }
317
333
  const data2 = { ...base.data, ...override.data };
@@ -326,12 +342,13 @@ async function compile(options) {
326
342
  );
327
343
  return { root, config, rules, diagnostics };
328
344
  }
329
- async function compileManifest(dir, registry) {
330
- const manifest = await readManifest(dir);
345
+ async function compileManifest(dir, registry, file = path3.join(dir, MANIFEST_FILE)) {
346
+ const source = path3.basename(file);
347
+ const manifest = await readManifest(dir, file);
331
348
  if (!manifest.ok) {
332
349
  return {
333
350
  rules: [],
334
- diagnostics: [{ source: MANIFEST_FILE, rule: null, message: manifest.message, level: "error" }]
351
+ diagnostics: [{ source, rule: null, message: manifest.message, level: "error" }]
335
352
  };
336
353
  }
337
354
  const diagnostics = [];
@@ -340,18 +357,23 @@ async function compileManifest(dir, registry) {
340
357
  for (const raw of manifest.value) {
341
358
  const rule = ruleSchema.safeParse(raw);
342
359
  if (!rule.success) {
343
- diagnostics.push({ source: MANIFEST_FILE, rule: idOf(raw), message: formatZodError(rule.error), level: "error" });
360
+ diagnostics.push({
361
+ source,
362
+ rule: idOf(raw),
363
+ message: formatZodError(rule.error, { schema: ruleSchema }),
364
+ level: "error"
365
+ });
344
366
  continue;
345
367
  }
346
368
  const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
347
369
  if (typeof result === "string") {
348
370
  diagnostics.push({
349
- source: MANIFEST_FILE,
371
+ source,
350
372
  rule: rule.data.alias ?? rule.data.id,
351
373
  message: result,
352
374
  level: "error"
353
375
  });
354
- } else compiled.push({ rule: result, source: MANIFEST_FILE });
376
+ } else compiled.push({ rule: result, source });
355
377
  }
356
378
  const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
357
379
  return { rules, diagnostics };
@@ -360,7 +382,7 @@ async function compileManifest(dir, registry) {
360
382
  // src/core/detection/select.ts
361
383
  function selectDetectorRules(rules, event, files, disabled) {
362
384
  return rules.filter(
363
- (rule) => isDetectorRule(rule) && rule.stages.includes(event) && !disabled.has(rule.id)
385
+ (rule) => isDetectorRule(rule) && rule.enabled && rule.stages.includes(event) && !disabled.has(rule.id)
364
386
  ).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
365
387
  }
366
388
  function rulesOfKind(rules, kind) {
@@ -368,7 +390,7 @@ function rulesOfKind(rules, kind) {
368
390
  }
369
391
  function selectTouchRules(rules, files, touched, disabled) {
370
392
  return rules.filter(
371
- (rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
393
+ (rule) => rule.enabled && rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
372
394
  );
373
395
  }
374
396
 
@@ -418,7 +440,7 @@ async function checkDetectors(options) {
418
440
  import { randomUUID } from "crypto";
419
441
  import { existsSync as existsSync2 } from "fs";
420
442
  import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
421
- import path5 from "path";
443
+ import path6 from "path";
422
444
 
423
445
  // src/core/git.ts
424
446
  import { execFile } from "child_process";
@@ -464,10 +486,26 @@ async function fileAtCommit(cwd, commit, file) {
464
486
  const result = await git(cwd, ["show", `${commit}:./${file}`]);
465
487
  return result.ok ? result.stdout : null;
466
488
  }
489
+ async function fileInIndex(cwd, file) {
490
+ const result = await git(cwd, ["show", `:./${file}`]);
491
+ return result.ok ? result.stdout : null;
492
+ }
493
+ async function isShallow(cwd) {
494
+ const result = await git(cwd, ["rev-parse", "--is-shallow-repository"]);
495
+ return result.ok && result.stdout.trim() === "true";
496
+ }
497
+ var FETCH_HISTORY = "fetch full history (actions/checkout: fetch-depth: 0)";
467
498
  async function mergeBase(cwd, ref, other = "HEAD") {
468
499
  const result = await git(cwd, ["merge-base", other, ref]);
469
- if (!result.ok) throw new Error(`cannot find merge base with ${ref}: ${result.stderr.trim()}`);
470
- return result.stdout.trim();
500
+ if (result.ok) return result.stdout.trim();
501
+ const shallow = await isShallow(cwd);
502
+ const resolves = (await git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).ok;
503
+ if (!resolves) {
504
+ throw new Error(`"${ref}" is not a commit here${shallow ? `; this is a shallow clone: ${FETCH_HISTORY}` : ""}`);
505
+ }
506
+ if (shallow) throw new Error(`no merge base with ${ref} in a shallow clone: ${FETCH_HISTORY}`);
507
+ const detail = result.stderr.trim();
508
+ throw new Error(`no merge base with ${ref}${detail ? `: ${detail}` : ": the histories are unrelated"}`);
471
509
  }
472
510
  async function changedFilesSince(cwd, commit) {
473
511
  const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
@@ -498,7 +536,7 @@ async function changedFilesBetween(cwd, from, to) {
498
536
 
499
537
  // src/core/session/lock.ts
500
538
  import { mkdir, rm, stat } from "fs/promises";
501
- import path3 from "path";
539
+ import path4 from "path";
502
540
  var LockTimeoutError = class extends Error {
503
541
  };
504
542
  var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
@@ -506,7 +544,7 @@ function isAlreadyExists(error) {
506
544
  return error instanceof Error && error.code === "EEXIST";
507
545
  }
508
546
  async function withLock(dir, fn, options = DEFAULTS) {
509
- const lock = path3.join(dir, ".lock");
547
+ const lock = path4.join(dir, ".lock");
510
548
  await mkdir(dir, { recursive: true });
511
549
  const started = Date.now();
512
550
  for (; ; ) {
@@ -539,7 +577,7 @@ async function withLock(dir, fn, options = DEFAULTS) {
539
577
 
540
578
  // src/core/repos/layout.ts
541
579
  import { createHash as createHash2 } from "crypto";
542
- import path4 from "path";
580
+ import path5 from "path";
543
581
  import { fileURLToPath } from "url";
544
582
  var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
545
583
  var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
@@ -547,7 +585,7 @@ function segmentsOf(repoPath) {
547
585
  return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
548
586
  }
549
587
  function localName(file) {
550
- return path4.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
588
+ return path5.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
551
589
  }
552
590
  function parseUrl(url) {
553
591
  if (SCHEME.test(url)) {
@@ -574,7 +612,7 @@ function repoSlug(url) {
574
612
  return safeSegment(`local_${parsed.name}_${hash}`);
575
613
  }
576
614
  function repoDir(home, url, rev) {
577
- return path4.join(home, "repos", repoSlug(url), safeSegment(rev));
615
+ return path5.join(home, "repos", repoSlug(url), safeSegment(rev));
578
616
  }
579
617
  function repoLabel(url, rev) {
580
618
  return `${repoName(url)}@${rev}`;
@@ -594,7 +632,7 @@ async function fetchCheckout(url, rev, into) {
594
632
  await run(into, ["init", "-q"]);
595
633
  await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
596
634
  await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
597
- await rm2(path5.join(into, ".git"), { recursive: true, force: true });
635
+ await rm2(path6.join(into, ".git"), { recursive: true, force: true });
598
636
  }
599
637
  function cachedRepo(home, url, rev) {
600
638
  const dir = repoDir(home, url, rev);
@@ -603,12 +641,12 @@ function cachedRepo(home, url, rev) {
603
641
  async function ensureRepo(home, url, rev) {
604
642
  const dir = repoDir(home, url, rev);
605
643
  if (existsSync2(dir)) return dir;
606
- const parent = path5.dirname(dir);
644
+ const parent = path6.dirname(dir);
607
645
  await withLock(
608
646
  parent,
609
647
  async () => {
610
648
  if (existsSync2(dir)) return;
611
- const temp = path5.join(parent, `.tmp-${randomUUID()}`);
649
+ const temp = path6.join(parent, `.tmp-${randomUUID()}`);
612
650
  try {
613
651
  await fetchCheckout(url, rev, temp);
614
652
  await rename(temp, dir);
@@ -651,18 +689,34 @@ function fixedRepo(dir, label) {
651
689
  }
652
690
 
653
691
  // src/core/pipeline.ts
654
- import { createHash as createHash3 } from "crypto";
692
+ import { createHash as createHash4 } from "crypto";
693
+ import path14 from "path";
694
+
695
+ // src/core/content.ts
696
+ var WORKTREE = { kind: "worktree" };
697
+ function contentReader(root, source) {
698
+ switch (source.kind) {
699
+ case "worktree":
700
+ return (file) => readSourceFile(root, file);
701
+ case "index":
702
+ return (file) => fileInIndex(root, file);
703
+ case "commit":
704
+ return (file) => fileAtCommit(root, source.ref, file);
705
+ }
706
+ }
655
707
 
656
708
  // src/core/baseline/changes.ts
657
709
  import { diffArrays } from "diff";
658
- function changedLines(before, after) {
659
- const ranges = [];
710
+ function diffLines(before, after) {
711
+ const changed = [];
712
+ const runs = [];
660
713
  const mark = (start, end) => {
661
- const previous = ranges.at(-1);
714
+ const previous = changed.at(-1);
662
715
  if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
663
- else ranges.push([start, end]);
716
+ else changed.push([start, end]);
664
717
  };
665
718
  let line = 1;
719
+ let baselineLine = 1;
666
720
  for (const part of diffArrays(Array.from(before), Array.from(after))) {
667
721
  const count = part.count ?? part.value.length;
668
722
  if (part.added) {
@@ -673,11 +727,17 @@ function changedLines(before, after) {
673
727
  const at = Math.min(line, after.length);
674
728
  mark(at, at);
675
729
  }
730
+ baselineLine += count;
676
731
  } else {
732
+ runs.push({ beforeStart: baselineLine, afterStart: line, count });
677
733
  line += count;
734
+ baselineLine += count;
678
735
  }
679
736
  }
680
- return ranges;
737
+ return { changed, map: { runs, afterLines: after.length } };
738
+ }
739
+ function identityMap(lines) {
740
+ return { runs: lines === 0 ? [] : [{ beforeStart: 1, afterStart: 1, count: lines }], afterLines: lines };
681
741
  }
682
742
 
683
743
  // src/core/baseline/hash.ts
@@ -715,22 +775,29 @@ function snapshotOf(text) {
715
775
  }
716
776
 
717
777
  // src/core/baseline/baseline.ts
718
- async function computeChanges(root, files, sources) {
719
- const changes = /* @__PURE__ */ new Map();
778
+ async function computeChanges(root, files, sources, read = contentReader(root, WORKTREE)) {
779
+ const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
720
780
  for (const file of files) {
721
781
  if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
722
782
  let before = sources.snapshots.get(file) ?? null;
783
+ let commitText = null;
723
784
  if (!before && sources.fallbackCommit) {
724
- const text = await fileAtCommit(root, sources.fallbackCommit, file);
725
- before = text === null ? null : snapshotOf(text);
785
+ commitText = await fileAtCommit(root, sources.fallbackCommit, file);
786
+ before = commitText === null ? null : snapshotOf(commitText);
726
787
  }
727
788
  if (!before) continue;
728
- const current = await readSourceFile(root, file);
789
+ const current = await read(file);
729
790
  if (current === null) continue;
791
+ if (commitText !== null) changes.fromCommit.set(file, commitText);
730
792
  const after = snapshotOf(current);
731
- changes.set(file, {
732
- changedLines: after.fileHash === before.fileHash ? [] : changedLines(before.lines, after.lines)
733
- });
793
+ if (after.fileHash === before.fileHash) {
794
+ changes.sets.set(file, { changedLines: [] });
795
+ changes.maps.set(file, identityMap(after.lines.length));
796
+ continue;
797
+ }
798
+ const diff = diffLines(before.lines, after.lines);
799
+ changes.sets.set(file, { changedLines: diff.changed });
800
+ changes.maps.set(file, diff.map);
734
801
  }
735
802
  return changes;
736
803
  }
@@ -740,17 +807,213 @@ function isNew(match, changes) {
740
807
  return change.changedLines.some(([start, end]) => match.line <= end && match.endLine >= start);
741
808
  }
742
809
 
810
+ // src/core/baseline/fingerprint.ts
811
+ function mapRange(map, [start, end]) {
812
+ let first = null;
813
+ let last = 0;
814
+ for (const run2 of map.runs) {
815
+ const from = Math.max(start, run2.beforeStart);
816
+ const to = Math.min(end, run2.beforeStart + run2.count - 1);
817
+ if (from > to) continue;
818
+ if (first === null) first = run2.afterStart + (from - run2.beforeStart);
819
+ last = run2.afterStart + (to - run2.beforeStart);
820
+ }
821
+ return first === null ? null : [first, last];
822
+ }
823
+ function classify(match, scope, rule, changes, fingerprints) {
824
+ const instance = () => isNew(match, changes.sets) ? "new" : "preexisting";
825
+ if (scope === "instance") return instance();
826
+ const recorded = fingerprints.get(match.file)?.get(rule);
827
+ const map = changes.maps.get(match.file);
828
+ if (recorded === void 0 || map === void 0) return instance();
829
+ for (const range of recorded) {
830
+ const mapped = mapRange(map, range);
831
+ if (mapped !== null && match.line <= mapped[1] && match.endLine >= mapped[0]) return "preexisting";
832
+ }
833
+ return "new";
834
+ }
835
+
836
+ // src/core/detection/budget.ts
837
+ function applyFileBudget(kind, selections, maxFiles, recent) {
838
+ const ofKind = selections.filter((selection) => selection.rule.detector?.kind === kind);
839
+ const files = [...new Set(ofKind.flatMap((selection) => selection.files))];
840
+ if (files.length <= maxFiles) return { selections, skipped: [] };
841
+ const rank = new Map(recent.map((file, index) => [file, index]));
842
+ const ordered = [...files].sort((a, b) => (rank.get(a) ?? recent.length) - (rank.get(b) ?? recent.length));
843
+ const keep = new Set(ordered.slice(0, maxFiles));
844
+ return {
845
+ selections: selections.map(
846
+ (selection) => selection.rule.detector?.kind === kind ? { ...selection, files: selection.files.filter((file) => keep.has(file)) } : selection
847
+ ).filter((selection) => selection.files.length > 0),
848
+ skipped: ordered.slice(maxFiles)
849
+ };
850
+ }
851
+ async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
852
+ const files = [...new Set(selections.filter((one) => bounded(one.rule.detector.kind)).flatMap((one) => one.files))];
853
+ if (files.length === 0) return { selections, skipped: [] };
854
+ const sizes = await Promise.all(files.map(async (file) => [file, await sizeOf(file)]));
855
+ const over = new Set(sizes.filter(([, size]) => size !== null && size > maxBytes).map(([file]) => file));
856
+ if (over.size === 0) return { selections, skipped: [] };
857
+ return {
858
+ selections: selections.map(
859
+ (selection) => bounded(selection.rule.detector.kind) ? { ...selection, files: selection.files.filter((file) => !over.has(file)) } : selection
860
+ ).filter((selection) => selection.files.length > 0),
861
+ skipped: files.filter((file) => over.has(file))
862
+ };
863
+ }
864
+
865
+ // src/core/suppress.ts
866
+ var ID = RULE_ID.source.slice(1, -1);
867
+ var IGNORE = new RegExp(
868
+ String.raw`rulecast-ignore:\s*(?<rule>${ID})(?:\s+(?<reason>\S.*?))?\s*(?:\*\/|-->)?\s*$`
869
+ );
870
+ function parseIgnore(line) {
871
+ const match = IGNORE.exec(line);
872
+ if (match?.groups === void 0) return null;
873
+ return { rule: match.groups.rule, reason: match.groups.reason ?? null };
874
+ }
875
+ async function suppress(findings, read) {
876
+ const result = { findings: [], ignored: [], warnings: [] };
877
+ if (findings.length === 0) return result;
878
+ const lines = /* @__PURE__ */ new Map();
879
+ const linesOf = async (file) => {
880
+ if (!lines.has(file)) lines.set(file, (await read(file))?.split("\n") ?? null);
881
+ return lines.get(file);
882
+ };
883
+ const warned = /* @__PURE__ */ new Set();
884
+ for (const finding of findings) {
885
+ const source = await linesOf(finding.match.file);
886
+ let ignore = null;
887
+ let at = 0;
888
+ for (const line of [finding.match.line, finding.match.line - 1]) {
889
+ const parsed = line >= 1 ? parseIgnore(source?.[line - 1] ?? "") : null;
890
+ if (parsed !== null && parsed.rule === finding.rule.id) {
891
+ ignore = parsed;
892
+ at = line;
893
+ break;
894
+ }
895
+ }
896
+ if (ignore === null) {
897
+ result.findings.push(finding);
898
+ continue;
899
+ }
900
+ if (ignore.reason === null) {
901
+ const where = `${finding.match.file}:${at}`;
902
+ if (!warned.has(where)) {
903
+ warned.add(where);
904
+ result.warnings.push(`${where}: rulecast-ignore needs a reason after the rule id; the finding was kept`);
905
+ }
906
+ result.findings.push(finding);
907
+ continue;
908
+ }
909
+ result.ignored.push({ ...finding, reason: ignore.reason });
910
+ }
911
+ return result;
912
+ }
913
+
914
+ // src/core/detection/run.ts
915
+ var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
916
+ async function runDetection(input) {
917
+ const output = { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
918
+ const byKind = /* @__PURE__ */ new Map();
919
+ for (const selection of input.selections) {
920
+ const kind = selection.rule.detector.kind;
921
+ const group = byKind.get(kind);
922
+ if (group === void 0) byKind.set(kind, [selection]);
923
+ else group.push(selection);
924
+ }
925
+ const controller = new AbortController();
926
+ const deadline = new Promise((resolve) => {
927
+ controller.signal.addEventListener("abort", () => resolve(TIMED_OUT), { once: true });
928
+ });
929
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs);
930
+ const deadlineAt = Date.now() + input.timeoutMs;
931
+ const late = () => Date.now() > deadlineAt;
932
+ const perKind = await Promise.all(
933
+ [...byKind].map(async ([kind, selections]) => {
934
+ const detector = input.detection.registry.get(kind);
935
+ if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
936
+ try {
937
+ const rules = await Promise.all(
938
+ selections.map(async ({ rule, files }) => ({
939
+ id: rule.id,
940
+ config: rule.detector.config,
941
+ files,
942
+ context: await input.detection.contextFor(rule)
943
+ }))
944
+ );
945
+ const result = await Promise.race([
946
+ detector.run({
947
+ event: input.event,
948
+ rules,
949
+ read: input.read,
950
+ fromDisk: input.fromDisk ?? true,
951
+ changes: input.changes,
952
+ cache: input.detection.cacheFor(kind),
953
+ settings: input.detection.settings,
954
+ cwd: input.detection.root,
955
+ signal: controller.signal,
956
+ deadlineAt
957
+ }),
958
+ deadline
959
+ ]);
960
+ if (result === TIMED_OUT || late()) return { status: "timedOut", kind, selections };
961
+ return { status: "ok", kind, selections, result };
962
+ } catch (error) {
963
+ if (error instanceof DeadlineError || controller.signal.aborted || late()) {
964
+ return { status: "timedOut", kind, selections };
965
+ }
966
+ return { status: "error", kind, selections, message: errorMessage(error) };
967
+ }
968
+ })
969
+ );
970
+ clearTimeout(timer);
971
+ for (const outcome of perKind) {
972
+ const ids = outcome.selections.map((selection) => selection.rule.id);
973
+ if (outcome.status === "timedOut") {
974
+ output.timedOut.push({ kind: outcome.kind, rules: ids });
975
+ continue;
976
+ }
977
+ if (outcome.status === "error") {
978
+ output.errors.push({ kind: outcome.kind, rules: ids, message: outcome.message });
979
+ continue;
980
+ }
981
+ const result = outcome.result;
982
+ const rulesById = new Map(outcome.selections.map((selection) => [selection.rule.id, selection.rule]));
983
+ const stray = result.findings.find((finding) => !rulesById.has(finding.rule));
984
+ const wholeRun = result.errors.find((error) => error.rule === null);
985
+ if (stray || wholeRun) {
986
+ const message = stray ? `detector reported unknown rule "${stray.rule}"` : wholeRun.message;
987
+ output.errors.push({ kind: outcome.kind, rules: ids, message });
988
+ continue;
989
+ }
990
+ const failed = /* @__PURE__ */ new Map();
991
+ for (const error of result.errors) failed.set(error.rule, error.message);
992
+ for (const finding of result.findings) {
993
+ const rule = rulesById.get(finding.rule);
994
+ const missing = rule.detector.captures.find((name) => typeof finding.match.captures[name] !== "string");
995
+ if (missing && !failed.has(rule.id)) failed.set(rule.id, `match is missing declared capture "${missing}"`);
996
+ }
997
+ for (const [rule, message] of failed) output.errors.push({ kind: outcome.kind, rules: [rule], message });
998
+ for (const finding of result.findings) {
999
+ if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
1000
+ }
1001
+ }
1002
+ const suppressed = await suppress(output.findings, input.read);
1003
+ return { ...output, ...suppressed };
1004
+ }
1005
+
743
1006
  // src/core/baseline/store.ts
744
- import path7 from "path";
1007
+ import path8 from "path";
745
1008
 
746
1009
  // src/core/jsonl.ts
747
1010
  import { mkdir as mkdir3, open, readFile as readFile3 } from "fs/promises";
748
- import path6 from "path";
1011
+ import path7 from "path";
749
1012
  var CorruptStoreError = class extends Error {
750
1013
  };
751
1014
  async function appendRecords(file, records) {
752
1015
  if (records.length === 0) return;
753
- await mkdir3(path6.dirname(file), { recursive: true });
1016
+ await mkdir3(path7.dirname(file), { recursive: true });
754
1017
  const handle = await open(file, "a+");
755
1018
  try {
756
1019
  await dropPartialRecord(handle);
@@ -802,7 +1065,7 @@ async function readRecords(file) {
802
1065
 
803
1066
  // src/core/baseline/store.ts
804
1067
  function storeFile(sessionDir2) {
805
- return path7.join(sessionDir2, "baseline.jsonl");
1068
+ return path8.join(sessionDir2, "baseline.jsonl");
806
1069
  }
807
1070
  function encodeLines(lines) {
808
1071
  return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
@@ -813,31 +1076,164 @@ function decodeLines(encoded) {
813
1076
  aligned.set(bytes);
814
1077
  return new Uint32Array(aligned.buffer);
815
1078
  }
816
- function startRecord(commit) {
817
- return { t: "start", commit };
1079
+ function startRecord(commit, configHash2 = null) {
1080
+ return { t: "start", commit, configHash: configHash2 };
818
1081
  }
819
1082
  function snapshotRecord(file, snapshot) {
820
1083
  return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
821
1084
  }
1085
+ function fingerprintRecord(file, rule, ranges) {
1086
+ return { t: "fingerprint", file, rule, ranges };
1087
+ }
822
1088
  async function appendBaseline(sessionDir2, records) {
823
1089
  await appendRecords(storeFile(sessionDir2), records);
824
1090
  }
1091
+ function mergeFingerprints(into, records) {
1092
+ for (const record of records) {
1093
+ if (record.t !== "fingerprint") continue;
1094
+ const byRule = into.get(record.file) ?? /* @__PURE__ */ new Map();
1095
+ into.set(record.file, byRule);
1096
+ if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
1097
+ }
1098
+ return into;
1099
+ }
825
1100
  async function readBaseline(sessionDir2) {
826
- const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map() };
827
- for (const record of await readRecords(storeFile(sessionDir2))) {
1101
+ const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
1102
+ const records = await readRecords(storeFile(sessionDir2));
1103
+ for (const record of records) {
828
1104
  if (record.t === "start" && !state.started) {
829
1105
  state.started = true;
830
1106
  state.startCommit = record.commit;
1107
+ state.startConfigHash = record.configHash ?? null;
831
1108
  } else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
832
1109
  state.snapshots.set(record.file, { fileHash: record.fileHash, lines: decodeLines(record.lines) });
833
1110
  }
834
1111
  }
1112
+ mergeFingerprints(state.fingerprints, records);
835
1113
  return state;
836
1114
  }
837
1115
 
1116
+ // src/core/baseline/record.ts
1117
+ async function recordFingerprints(input) {
1118
+ if (!input.rules.some((rule) => rule.scope === "container")) return [];
1119
+ const guards = (kind) => input.detection.registry.get(kind)?.guards === true;
1120
+ let selections = selectDetectorRules(input.rules, input.event, input.files, input.disabled).filter(
1121
+ (selection) => selection.rule.scope === "container" && input.detection.registry.get(selection.rule.detector.kind)?.metered !== true && (input.source === "disk" || guards(selection.rule.detector.kind))
1122
+ );
1123
+ if (input.ceiling) {
1124
+ const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
1125
+ selections = applied.selections;
1126
+ }
1127
+ if (selections.length === 0) return [];
1128
+ const output = await runDetection({
1129
+ detection: input.detection,
1130
+ event: input.event,
1131
+ selections,
1132
+ // No change set: the baseline content is being measured whole, and there is nothing it is a
1133
+ // change from. A detector that reads `changes` treats an absent file as entirely new, which is
1134
+ // exactly right here.
1135
+ changes: /* @__PURE__ */ new Map(),
1136
+ read: input.read,
1137
+ timeoutMs: input.timeoutMs
1138
+ });
1139
+ const unmeasured = /* @__PURE__ */ new Set();
1140
+ for (const error of output.errors) for (const rule of error.rules) unmeasured.add(rule);
1141
+ for (const timeout of output.timedOut) for (const rule of timeout.rules) unmeasured.add(rule);
1142
+ const key = (file, rule) => `${file}\0${rule}`;
1143
+ const ranges = /* @__PURE__ */ new Map();
1144
+ for (const { rule, match } of output.findings) {
1145
+ const found = ranges.get(key(match.file, rule.id));
1146
+ if (found === void 0) ranges.set(key(match.file, rule.id), [[match.line, match.endLine]]);
1147
+ else found.push([match.line, match.endLine]);
1148
+ }
1149
+ const records = [];
1150
+ for (const { rule, files } of selections) {
1151
+ if (unmeasured.has(rule.id)) continue;
1152
+ for (const file of files) records.push(fingerprintRecord(file, rule.id, ranges.get(key(file, rule.id)) ?? []));
1153
+ }
1154
+ return records;
1155
+ }
1156
+
1157
+ // src/core/baseline/stage.ts
1158
+ async function touchRecords(input, state, files) {
1159
+ const records = [];
1160
+ const snapshotted = [];
1161
+ for (const file of files) {
1162
+ if (state.snapshots.has(file)) continue;
1163
+ const text = await readSourceFile(input.root, file);
1164
+ if (text === null) continue;
1165
+ records.push(snapshotRecord(file, snapshotOf(text)));
1166
+ snapshotted.push(file);
1167
+ }
1168
+ if (snapshotted.length === 0) return records;
1169
+ records.push(
1170
+ ...await recordFingerprints({
1171
+ detection: input.detection,
1172
+ files: snapshotted,
1173
+ rules: input.rules,
1174
+ disabled: input.disabled,
1175
+ read: (file) => readSourceFile(input.root, file),
1176
+ event: "edit",
1177
+ source: "disk",
1178
+ timeoutMs: input.limits.editDeadlineMs,
1179
+ ceiling: { maxFileBytes: input.limits.maxFileBytes, sizeOf: (file) => fileBytes(input.root, file) }
1180
+ })
1181
+ );
1182
+ return records;
1183
+ }
1184
+ async function changesFor(input, event, state, files, read) {
1185
+ let snapshots = state?.snapshots ?? /* @__PURE__ */ new Map();
1186
+ let fallbackCommit = state?.startCommit ?? null;
1187
+ if (event.kind === "verify" && event.baseCommit) {
1188
+ fallbackCommit = event.baseCommit;
1189
+ snapshots = /* @__PURE__ */ new Map();
1190
+ }
1191
+ const changes = await computeChanges(input.root, files, { snapshots, fallbackCommit }, read);
1192
+ const kept = event.kind === "verify" && state !== null && !event.baseCommit ? files.filter((file) => changes.sets.get(file)?.changedLines.length !== 0) : [...files];
1193
+ const fingerprints = new Map(
1194
+ [...state?.fingerprints ?? []].map(([file, byRule]) => [file, new Map(byRule)])
1195
+ );
1196
+ if (event.kind !== "verify" || changes.fromCommit.size === 0) {
1197
+ return { files: kept, changes, fingerprints, records: [] };
1198
+ }
1199
+ const texts = changes.fromCommit;
1200
+ const missing = [...texts.keys()].filter((file) => !fingerprints.has(file));
1201
+ const records = missing.length === 0 ? [] : await recordFingerprints({
1202
+ detection: input.detection,
1203
+ files: missing,
1204
+ rules: input.rules,
1205
+ disabled: input.disabled,
1206
+ read: async (file) => texts.get(file) ?? null,
1207
+ event: "verify",
1208
+ source: "memory",
1209
+ timeoutMs: input.limits.verifyMs
1210
+ });
1211
+ return { files: kept, changes, fingerprints: mergeFingerprints(fingerprints, records), records };
1212
+ }
1213
+
838
1214
  // src/core/delivery/persist.ts
839
- import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
840
- import path8 from "path";
1215
+ import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
1216
+ import path9 from "path";
1217
+ var AGENT_DIR = ".rulecast";
1218
+ function agentDir(root) {
1219
+ const dir = path9.join(root, AGENT_DIR);
1220
+ mkdirSync2(dir, { recursive: true });
1221
+ const ignore = path9.join(dir, ".gitignore");
1222
+ if (!existsSync3(ignore)) writeFileSync2(ignore, "# Written by rulecast: files its hooks point an agent at.\n*\n");
1223
+ return dir;
1224
+ }
1225
+ function copyIntoProject(root, home, file) {
1226
+ const rest = path9.relative(home, file);
1227
+ if (rest.startsWith("..") || path9.isAbsolute(rest)) return null;
1228
+ try {
1229
+ const target = path9.join(agentDir(root), rest);
1230
+ mkdirSync2(path9.dirname(target), { recursive: true });
1231
+ copyFileSync(file, target);
1232
+ return path9.join(AGENT_DIR, rest).split(path9.sep).join("/");
1233
+ } catch {
1234
+ return null;
1235
+ }
1236
+ }
841
1237
  var KEEP = 3;
842
1238
  var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
843
1239
  var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -853,15 +1249,14 @@ function markdown(delivery, now) {
853
1249
  ""
854
1250
  ].join("\n");
855
1251
  }
856
- function writeOverflow(stateDir, session, delivery, now = /* @__PURE__ */ new Date()) {
1252
+ function writeOverflow(dir, session, delivery, now = /* @__PURE__ */ new Date()) {
857
1253
  const prefix = safeSegment2(session ?? "no-session");
858
- const dir = path8.join(stateDir, "deliveries");
859
- const file = path8.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
1254
+ const file = path9.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
860
1255
  try {
861
1256
  mkdirSync2(dir, { recursive: true });
862
1257
  writeFileSync2(file, markdown(delivery, now));
863
1258
  const mine = readdirSync(dir).filter((name) => name.startsWith(`${prefix}-`)).sort();
864
- for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path8.join(dir, old), { force: true });
1259
+ for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path9.join(dir, old), { force: true });
865
1260
  return file;
866
1261
  } catch {
867
1262
  return null;
@@ -879,16 +1274,16 @@ async function resolveRuleContext(resolver, context) {
879
1274
  }
880
1275
  function createReferenceResolver(root) {
881
1276
  const texts = /* @__PURE__ */ new Map();
882
- const read = (path11) => {
883
- let text = texts.get(path11);
1277
+ const read = (path15) => {
1278
+ let text = texts.get(path15);
884
1279
  if (!text) {
885
- text = readSourceFile(root, path11);
886
- texts.set(path11, text);
1280
+ text = readSourceFile(root, path15);
1281
+ texts.set(path15, text);
887
1282
  }
888
1283
  return text;
889
1284
  };
890
- const contentOf = async (path11, anchor) => {
891
- const text = await read(path11);
1285
+ const contentOf = async (path15, anchor) => {
1286
+ const text = await read(path15);
892
1287
  if (text === null) return null;
893
1288
  if (anchor === null) return text.trimEnd();
894
1289
  const section = sectionRange(text, anchor);
@@ -900,136 +1295,106 @@ function createReferenceResolver(root) {
900
1295
  if (content === null) return { spec, found: false };
901
1296
  return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
902
1297
  },
903
- async currentHash(path11, anchor) {
904
- const content = await contentOf(path11, anchor);
1298
+ async currentHash(path15, anchor) {
1299
+ const content = await contentOf(path15, anchor);
905
1300
  return content === null ? null : fnv1a(content);
906
1301
  },
907
- async contains(path11, parent, child) {
1302
+ async contains(path15, parent, child) {
908
1303
  if (parent === null) return true;
909
1304
  if (child === null) return false;
910
- const text = await read(path11);
1305
+ const text = await read(path15);
911
1306
  return text !== null && sectionContains(text, parent, child);
912
1307
  }
913
1308
  };
914
1309
  }
915
1310
 
916
- // src/core/detection/budget.ts
917
- function applyFileBudget(kind, selections, maxFiles, recent) {
918
- const ofKind = selections.filter((selection) => selection.rule.detector?.kind === kind);
919
- const files = [...new Set(ofKind.flatMap((selection) => selection.files))];
920
- if (files.length <= maxFiles) return { selections, skipped: [] };
921
- const rank = new Map(recent.map((file, index) => [file, index]));
922
- const ordered = [...files].sort((a, b) => (rank.get(a) ?? recent.length) - (rank.get(b) ?? recent.length));
923
- const keep = new Set(ordered.slice(0, maxFiles));
1311
+ // src/core/detection/context.ts
1312
+ function detectionFor(project, registry, stateDir, resolver) {
924
1313
  return {
925
- selections: selections.map(
926
- (selection) => selection.rule.detector?.kind === kind ? { ...selection, files: selection.files.filter((file) => keep.has(file)) } : selection
927
- ).filter((selection) => selection.files.length > 0),
928
- skipped: ordered.slice(maxFiles)
1314
+ root: project.root,
1315
+ registry,
1316
+ settings: { llm: project.config.llm },
1317
+ cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1318
+ contextFor: (rule) => resolveRuleContext(resolver, rule.context)
929
1319
  };
930
1320
  }
931
- async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
932
- const files = [...new Set(selections.filter((one) => bounded(one.rule.detector.kind)).flatMap((one) => one.files))];
933
- if (files.length === 0) return { selections, skipped: [] };
934
- const sizes = await Promise.all(files.map(async (file) => [file, await sizeOf(file)]));
935
- const over = new Set(sizes.filter(([, size]) => size !== null && size > maxBytes).map(([file]) => file));
936
- if (over.size === 0) return { selections, skipped: [] };
937
- return {
938
- selections: selections.map(
939
- (selection) => bounded(selection.rule.detector.kind) ? { ...selection, files: selection.files.filter((file) => !over.has(file)) } : selection
940
- ).filter((selection) => selection.files.length > 0),
941
- skipped: files.filter((file) => over.has(file))
942
- };
1321
+
1322
+ // src/core/detection/scratch.ts
1323
+ import { mkdir as mkdir4, mkdtemp, rm as rm3, writeFile } from "fs/promises";
1324
+ import path10 from "path";
1325
+ async function withScratchTree(root, prefix, entries, fn) {
1326
+ const dir = path10.basename(await mkdtemp(path10.join(root, prefix)));
1327
+ try {
1328
+ for (const [file, content] of entries) {
1329
+ const full = path10.join(root, dir, file);
1330
+ await mkdir4(path10.dirname(full), { recursive: true });
1331
+ await writeFile(full, content);
1332
+ }
1333
+ return await fn(dir);
1334
+ } finally {
1335
+ await rm3(path10.join(root, dir), { recursive: true, force: true });
1336
+ }
943
1337
  }
944
1338
 
945
- // src/core/detection/run.ts
946
- var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
947
- async function runDetection(input) {
948
- const output = { findings: [], errors: [], timedOut: [] };
949
- const byKind = /* @__PURE__ */ new Map();
1339
+ // src/core/detection/materialise.ts
1340
+ var STAGED_DIR = ".rulecast-staged-";
1341
+ async function runDetectionFrom(input, source) {
1342
+ if (source.kind === "worktree") return runDetection(input);
1343
+ const registry = input.detection.registry;
1344
+ const direct = [];
1345
+ const copied = [];
950
1346
  for (const selection of input.selections) {
951
- const kind = selection.rule.detector.kind;
952
- const group = byKind.get(kind);
953
- if (group === void 0) byKind.set(kind, [selection]);
954
- else group.push(selection);
1347
+ const detector = registry.get(selection.rule.detector.kind);
1348
+ const readsContent = detector?.guards === true || detector?.takesContent?.(selection.rule.detector.config) === true;
1349
+ if (readsContent || detector === void 0) direct.push(selection);
1350
+ else copied.push(selection);
955
1351
  }
956
- const controller = new AbortController();
957
- const deadline = new Promise((resolve) => {
958
- controller.signal.addEventListener("abort", () => resolve(TIMED_OUT), { once: true });
959
- });
960
- const timer = setTimeout(() => controller.abort(), input.timeoutMs);
961
- const deadlineAt = Date.now() + input.timeoutMs;
962
- const late = () => Date.now() > deadlineAt;
963
- const perKind = await Promise.all(
964
- [...byKind].map(async ([kind, selections]) => {
965
- const detector = input.registry.get(kind);
966
- if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
967
- try {
968
- const rules = await Promise.all(
969
- selections.map(async ({ rule, files }) => ({
970
- id: rule.id,
971
- config: rule.detector.config,
972
- files,
973
- context: await input.contextFor(rule)
974
- }))
975
- );
976
- const result = await Promise.race([
977
- detector.run({
978
- event: input.event,
979
- rules,
980
- read: input.read,
981
- changes: input.changes,
982
- cache: input.cacheFor(kind),
983
- settings: input.settings,
984
- cwd: input.root,
985
- signal: controller.signal,
986
- deadlineAt
987
- }),
988
- deadline
989
- ]);
990
- if (result === TIMED_OUT || late()) return { status: "timedOut", kind, selections };
991
- return { status: "ok", kind, selections, result };
992
- } catch (error) {
993
- if (error instanceof DeadlineError || controller.signal.aborted || late()) {
994
- return { status: "timedOut", kind, selections };
995
- }
996
- return { status: "error", kind, selections, message: errorMessage(error) };
997
- }
998
- })
999
- );
1000
- clearTimeout(timer);
1001
- for (const outcome of perKind) {
1002
- const ids = outcome.selections.map((selection) => selection.rule.id);
1003
- if (outcome.status === "timedOut") {
1004
- output.timedOut.push({ kind: outcome.kind, rules: ids });
1005
- continue;
1006
- }
1007
- if (outcome.status === "error") {
1008
- output.errors.push({ kind: outcome.kind, rules: ids, message: outcome.message });
1009
- continue;
1010
- }
1011
- const result = outcome.result;
1012
- const rulesById = new Map(outcome.selections.map((selection) => [selection.rule.id, selection.rule]));
1013
- const stray = result.findings.find((finding) => !rulesById.has(finding.rule));
1014
- const wholeRun = result.errors.find((error) => error.rule === null);
1015
- if (stray || wholeRun) {
1016
- const message = stray ? `detector reported unknown rule "${stray.rule}"` : wholeRun.message;
1017
- output.errors.push({ kind: outcome.kind, rules: ids, message });
1018
- continue;
1019
- }
1020
- const failed = /* @__PURE__ */ new Map();
1021
- for (const error of result.errors) failed.set(error.rule, error.message);
1022
- for (const finding of result.findings) {
1023
- const rule = rulesById.get(finding.rule);
1024
- const missing = rule.detector.captures.find((name) => typeof finding.match.captures[name] !== "string");
1025
- if (missing && !failed.has(rule.id)) failed.set(rule.id, `match is missing declared capture "${missing}"`);
1026
- }
1027
- for (const [rule, message] of failed) output.errors.push({ kind: outcome.kind, rules: [rule], message });
1028
- for (const finding of result.findings) {
1029
- if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
1030
- }
1352
+ const outputs = await Promise.all([
1353
+ direct.length === 0 ? null : runDetection({ ...input, selections: direct, fromDisk: false }),
1354
+ copied.length === 0 ? null : fromScratch(input, copied)
1355
+ ]);
1356
+ return merge(outputs.filter((output) => output !== null));
1357
+ }
1358
+ async function fromScratch(input, selections) {
1359
+ const entries = /* @__PURE__ */ new Map();
1360
+ for (const file of new Set(selections.flatMap((selection) => selection.files))) {
1361
+ const text = await input.read(file);
1362
+ if (text !== null) entries.set(file, text);
1031
1363
  }
1032
- return output;
1364
+ if (entries.size === 0) return { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
1365
+ return withScratchTree(input.detection.root, STAGED_DIR, entries, async (dir) => {
1366
+ const prefix = `${dir}/`;
1367
+ const strip = (file) => file.startsWith(prefix) ? file.slice(prefix.length) : file;
1368
+ const output = await runDetection({
1369
+ ...input,
1370
+ selections: selections.map((selection) => ({
1371
+ ...selection,
1372
+ files: selection.files.filter((file) => entries.has(file)).map((file) => prefix + file)
1373
+ })).filter((selection) => selection.files.length > 0),
1374
+ changes: new Map([...input.changes].map(([file, change]) => [prefix + file, change])),
1375
+ // Anything reading through `read` under a scratch path is asking about the real file.
1376
+ read: (file) => input.read(strip(file))
1377
+ });
1378
+ return {
1379
+ findings: output.findings.map(({ rule, match }) => ({ rule, match: { ...match, file: strip(match.file) } })),
1380
+ errors: output.errors.map((error) => ({ ...error, message: error.message.replaceAll(prefix, "") })),
1381
+ timedOut: output.timedOut,
1382
+ ignored: output.ignored.map((ignored) => ({
1383
+ ...ignored,
1384
+ match: { ...ignored.match, file: strip(ignored.match.file) }
1385
+ })),
1386
+ warnings: output.warnings.map((warning) => warning.replaceAll(prefix, ""))
1387
+ };
1388
+ });
1389
+ }
1390
+ function merge(outputs) {
1391
+ return {
1392
+ findings: outputs.flatMap((output) => output.findings),
1393
+ errors: outputs.flatMap((output) => output.errors),
1394
+ timedOut: outputs.flatMap((output) => output.timedOut),
1395
+ ignored: outputs.flatMap((output) => output.ignored),
1396
+ warnings: outputs.flatMap((output) => output.warnings)
1397
+ };
1033
1398
  }
1034
1399
 
1035
1400
  // src/core/detection/proposal.ts
@@ -1057,8 +1422,8 @@ function propose(current, intent) {
1057
1422
  }
1058
1423
  return { content: content + rest, inserts };
1059
1424
  }
1060
- function isInserted(proposal, match, detector) {
1061
- if (detector === "path") return true;
1425
+ function isInserted(proposal, match, wholeFile) {
1426
+ if (wholeFile) return true;
1062
1427
  const starts = lineStarts(proposal.content);
1063
1428
  const lineStart = starts[match.line - 1];
1064
1429
  if (lineStart === void 0) return false;
@@ -1068,7 +1433,7 @@ function isInserted(proposal, match, detector) {
1068
1433
  }
1069
1434
 
1070
1435
  // src/core/session/decide.ts
1071
- import path9 from "path";
1436
+ import path11 from "path";
1072
1437
 
1073
1438
  // src/core/session/state.ts
1074
1439
  function preexistingKey(rule, file) {
@@ -1076,7 +1441,14 @@ function preexistingKey(rule, file) {
1076
1441
  }
1077
1442
  var refusalKey = preexistingKey;
1078
1443
  function emptyWork() {
1079
- return { edited: [], stopBlocks: /* @__PURE__ */ new Map(), refusals: /* @__PURE__ */ new Map(), disabled: /* @__PURE__ */ new Map(), accessed: /* @__PURE__ */ new Map() };
1444
+ return {
1445
+ edited: [],
1446
+ stopBlocks: /* @__PURE__ */ new Map(),
1447
+ refusals: /* @__PURE__ */ new Map(),
1448
+ disabled: /* @__PURE__ */ new Map(),
1449
+ accessed: /* @__PURE__ */ new Map(),
1450
+ noticed: /* @__PURE__ */ new Set()
1451
+ };
1080
1452
  }
1081
1453
  function emptyContext() {
1082
1454
  return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
@@ -1107,6 +1479,9 @@ function foldWork(records) {
1107
1479
  case "disabled":
1108
1480
  if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
1109
1481
  break;
1482
+ case "noticed":
1483
+ state.noticed.add(record.key);
1484
+ break;
1110
1485
  }
1111
1486
  }
1112
1487
  return state;
@@ -1136,14 +1511,10 @@ function foldContext(records) {
1136
1511
  }
1137
1512
 
1138
1513
  // src/core/session/decide.ts
1139
- var ITEM_OVERHEAD = 64;
1140
- var HEADER_OVERHEAD = 128;
1141
- var OVERFLOW_NOTICE = 256;
1142
- var REFERENCE_OVERHEAD = 96;
1143
1514
  var WARNING_SHARE = 0.15;
1144
1515
  var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
1145
1516
  function locationOf(spec) {
1146
- return path9.isAbsolute(spec.path) ? { location: spec.path } : {};
1517
+ return path11.isAbsolute(spec.path) ? { location: spec.path } : {};
1147
1518
  }
1148
1519
  function capturesFor(rule, match) {
1149
1520
  const names = templateBindings(rule.message ?? "");
@@ -1206,37 +1577,38 @@ async function isCovered(spec, delivered, resolver) {
1206
1577
  }
1207
1578
  return false;
1208
1579
  }
1209
- async function decide(input) {
1580
+ async function assemble(input) {
1210
1581
  const delivery = emptyDelivery();
1211
- const work = [];
1212
1582
  const context = [];
1583
+ const touches = input.touches ?? [];
1584
+ const state = input.context ?? emptyContext();
1213
1585
  const fresh = input.findings.filter((finding) => finding.status === "new");
1214
1586
  delivery.findings = renderFindings(fresh);
1215
- const rulesById = new Map(fresh.map(({ rule }) => [rule.id, rule]));
1216
1587
  for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
1217
1588
  const summaries = /* @__PURE__ */ new Map();
1218
1589
  for (const { rule, match, status } of input.findings) {
1219
1590
  if (status !== "preexisting") continue;
1220
1591
  const key = preexistingKey(rule.id, match.file);
1221
- if (input.context.preexisting.has(key)) continue;
1592
+ if (state.preexisting.has(key)) continue;
1222
1593
  const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
1223
1594
  summary.count++;
1224
1595
  summaries.set(key, summary);
1225
1596
  }
1226
1597
  delivery.preexistingSummary = [...summaries.values()];
1227
- const unwarned = input.warnings.filter((warning) => !input.context.warned.has(warning.key));
1598
+ const unwarned = (input.warnings ?? []).filter((warning) => !state.warned.has(warning.key));
1228
1599
  delivery.warnings = unwarned.map((warning) => warning.text);
1229
- delivery.touches = input.touches.map((rule) => rule.id);
1230
- for (const rule of input.touches) context.push({ t: "touched", rule: rule.id });
1231
- const delivered = [...input.context.delivered];
1232
- if (input.agentRead !== null) {
1233
- const hash = await input.resolver.currentHash(input.agentRead, null);
1600
+ delivery.touches = touches.map((rule) => rule.id);
1601
+ for (const rule of touches) context.push({ t: "touched", rule: rule.id });
1602
+ const delivered = [...state.delivered];
1603
+ const agentRead = input.agentRead ?? null;
1604
+ if (agentRead !== null) {
1605
+ const hash = await input.resolver.currentHash(agentRead, null);
1234
1606
  if (hash !== null) {
1235
- delivered.push({ path: input.agentRead, anchor: null, hash });
1236
- context.push({ t: "delivered", path: input.agentRead, anchor: null, hash });
1607
+ delivered.push({ path: agentRead, anchor: null, hash });
1608
+ context.push({ t: "delivered", path: agentRead, anchor: null, hash });
1237
1609
  }
1238
1610
  }
1239
- const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...input.touches];
1611
+ const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...touches];
1240
1612
  const candidates = [];
1241
1613
  for (const spec of referencesInOrder(ruleOrder)) {
1242
1614
  const resolved = await input.resolver.resolve(spec);
@@ -1253,26 +1625,38 @@ async function decide(input) {
1253
1625
  delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
1254
1626
  }
1255
1627
  }
1256
- const limit = input.maxContextChars;
1257
- const render = { maxMatchesPerRule: input.maxMatchesPerRule };
1258
- const complete = {
1259
- ...delivery,
1260
- findings: [...delivery.findings],
1261
- preexistingSummary: [...delivery.preexistingSummary],
1262
- references: [...delivery.references],
1263
- warnings: [...delivery.warnings],
1628
+ return { delivery, candidates, fresh, unwarned, touches, context };
1629
+ }
1630
+ function trim(assembled, limits) {
1631
+ const { candidates, unwarned, touches } = assembled;
1632
+ const delivery = {
1633
+ ...assembled.delivery,
1634
+ findings: [...assembled.delivery.findings],
1635
+ preexistingSummary: [...assembled.delivery.preexistingSummary],
1636
+ references: [...assembled.delivery.references],
1637
+ warnings: [...assembled.delivery.warnings],
1264
1638
  omitted: { findings: [], rules: 0, preexisting: 0 }
1265
1639
  };
1640
+ const context = [];
1641
+ const rulesById = new Map(assembled.fresh.map(({ rule }) => [rule.id, rule]));
1642
+ const limit = limits.maxContextChars;
1643
+ const render = { maxMatchesPerRule: limits.maxMatchesPerRule };
1266
1644
  const byRule = /* @__PURE__ */ new Map();
1267
1645
  for (const finding of delivery.findings) {
1268
1646
  const group = byRule.get(finding.rule);
1269
1647
  if (group === void 0) byRule.set(finding.rule, [finding]);
1270
1648
  else group.push(finding);
1271
1649
  }
1272
- let used = HEADER_OVERHEAD;
1650
+ const conventions = delivery.references.length > 0 || delivery.preexistingSummary.length > 0;
1651
+ let used = deliveryCost.header(byRule.size, new Set(delivery.findings.map((finding) => finding.file)), conventions) + (delivery.references.length > 0 ? deliveryCost.referencesEnd : 0);
1273
1652
  const fits = (size) => limit === null || used + size <= limit;
1274
1653
  const kept = /* @__PURE__ */ new Map();
1275
- const lineCost = (ref) => ref.length + REFERENCE_OVERHEAD;
1654
+ const specs = /* @__PURE__ */ new Map();
1655
+ for (const rule of [...rulesById.values(), ...touches]) for (const spec of rule.context) specs.set(spec.ref, spec);
1656
+ const lineCost = (ref) => {
1657
+ const spec = specs.get(ref);
1658
+ return deliveryCost.referenceLine(ref, spec === void 0 ? void 0 : locationOf(spec).location);
1659
+ };
1276
1660
  const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
1277
1661
  const charged = /* @__PURE__ */ new Set();
1278
1662
  const orphaned = /* @__PURE__ */ new Set();
@@ -1284,7 +1668,7 @@ async function decide(input) {
1284
1668
  for (const [id, findings] of byRule) {
1285
1669
  const rule = rulesById.get(id);
1286
1670
  const refs = rule === void 0 ? [] : uncharged(rule);
1287
- const size = measureRuleBlock(id, [findings[0]], delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
1671
+ const size = measureRuleBlock(id, [findings[0]], findings, delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
1288
1672
  if (used + spent.chars + size > cap) {
1289
1673
  spent.dropped++;
1290
1674
  continue;
@@ -1293,7 +1677,7 @@ async function decide(input) {
1293
1677
  for (const ref of refs) spent.refs.add(ref);
1294
1678
  spent.kept.set(id, [findings[0]]);
1295
1679
  }
1296
- for (const touch of input.touches) {
1680
+ for (const touch of touches) {
1297
1681
  for (const ref of uncharged(touch)) {
1298
1682
  spent.chars += lineCost(ref);
1299
1683
  spent.refs.add(ref);
@@ -1303,19 +1687,20 @@ async function decide(input) {
1303
1687
  };
1304
1688
  if (limit !== null) {
1305
1689
  let floor = floorWithin(limit);
1306
- if (floor.dropped > 0) floor = floorWithin(limit - OVERFLOW_NOTICE);
1307
- used += floor.chars + (floor.dropped > 0 ? OVERFLOW_NOTICE : 0);
1690
+ const notice = deliveryCost.overflowNotice(byRule.size);
1691
+ if (floor.dropped > 0) floor = floorWithin(limit - notice);
1692
+ used += floor.chars + (floor.dropped > 0 ? notice : 0);
1308
1693
  delivery.omitted.rules = floor.dropped;
1309
1694
  for (const [id, findings] of floor.kept) kept.set(id, findings);
1310
1695
  for (const ref of floor.refs) charged.add(ref);
1311
1696
  for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
1312
1697
  }
1313
- const warningCost = (text) => text.length + ITEM_OVERHEAD;
1698
+ const warningCost = (text) => deliveryCost.warning(text);
1314
1699
  if (limit === null) {
1315
1700
  for (const warning of unwarned) context.push({ t: "warned", key: warning.key });
1316
1701
  } else {
1317
1702
  const ceiling = Math.max(0, Math.min(Math.floor(limit * WARNING_SHARE), limit - used));
1318
- let spent = 0;
1703
+ let spent = unwarned.length > 0 ? deliveryCost.warningsFrame : 0;
1319
1704
  let held = 0;
1320
1705
  while (held < unwarned.length && spent + warningCost(unwarned[held].text) <= ceiling) {
1321
1706
  spent += warningCost(unwarned[held].text);
@@ -1334,10 +1719,10 @@ async function decide(input) {
1334
1719
  spent += warningCost(moreWarnings(cut));
1335
1720
  delivery.warnings.push(moreWarnings(cut));
1336
1721
  }
1337
- used += spent;
1722
+ used += delivery.warnings.length > 0 ? spent : 0;
1338
1723
  }
1339
1724
  for (const { index, resolved } of candidates) {
1340
- const size = resolved.content.length;
1725
+ const size = deliveryCost.referenceContent(resolved.spec.ref, resolved.content, locationOf(resolved.spec).location);
1341
1726
  if (orphaned.has(resolved.spec.ref)) continue;
1342
1727
  if (!fits(size)) {
1343
1728
  delivery.references[index] = {
@@ -1351,13 +1736,16 @@ async function decide(input) {
1351
1736
  used += size;
1352
1737
  context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
1353
1738
  }
1739
+ const frame = delivery.preexistingSummary.length > 0 ? deliveryCost.backlogFrame(delivery.preexistingSummary.length) : 0;
1740
+ if (frame > 0 && !fits(frame)) delivery.preexistingSummary = [];
1741
+ else used += frame;
1354
1742
  const summariesKept = [];
1355
1743
  for (const summary of delivery.preexistingSummary) {
1356
- if (!fits(summary.rule.length + summary.file.length + ITEM_OVERHEAD)) {
1744
+ if (!fits(deliveryCost.backlogSummary(summary))) {
1357
1745
  delivery.omitted.preexisting++;
1358
1746
  continue;
1359
1747
  }
1360
- used += summary.rule.length + summary.file.length + ITEM_OVERHEAD;
1748
+ used += deliveryCost.backlogSummary(summary);
1361
1749
  summariesKept.push(summary);
1362
1750
  context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
1363
1751
  }
@@ -1368,9 +1756,9 @@ async function decide(input) {
1368
1756
  added = false;
1369
1757
  for (const [rule, findings] of byRule) {
1370
1758
  const shown = kept.get(rule);
1371
- if (shown === void 0 || shown.length >= Math.min(findings.length, input.maxMatchesPerRule)) continue;
1759
+ if (shown === void 0 || shown.length >= Math.min(findings.length, limits.maxMatchesPerRule)) continue;
1372
1760
  const template = delivery.templates[rule];
1373
- const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
1761
+ const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], findings, template, render) - measureRuleBlock(rule, shown, findings, template, render);
1374
1762
  if (!fits(delta)) continue;
1375
1763
  used += delta;
1376
1764
  shown.push(findings[shown.length]);
@@ -1390,20 +1778,26 @@ async function decide(input) {
1390
1778
  }
1391
1779
  delivery.findings = delivery.findings.filter((finding) => kept.get(finding.rule)?.includes(finding) === true);
1392
1780
  }
1393
- if (input.stopGate) {
1394
- const newErrors = fresh.some(({ rule }) => rule.severity === "error");
1395
- const blocks = input.work.stopBlocks.get(input.agent) ?? 0;
1396
- if (!newErrors) delivery.stop = "allow";
1397
- else if (blocks < input.maxBlocks) {
1398
- delivery.stop = "block";
1399
- work.push({ t: "stopBlock", agent: input.agent });
1400
- } else delivery.stop = "capReached";
1401
- if (delivery.stop === "allow") return { delivery, overflow: null, work, context: [] };
1402
- if (delivery.stop === "capReached") {
1403
- return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context: [] };
1404
- }
1405
- }
1406
- return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context };
1781
+ return { delivery, overflow: delivery.omitted.rules > 0 ? assembled.delivery : null, context };
1782
+ }
1783
+ function gate(fresh, work, agent, maxBlocks) {
1784
+ if (!fresh.some(({ rule }) => rule.severity === "error")) return { stop: "allow", work: [] };
1785
+ if ((work.stopBlocks.get(agent) ?? 0) < maxBlocks) return { stop: "block", work: [{ t: "stopBlock", agent }] };
1786
+ return { stop: "capReached", work: [] };
1787
+ }
1788
+ async function decide(input) {
1789
+ const assembled = await assemble(input);
1790
+ const trimmed = trim(assembled, {
1791
+ maxContextChars: input.maxContextChars,
1792
+ maxMatchesPerRule: input.maxMatchesPerRule
1793
+ });
1794
+ const context = [...assembled.context, ...trimmed.context];
1795
+ if (!input.stopGate) return { delivery: trimmed.delivery, overflow: trimmed.overflow, work: [], context };
1796
+ const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks);
1797
+ const delivery = { ...trimmed.delivery, stop: gated.stop };
1798
+ if (gated.stop === "allow") return { delivery, overflow: null, work: gated.work, context: [] };
1799
+ if (gated.stop === "capReached") return { delivery, overflow: trimmed.overflow, work: gated.work, context: [] };
1800
+ return { delivery, overflow: trimmed.overflow, work: gated.work, context };
1407
1801
  }
1408
1802
 
1409
1803
  // src/core/guard.ts
@@ -1413,7 +1807,7 @@ async function guardWrite(input) {
1413
1807
  if (file === void 0 || intent === void 0) return emptyDelivery();
1414
1808
  const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
1415
1809
  if (rules.length === 0) return emptyDelivery();
1416
- const proposal = propose(await readSourceFile(input.root, file), intent);
1810
+ const proposal = propose(await readSourceFile(input.detection.root, file), intent);
1417
1811
  if (proposal === null) {
1418
1812
  input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
1419
1813
  return emptyDelivery();
@@ -1426,19 +1820,18 @@ async function guardWrite(input) {
1426
1820
  const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
1427
1821
  if (selections.length === 0) return emptyDelivery();
1428
1822
  const output = await runDetection({
1429
- root: input.root,
1823
+ detection: input.detection,
1430
1824
  event: "edit",
1431
1825
  selections,
1432
1826
  changes: /* @__PURE__ */ new Map(),
1433
1827
  // The proposed file for the one being written; anything else a detector reaches for is real.
1434
- read: async (name) => name === file ? proposal.content : readSourceFile(input.root, name),
1435
- registry: input.registry,
1436
- cacheFor: (kind) => diskCache(detectorCacheDir(input.stateDir, kind)),
1437
- contextFor: (rule) => resolveRuleContext(input.resolver, rule.context),
1438
- settings: { llm: input.config.llm },
1828
+ read: async (name) => name === file ? proposal.content : readSourceFile(input.detection.root, name),
1439
1829
  timeoutMs: input.config.timeouts.editDeadlineMs
1440
1830
  });
1441
- const evidence = output.findings.filter(({ rule, match }) => isInserted(proposal, match, rule.detector.kind));
1831
+ const wholeFile = (kind) => input.detection.registry.get(kind)?.wholeFile === true;
1832
+ const evidence = output.findings.filter(
1833
+ ({ rule, match }) => isInserted(proposal, match, wholeFile(rule.detector.kind))
1834
+ );
1442
1835
  if (evidence.length === 0) return emptyDelivery();
1443
1836
  const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
1444
1837
  const refusing = evidence.filter(({ rule }) => !spent(rule.id));
@@ -1446,41 +1839,67 @@ async function guardWrite(input) {
1446
1839
  input.log(`guard: ${file} has spent its refusals; allowing the write`);
1447
1840
  return emptyDelivery();
1448
1841
  }
1449
- const { delivery } = await decide({
1450
- agent: "main",
1842
+ const assembled = await assemble({
1451
1843
  findings: refusing.map(({ rule, match }) => ({ rule, match, status: "new" })),
1452
- touches: [],
1453
- agentRead: null,
1454
- warnings: [],
1455
- work: input.work,
1456
- // An empty context, so the sections come with the refusal even if they were delivered earlier:
1457
- // the message has to stand on its own, and nothing here is recorded as delivered because the
1458
- // write it explains never happened.
1459
- context: emptyContext(),
1460
1844
  resolver: input.resolver,
1461
- maxBytes: input.config.context.maxBytes,
1462
- maxBlocks: input.config.stopGate.maxBlocks,
1845
+ maxBytes: input.config.context.maxBytes
1846
+ });
1847
+ const { delivery, context } = trim(assembled, {
1463
1848
  maxContextChars: input.maxContextChars,
1464
- maxMatchesPerRule: input.config.maxMatchesPerRule,
1465
- stopGate: false
1849
+ maxMatchesPerRule: input.config.maxMatchesPerRule
1466
1850
  });
1851
+ await input.recordContext(context.filter((record) => record.t === "delivered"));
1467
1852
  await input.record(
1468
1853
  [...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
1469
1854
  );
1470
1855
  return delivery;
1471
1856
  }
1472
1857
 
1858
+ // src/core/session/oversight.ts
1859
+ import { createHash as createHash3 } from "crypto";
1860
+ import { readFile as readFile4 } from "fs/promises";
1861
+ import path12 from "path";
1862
+ function notices(input) {
1863
+ const out = [];
1864
+ const configKey = `config:${input.currentConfigHash ?? "gone"}`;
1865
+ if (input.startConfigHash !== null && input.startConfigHash !== input.currentConfigHash && !input.told.has(configKey)) {
1866
+ out.push({
1867
+ key: configKey,
1868
+ text: `rulecast: ${CONFIG_FILE} changed during this session; findings may have been silenced. Review: git diff ${CONFIG_FILE}`
1869
+ });
1870
+ }
1871
+ const keyOf = ({ rule, match }) => `ignore:${rule}:${match.file}:${match.line}`;
1872
+ const added = input.ignored.filter((ignored) => ignored.status === "new" && !input.told.has(keyOf(ignored)));
1873
+ if (added.length > 0) {
1874
+ const sites = added.map(({ rule, match, reason }) => `${match.file}:${match.line} (${rule}: "${reason}")`);
1875
+ const comments = added.length === 1 ? "a rulecast-ignore comment" : `${added.length} rulecast-ignore comments`;
1876
+ out.push({
1877
+ key: added.map(keyOf).join("\n"),
1878
+ text: `rulecast: the agent added ${comments} this session: ${sites.join(", ")}`
1879
+ });
1880
+ }
1881
+ return out;
1882
+ }
1883
+ async function configHash(root) {
1884
+ try {
1885
+ return createHash3("sha256").update(await readFile4(path12.join(root, CONFIG_FILE))).digest("hex");
1886
+ } catch (error) {
1887
+ if (isNotFound(error)) return null;
1888
+ throw error;
1889
+ }
1890
+ }
1891
+
1473
1892
  // src/core/session/session.ts
1474
- import path10 from "path";
1893
+ import path13 from "path";
1475
1894
  function safeSegment3(value) {
1476
1895
  const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
1477
1896
  return /^\.*$/.test(segment) ? `_${segment}` : segment;
1478
1897
  }
1479
1898
  function sessionDir(stateDir, sessionId) {
1480
- return path10.join(stateDir, "sessions", safeSegment3(sessionId));
1899
+ return path13.join(stateDir, "sessions", safeSegment3(sessionId));
1481
1900
  }
1482
- var workFile = (dir) => path10.join(dir, "work.jsonl");
1483
- var contextFile = (dir, agent) => path10.join(dir, `context.${safeSegment3(agent)}.jsonl`);
1901
+ var workFile = (dir) => path13.join(dir, "work.jsonl");
1902
+ var contextFile = (dir, agent) => path13.join(dir, `context.${safeSegment3(agent)}.jsonl`);
1484
1903
  async function appendWork(dir, records) {
1485
1904
  await appendRecords(workFile(dir), records);
1486
1905
  }
@@ -1507,7 +1926,7 @@ async function commitSession(dir, agent, decideWith) {
1507
1926
  var SUMMARY_THRESHOLD = 3;
1508
1927
  var DEFAULT_HINT = "rulecast validate";
1509
1928
  function summaryKey(prefix, ids) {
1510
- const digest = createHash3("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
1929
+ const digest = createHash4("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
1511
1930
  return `${prefix}:${digest}`;
1512
1931
  }
1513
1932
  function compileWarnings(errors) {
@@ -1530,8 +1949,9 @@ First: ${diagnosticText(errors[0])}`
1530
1949
  }
1531
1950
  ];
1532
1951
  }
1533
- function detectorWarnings(errors) {
1534
- const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. Disabled for this session.`;
1952
+ function detectorWarnings(errors, scope) {
1953
+ const outcome = scope === "session" ? "Disabled for this session." : "Skipped for this run.";
1954
+ const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. ${outcome}`;
1535
1955
  const byKind = /* @__PURE__ */ new Map();
1536
1956
  for (const error of errors) {
1537
1957
  const group = byKind.get(error.kind);
@@ -1552,7 +1972,7 @@ function detectorWarnings(errors) {
1552
1972
  const ids = group.flatMap((error) => error.rules);
1553
1973
  summarised.push({
1554
1974
  key: summaryKey(`detector:${kind}`, ids),
1555
- text: `${ids.length} ${kind} rules were disabled this session \u2014 see debug.log.
1975
+ text: `${ids.length} ${kind} rules ${scope === "session" ? "were disabled this session" : "were skipped in this run"} \u2014 see debug.log.
1556
1976
  First: ${text(group[0])}`
1557
1977
  });
1558
1978
  }
@@ -1589,12 +2009,17 @@ async function runPipeline(options) {
1589
2009
  if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
1590
2010
  if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
1591
2011
  }
1592
- let baseline = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map() };
2012
+ let baseline = {
2013
+ started: false,
2014
+ startCommit: null,
2015
+ snapshots: /* @__PURE__ */ new Map(),
2016
+ fingerprints: /* @__PURE__ */ new Map()
2017
+ };
1593
2018
  if (session && event.kind !== "reset" && event.kind !== "guard") {
1594
2019
  const dir = session.dir;
1595
2020
  baseline = await readingStore(() => readBaseline(dir), baseline);
1596
2021
  if (session && !baseline.started) {
1597
- await appendBaseline(dir, [startRecord(await headCommit(root))]);
2022
+ await appendBaseline(dir, [startRecord(await headCommit(root), await configHash(root))]);
1598
2023
  baseline = await readingStore(() => readBaseline(dir), baseline);
1599
2024
  }
1600
2025
  }
@@ -1602,15 +2027,14 @@ async function runPipeline(options) {
1602
2027
  const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
1603
2028
  const disabled = new Set(view.work.disabled.keys());
1604
2029
  const resolver = createReferenceResolver(root);
2030
+ const detection = detectionFor(project, registry, stateDir, resolver);
1605
2031
  const workRecords = [];
1606
2032
  const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
1607
2033
  if (event.kind === "guard") {
1608
2034
  const open2 = session;
1609
2035
  const delivery = await guardWrite({
1610
- root,
2036
+ detection,
1611
2037
  config,
1612
- registry,
1613
- stateDir,
1614
2038
  event,
1615
2039
  rules: project.rules,
1616
2040
  disabled,
@@ -1619,12 +2043,16 @@ async function runPipeline(options) {
1619
2043
  maxContextChars: options.maxContextChars,
1620
2044
  log,
1621
2045
  record: open2 ? (records) => appendWork(open2.dir, records) : async () => {
2046
+ },
2047
+ recordContext: open2 ? (records) => appendContext(open2.dir, open2.agent, records) : async () => {
1622
2048
  }
1623
2049
  });
1624
2050
  return { delivery, failed, deadlineMissed };
1625
2051
  }
1626
2052
  let touches = [];
1627
2053
  let findings = [];
2054
+ let ignored = [];
2055
+ let told = [];
1628
2056
  let agentRead = null;
1629
2057
  if (event.kind === "touch" || event.kind === "edit") {
1630
2058
  touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
@@ -1640,48 +2068,55 @@ async function runPipeline(options) {
1640
2068
  const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
1641
2069
  touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
1642
2070
  }
2071
+ const baselineInput = {
2072
+ root,
2073
+ detection,
2074
+ rules: project.rules,
2075
+ disabled,
2076
+ limits: {
2077
+ editDeadlineMs: config.timeouts.editDeadlineMs,
2078
+ verifyMs: config.timeouts.verifyMs,
2079
+ maxFileBytes: config.maxFileBytes
2080
+ }
2081
+ };
1643
2082
  if (event.kind === "touch") {
1644
2083
  if (event.completeRead) agentRead = event.files[0] ?? null;
1645
- if (session) {
1646
- const records = [];
1647
- for (const file of relevant(event.files)) {
1648
- if (baseline.snapshots.has(file)) continue;
1649
- const text = await readSourceFile(root, file);
1650
- if (text !== null) records.push(snapshotRecord(file, snapshotOf(text)));
1651
- }
1652
- await appendBaseline(session.dir, records);
1653
- }
2084
+ if (session) await appendBaseline(session.dir, await touchRecords(baselineInput, baseline, relevant(event.files)));
1654
2085
  }
1655
2086
  if (event.kind === "edit" || event.kind === "verify") {
1656
- let files = relevant(event.files);
1657
- let snapshots = session ? baseline.snapshots : /* @__PURE__ */ new Map();
1658
- let fallbackCommit = session ? baseline.startCommit : null;
2087
+ let requested = relevant(event.files);
1659
2088
  if (event.kind === "edit" && session) {
1660
2089
  await appendWork(
1661
2090
  session.dir,
1662
- files.map((file) => ({ t: "edited", file }))
2091
+ requested.map((file) => ({ t: "edited", file }))
1663
2092
  );
1664
2093
  }
1665
- if (event.kind === "verify") {
1666
- if (event.baseCommit) {
1667
- fallbackCommit = event.baseCommit;
1668
- snapshots = /* @__PURE__ */ new Map();
1669
- }
1670
- if (event.files.length === 0 && session) files = relevant(view.work.edited);
1671
- }
1672
- const changes = await computeChanges(root, files, { snapshots, fallbackCommit });
1673
- if (event.kind === "verify" && session && !event.baseCommit) {
1674
- files = files.filter((file) => changes.get(file)?.changedLines.length !== 0);
1675
- }
2094
+ if (event.kind === "verify" && event.files.length === 0 && session) requested = relevant(view.work.edited);
2095
+ const read = contentReader(root, event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE);
2096
+ const stage = await changesFor(
2097
+ baselineInput,
2098
+ { kind: event.kind, baseCommit: event.baseCommit },
2099
+ session ? baseline : null,
2100
+ requested,
2101
+ read
2102
+ );
2103
+ const { files, changes: baselineChanges, fingerprints } = stage;
2104
+ const changes = baselineChanges.sets;
2105
+ if (session && stage.records.length > 0) await appendBaseline(session.dir, stage.records);
1676
2106
  const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
1677
2107
  let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
1678
2108
  (selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
1679
2109
  );
1680
- let overBudget = [];
2110
+ const overBudget = [];
1681
2111
  if (event.kind === "verify") {
1682
- const budgeted = applyFileBudget("llm", selections, config.llm.maxFilesPerVerify, [...view.work.edited].reverse());
1683
- selections = budgeted.selections;
1684
- overBudget = budgeted.skipped;
2112
+ const recent = [...view.work.edited].reverse();
2113
+ for (const kind of new Set(selections.map((selection) => selection.rule.detector.kind))) {
2114
+ const budget = registry.get(kind)?.fileBudget?.(detection.settings);
2115
+ if (budget === void 0) continue;
2116
+ const budgeted = applyFileBudget(kind, selections, budget.max, recent);
2117
+ selections = budgeted.selections;
2118
+ if (budgeted.skipped.length > 0) overBudget.push({ kind, ...budget, skipped: budgeted.skipped });
2119
+ }
1685
2120
  }
1686
2121
  if (event.kind === "edit") {
1687
2122
  const ceiling = await applySizeCeiling(
@@ -1695,35 +2130,54 @@ async function runPipeline(options) {
1695
2130
  log(`over max_file_bytes (${config.maxFileBytes}), not checked on this edit: ${ceiling.skipped.join(", ")}`);
1696
2131
  }
1697
2132
  }
1698
- const output = await runDetection({
1699
- root,
1700
- event: event.kind,
1701
- selections,
1702
- changes,
1703
- read: (file) => readSourceFile(root, file),
1704
- registry,
1705
- cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
1706
- contextFor: (rule) => resolveRuleContext(resolver, rule.context),
1707
- settings: { llm: config.llm },
1708
- timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
1709
- });
2133
+ const output = await runDetectionFrom(
2134
+ {
2135
+ detection,
2136
+ event: event.kind,
2137
+ selections,
2138
+ changes,
2139
+ read,
2140
+ timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
2141
+ },
2142
+ event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE
2143
+ );
1710
2144
  findings = output.findings.map(({ rule, match }) => ({
1711
2145
  rule,
1712
2146
  match,
1713
- status: isNew(match, changes) ? "new" : "preexisting"
2147
+ status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
1714
2148
  }));
1715
2149
  for (const error of output.errors) {
1716
2150
  failed = true;
1717
2151
  log(`${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}`);
1718
2152
  for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
1719
2153
  }
1720
- warnings.push(...detectorWarnings(output.errors));
1721
- if (overBudget.length > 0) {
2154
+ warnings.push(...detectorWarnings(output.errors, session === null ? "run" : "session"));
2155
+ ignored = output.ignored;
2156
+ if (options.stopGate === true && event.kind === "verify" && session !== null) {
2157
+ told = notices({
2158
+ startConfigHash: baseline.startConfigHash ?? null,
2159
+ currentConfigHash: await configHash(root),
2160
+ ignored: ignored.map(({ rule, match, reason }) => ({
2161
+ rule: rule.id,
2162
+ match,
2163
+ reason,
2164
+ status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
2165
+ })),
2166
+ told: view.work.noticed
2167
+ });
2168
+ for (const notice of told) for (const key of notice.key.split("\n")) workRecords.push({ t: "noticed", key });
2169
+ }
2170
+ for (const text of output.warnings) warnings.push({ key: `ignore:${text}`, text });
2171
+ for (const { kind, max, setting, skipped } of overBudget) {
1722
2172
  warnings.push({
1723
- key: `llm-budget:${overBudget.join(",")}`,
1724
- 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.`
2173
+ key: `${kind}-budget:${skipped.join(",")}`,
2174
+ text: `${kind} rules checked ${max} files; ${skipped.length} were not checked: ${skipped.join(", ")}. Raise ${setting}, or run rulecast run --files on them.`
1725
2175
  });
1726
2176
  }
2177
+ const hintFor = (kind) => {
2178
+ const hint = registry.get(kind)?.timeoutHint;
2179
+ return hint === void 0 ? "" : ` (${hint})`;
2180
+ };
1727
2181
  for (const timeout of output.timedOut) {
1728
2182
  if (event.kind === "edit") {
1729
2183
  log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
@@ -1732,9 +2186,9 @@ async function runPipeline(options) {
1732
2186
  failed = true;
1733
2187
  warnings.push({
1734
2188
  key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
1735
- // Naming the lever matters: dogfooding measured a single haiku call on a 135-line file
2189
+ // Naming the lever matters: a field trial measured a single haiku call on a 135-line file
1736
2190
  // at 89 s against a 60 s default, and "timed out" alone leaves nobody knowing what to do.
1737
- 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)" : ""}.`
2191
+ text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${hintFor(timeout.kind)}.`
1738
2192
  });
1739
2193
  }
1740
2194
  }
@@ -1754,17 +2208,30 @@ async function runPipeline(options) {
1754
2208
  maxMatchesPerRule: config.maxMatchesPerRule,
1755
2209
  stopGate: options.stopGate === true && event.kind === "verify" && session !== null
1756
2210
  });
2211
+ const home = path14.dirname(path14.dirname(stateDir));
1757
2212
  const persisted = (decision) => {
1758
2213
  if (decision.overflow !== null) {
1759
- decision.delivery.overflowPath = writeOverflow(stateDir, event.session?.id ?? null, decision.overflow);
2214
+ let written = null;
2215
+ try {
2216
+ written = writeOverflow(path14.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
2217
+ } catch {
2218
+ written = null;
2219
+ }
2220
+ decision.delivery.overflowPath = written === null ? null : path14.relative(root, written).split(path14.sep).join("/");
2221
+ }
2222
+ for (const reference of decision.delivery.references) {
2223
+ if (reference.location === void 0 || !path14.isAbsolute(reference.location)) continue;
2224
+ const copied = copyIntoProject(root, home, reference.location);
2225
+ if (copied !== null) reference.location = copied;
1760
2226
  }
2227
+ if (told.length > 0) decision.delivery.notices = told.map((notice) => notice.text);
1761
2228
  return decision.delivery;
1762
2229
  };
1763
- if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
2230
+ if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed, ignored };
1764
2231
  await appendWork(session.dir, workRecords);
1765
2232
  try {
1766
2233
  const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
1767
- return { delivery: persisted(decision), failed, deadlineMissed };
2234
+ return { delivery: persisted(decision), failed, deadlineMissed, ignored };
1768
2235
  } catch (error) {
1769
2236
  if (error instanceof CorruptStoreError) {
1770
2237
  log(`session store unreadable: ${error.message}`);
@@ -1773,7 +2240,7 @@ async function runPipeline(options) {
1773
2240
  warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
1774
2241
  } else throw error;
1775
2242
  const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
1776
- return { delivery: persisted(decision), failed, deadlineMissed };
2243
+ return { delivery: persisted(decision), failed, deadlineMissed, ignored };
1777
2244
  }
1778
2245
  }
1779
2246
 
@@ -1781,10 +2248,12 @@ export {
1781
2248
  defaultConfig,
1782
2249
  CONFIG_FILE,
1783
2250
  MANIFEST_FILE,
2251
+ readYamlFile,
1784
2252
  readConfigData,
1785
2253
  parseConfig,
1786
2254
  readManifest,
1787
2255
  git,
2256
+ headCommit,
1788
2257
  mergeBase,
1789
2258
  changedFilesSince,
1790
2259
  allFiles,
@@ -1805,12 +2274,16 @@ export {
1805
2274
  diagnosticText,
1806
2275
  compile,
1807
2276
  compileManifest,
2277
+ createReferenceResolver,
1808
2278
  rulesOfKind,
1809
2279
  checkableKinds,
1810
2280
  checkDetectors,
2281
+ detectionFor,
1811
2282
  runDetection,
1812
2283
  cachedRepos,
1813
2284
  fetchingRepos,
1814
2285
  fixedRepo,
2286
+ WORKTREE,
2287
+ withScratchTree,
1815
2288
  runPipeline
1816
2289
  };