@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.
- package/README.md +134 -10
- package/dist/{chunk-ZEFEQKG5.js → chunk-DQUCE2IK.js} +230 -69
- package/dist/{chunk-C6C5EER7.js → chunk-IKONOTME.js} +238 -130
- package/dist/{chunk-RODZRI7B.js → chunk-WUFU4LW2.js} +792 -319
- package/dist/cli.js +803 -102
- package/dist/index.d.ts +111 -4
- package/dist/index.js +37 -5
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-2oz2AlwA.d.ts → types-Dcra5LXR.d.ts} +200 -3
- package/package.json +3 -2
|
@@ -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-
|
|
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)
|
|
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(
|
|
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: `${
|
|
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,
|
|
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
|
|
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
|
|
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({
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
470
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
659
|
-
const
|
|
710
|
+
function diffLines(before, after) {
|
|
711
|
+
const changed = [];
|
|
712
|
+
const runs = [];
|
|
660
713
|
const mark = (start, end) => {
|
|
661
|
-
const previous =
|
|
714
|
+
const previous = changed.at(-1);
|
|
662
715
|
if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
|
|
663
|
-
else
|
|
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
|
|
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
|
-
|
|
725
|
-
before =
|
|
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
|
|
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
|
-
|
|
732
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
1252
|
+
function writeOverflow(dir, session, delivery, now = /* @__PURE__ */ new Date()) {
|
|
857
1253
|
const prefix = safeSegment2(session ?? "no-session");
|
|
858
|
-
const
|
|
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(
|
|
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 = (
|
|
883
|
-
let text = texts.get(
|
|
1277
|
+
const read = (path15) => {
|
|
1278
|
+
let text = texts.get(path15);
|
|
884
1279
|
if (!text) {
|
|
885
|
-
text = readSourceFile(root,
|
|
886
|
-
texts.set(
|
|
1280
|
+
text = readSourceFile(root, path15);
|
|
1281
|
+
texts.set(path15, text);
|
|
887
1282
|
}
|
|
888
1283
|
return text;
|
|
889
1284
|
};
|
|
890
|
-
const contentOf = async (
|
|
891
|
-
const text = await read(
|
|
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(
|
|
904
|
-
const content = await contentOf(
|
|
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(
|
|
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(
|
|
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/
|
|
917
|
-
function
|
|
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
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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/
|
|
946
|
-
var
|
|
947
|
-
async function
|
|
948
|
-
|
|
949
|
-
const
|
|
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
|
|
952
|
-
const
|
|
953
|
-
if (
|
|
954
|
-
else
|
|
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
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
const
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
|
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,
|
|
1061
|
-
if (
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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 (
|
|
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) => !
|
|
1598
|
+
const unwarned = (input.warnings ?? []).filter((warning) => !state.warned.has(warning.key));
|
|
1228
1599
|
delivery.warnings = unwarned.map((warning) => warning.text);
|
|
1229
|
-
delivery.touches =
|
|
1230
|
-
for (const rule of
|
|
1231
|
-
const delivered = [...
|
|
1232
|
-
|
|
1233
|
-
|
|
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:
|
|
1236
|
-
context.push({ t: "delivered", path:
|
|
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(), ...
|
|
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
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1307
|
-
|
|
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
|
|
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.
|
|
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(
|
|
1744
|
+
if (!fits(deliveryCost.backlogSummary(summary))) {
|
|
1357
1745
|
delivery.omitted.preexisting++;
|
|
1358
1746
|
continue;
|
|
1359
1747
|
}
|
|
1360
|
-
used +=
|
|
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,
|
|
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
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
}
|
|
1406
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1899
|
+
return path13.join(stateDir, "sessions", safeSegment3(sessionId));
|
|
1481
1900
|
}
|
|
1482
|
-
var workFile = (dir) =>
|
|
1483
|
-
var contextFile = (dir, agent) =>
|
|
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 =
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2091
|
+
requested.map((file) => ({ t: "edited", file }))
|
|
1663
2092
|
);
|
|
1664
2093
|
}
|
|
1665
|
-
if (event.kind === "verify")
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
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
|
-
|
|
2110
|
+
const overBudget = [];
|
|
1681
2111
|
if (event.kind === "verify") {
|
|
1682
|
-
const
|
|
1683
|
-
selections
|
|
1684
|
-
|
|
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
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
1724
|
-
text:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
};
|