@syv-ai/rulecast 0.3.0 → 0.5.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 +93 -10
- package/dist/{chunk-HZUATJ53.js → chunk-4XS34SYW.js} +266 -81
- package/dist/{chunk-KOUHBT7F.js → chunk-MB4F7ZXC.js} +236 -133
- package/dist/{chunk-7KUPYT35.js → chunk-Y3FXJRZB.js} +818 -275
- package/dist/cli.js +607 -152
- package/dist/index.d.ts +75 -4
- package/dist/index.js +32 -4
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-C8b7hrnG.d.ts → types-D5y_IokF.d.ts} +124 -5
- package/package.json +3 -2
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BACKLOG_HINT,
|
|
3
2
|
DeadlineError,
|
|
4
3
|
LLM_PROVIDERS,
|
|
4
|
+
SWEPT_BLOCK,
|
|
5
5
|
VERSION,
|
|
6
6
|
compileFilter,
|
|
7
7
|
compileRule,
|
|
8
|
+
deliveryCost,
|
|
8
9
|
detectorCacheDir,
|
|
9
10
|
diskCache,
|
|
10
11
|
emptyDelivery,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
sectionRange,
|
|
25
26
|
sectionText,
|
|
26
27
|
templateBindings
|
|
27
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-4XS34SYW.js";
|
|
28
29
|
|
|
29
30
|
// src/core/config/load.ts
|
|
30
31
|
import { readFile } from "fs/promises";
|
|
@@ -59,6 +60,8 @@ var ruleKeys = {
|
|
|
59
60
|
*/
|
|
60
61
|
scope: z.enum(RULE_SCOPES).optional(),
|
|
61
62
|
refuse_write: z.boolean().optional(),
|
|
63
|
+
/** false: compiled and listed, selected for nothing. The way to switch a catalog rule off without deleting it. */
|
|
64
|
+
enabled: z.boolean().optional(),
|
|
62
65
|
detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
|
|
63
66
|
message: z.string().optional(),
|
|
64
67
|
context: z.array(referenceInputSchema).optional(),
|
|
@@ -150,13 +153,15 @@ function readConfigData(root) {
|
|
|
150
153
|
}
|
|
151
154
|
function parseConfig(data) {
|
|
152
155
|
const result = configSchema.safeParse(data);
|
|
153
|
-
if (!result.success)
|
|
156
|
+
if (!result.success) {
|
|
157
|
+
return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error, { schema: configSchema })}` };
|
|
158
|
+
}
|
|
154
159
|
return { ok: true, value: result.data };
|
|
155
160
|
}
|
|
156
|
-
async function readManifest(dir) {
|
|
157
|
-
const loaded = await readYamlFile(
|
|
161
|
+
async function readManifest(dir, file = path.join(dir, MANIFEST_FILE)) {
|
|
162
|
+
const loaded = await readYamlFile(file);
|
|
158
163
|
if (!loaded.ok) return loaded;
|
|
159
|
-
if (!Array.isArray(loaded.value)) return { ok: false, message: `${
|
|
164
|
+
if (!Array.isArray(loaded.value)) return { ok: false, message: `${path.basename(file)}: must be a list of rules` };
|
|
160
165
|
return { ok: true, value: loaded.value };
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -197,6 +202,7 @@ function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
|
|
|
197
202
|
|
|
198
203
|
// src/core/compile/project.ts
|
|
199
204
|
import { readFile as readFile2 } from "fs/promises";
|
|
205
|
+
import path3 from "path";
|
|
200
206
|
function diagnosticText(diagnostic) {
|
|
201
207
|
const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
|
|
202
208
|
return `${diagnostic.source}${rule}: ${diagnostic.message}`;
|
|
@@ -274,7 +280,7 @@ async function compile(options) {
|
|
|
274
280
|
}
|
|
275
281
|
for (const raw of entry.rules) {
|
|
276
282
|
const rule = ruleSchema.safeParse(raw);
|
|
277
|
-
if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
|
|
283
|
+
if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error, { schema: ruleSchema }));
|
|
278
284
|
else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
|
|
279
285
|
}
|
|
280
286
|
continue;
|
|
@@ -311,7 +317,7 @@ async function compile(options) {
|
|
|
311
317
|
for (const raw of entry.rules) {
|
|
312
318
|
const override = overrideSchema.safeParse(raw);
|
|
313
319
|
if (!override.success) {
|
|
314
|
-
error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
|
|
320
|
+
error(CONFIG_FILE, idOf(raw), formatZodError(override.error, { schema: overrideSchema }));
|
|
315
321
|
continue;
|
|
316
322
|
}
|
|
317
323
|
const { id } = override.data;
|
|
@@ -322,7 +328,7 @@ async function compile(options) {
|
|
|
322
328
|
}
|
|
323
329
|
const base = ruleSchema.safeParse(candidates[0]);
|
|
324
330
|
if (!base.success) {
|
|
325
|
-
error(source, id,
|
|
331
|
+
error(source, id, formatZodError(base.error, { prefix: "manifest", schema: ruleSchema }));
|
|
326
332
|
continue;
|
|
327
333
|
}
|
|
328
334
|
const data2 = { ...base.data, ...override.data };
|
|
@@ -337,12 +343,13 @@ async function compile(options) {
|
|
|
337
343
|
);
|
|
338
344
|
return { root, config, rules, diagnostics };
|
|
339
345
|
}
|
|
340
|
-
async function compileManifest(dir, registry) {
|
|
341
|
-
const
|
|
346
|
+
async function compileManifest(dir, registry, file = path3.join(dir, MANIFEST_FILE)) {
|
|
347
|
+
const source = path3.basename(file);
|
|
348
|
+
const manifest = await readManifest(dir, file);
|
|
342
349
|
if (!manifest.ok) {
|
|
343
350
|
return {
|
|
344
351
|
rules: [],
|
|
345
|
-
diagnostics: [{ source
|
|
352
|
+
diagnostics: [{ source, rule: null, message: manifest.message, level: "error" }]
|
|
346
353
|
};
|
|
347
354
|
}
|
|
348
355
|
const diagnostics = [];
|
|
@@ -351,18 +358,23 @@ async function compileManifest(dir, registry) {
|
|
|
351
358
|
for (const raw of manifest.value) {
|
|
352
359
|
const rule = ruleSchema.safeParse(raw);
|
|
353
360
|
if (!rule.success) {
|
|
354
|
-
diagnostics.push({
|
|
361
|
+
diagnostics.push({
|
|
362
|
+
source,
|
|
363
|
+
rule: idOf(raw),
|
|
364
|
+
message: formatZodError(rule.error, { schema: ruleSchema }),
|
|
365
|
+
level: "error"
|
|
366
|
+
});
|
|
355
367
|
continue;
|
|
356
368
|
}
|
|
357
369
|
const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
|
|
358
370
|
if (typeof result === "string") {
|
|
359
371
|
diagnostics.push({
|
|
360
|
-
source
|
|
372
|
+
source,
|
|
361
373
|
rule: rule.data.alias ?? rule.data.id,
|
|
362
374
|
message: result,
|
|
363
375
|
level: "error"
|
|
364
376
|
});
|
|
365
|
-
} else compiled.push({ rule: result, source
|
|
377
|
+
} else compiled.push({ rule: result, source });
|
|
366
378
|
}
|
|
367
379
|
const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
|
|
368
380
|
return { rules, diagnostics };
|
|
@@ -371,7 +383,7 @@ async function compileManifest(dir, registry) {
|
|
|
371
383
|
// src/core/detection/select.ts
|
|
372
384
|
function selectDetectorRules(rules, event, files, disabled) {
|
|
373
385
|
return rules.filter(
|
|
374
|
-
(rule) => isDetectorRule(rule) && rule.stages.includes(event) && !disabled.has(rule.id)
|
|
386
|
+
(rule) => isDetectorRule(rule) && rule.enabled && rule.stages.includes(event) && !disabled.has(rule.id)
|
|
375
387
|
).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
|
|
376
388
|
}
|
|
377
389
|
function rulesOfKind(rules, kind) {
|
|
@@ -379,7 +391,7 @@ function rulesOfKind(rules, kind) {
|
|
|
379
391
|
}
|
|
380
392
|
function selectTouchRules(rules, files, touched, disabled) {
|
|
381
393
|
return rules.filter(
|
|
382
|
-
(rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
|
|
394
|
+
(rule) => rule.enabled && rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
|
|
383
395
|
);
|
|
384
396
|
}
|
|
385
397
|
|
|
@@ -429,7 +441,7 @@ async function checkDetectors(options) {
|
|
|
429
441
|
import { randomUUID } from "crypto";
|
|
430
442
|
import { existsSync as existsSync2 } from "fs";
|
|
431
443
|
import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
|
|
432
|
-
import
|
|
444
|
+
import path6 from "path";
|
|
433
445
|
|
|
434
446
|
// src/core/git.ts
|
|
435
447
|
import { execFile } from "child_process";
|
|
@@ -475,10 +487,26 @@ async function fileAtCommit(cwd, commit, file) {
|
|
|
475
487
|
const result = await git(cwd, ["show", `${commit}:./${file}`]);
|
|
476
488
|
return result.ok ? result.stdout : null;
|
|
477
489
|
}
|
|
490
|
+
async function fileInIndex(cwd, file) {
|
|
491
|
+
const result = await git(cwd, ["show", `:./${file}`]);
|
|
492
|
+
return result.ok ? result.stdout : null;
|
|
493
|
+
}
|
|
494
|
+
async function isShallow(cwd) {
|
|
495
|
+
const result = await git(cwd, ["rev-parse", "--is-shallow-repository"]);
|
|
496
|
+
return result.ok && result.stdout.trim() === "true";
|
|
497
|
+
}
|
|
498
|
+
var FETCH_HISTORY = "fetch full history (actions/checkout: fetch-depth: 0)";
|
|
478
499
|
async function mergeBase(cwd, ref, other = "HEAD") {
|
|
479
500
|
const result = await git(cwd, ["merge-base", other, ref]);
|
|
480
|
-
if (
|
|
481
|
-
|
|
501
|
+
if (result.ok) return result.stdout.trim();
|
|
502
|
+
const shallow = await isShallow(cwd);
|
|
503
|
+
const resolves = (await git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).ok;
|
|
504
|
+
if (!resolves) {
|
|
505
|
+
throw new Error(`"${ref}" is not a commit here${shallow ? `; this is a shallow clone: ${FETCH_HISTORY}` : ""}`);
|
|
506
|
+
}
|
|
507
|
+
if (shallow) throw new Error(`no merge base with ${ref} in a shallow clone: ${FETCH_HISTORY}`);
|
|
508
|
+
const detail = result.stderr.trim();
|
|
509
|
+
throw new Error(`no merge base with ${ref}${detail ? `: ${detail}` : ": the histories are unrelated"}`);
|
|
482
510
|
}
|
|
483
511
|
async function changedFilesSince(cwd, commit) {
|
|
484
512
|
const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
|
|
@@ -509,7 +537,7 @@ async function changedFilesBetween(cwd, from, to) {
|
|
|
509
537
|
|
|
510
538
|
// src/core/session/lock.ts
|
|
511
539
|
import { mkdir, rm, stat } from "fs/promises";
|
|
512
|
-
import
|
|
540
|
+
import path4 from "path";
|
|
513
541
|
var LockTimeoutError = class extends Error {
|
|
514
542
|
};
|
|
515
543
|
var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
|
|
@@ -517,7 +545,7 @@ function isAlreadyExists(error) {
|
|
|
517
545
|
return error instanceof Error && error.code === "EEXIST";
|
|
518
546
|
}
|
|
519
547
|
async function withLock(dir, fn, options = DEFAULTS) {
|
|
520
|
-
const lock =
|
|
548
|
+
const lock = path4.join(dir, ".lock");
|
|
521
549
|
await mkdir(dir, { recursive: true });
|
|
522
550
|
const started = Date.now();
|
|
523
551
|
for (; ; ) {
|
|
@@ -550,7 +578,7 @@ async function withLock(dir, fn, options = DEFAULTS) {
|
|
|
550
578
|
|
|
551
579
|
// src/core/repos/layout.ts
|
|
552
580
|
import { createHash as createHash2 } from "crypto";
|
|
553
|
-
import
|
|
581
|
+
import path5 from "path";
|
|
554
582
|
import { fileURLToPath } from "url";
|
|
555
583
|
var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
556
584
|
var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
|
|
@@ -558,7 +586,7 @@ function segmentsOf(repoPath) {
|
|
|
558
586
|
return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
|
|
559
587
|
}
|
|
560
588
|
function localName(file) {
|
|
561
|
-
return
|
|
589
|
+
return path5.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
|
|
562
590
|
}
|
|
563
591
|
function parseUrl(url) {
|
|
564
592
|
if (SCHEME.test(url)) {
|
|
@@ -585,7 +613,7 @@ function repoSlug(url) {
|
|
|
585
613
|
return safeSegment(`local_${parsed.name}_${hash}`);
|
|
586
614
|
}
|
|
587
615
|
function repoDir(home, url, rev) {
|
|
588
|
-
return
|
|
616
|
+
return path5.join(home, "repos", repoSlug(url), safeSegment(rev));
|
|
589
617
|
}
|
|
590
618
|
function repoLabel(url, rev) {
|
|
591
619
|
return `${repoName(url)}@${rev}`;
|
|
@@ -605,7 +633,7 @@ async function fetchCheckout(url, rev, into) {
|
|
|
605
633
|
await run(into, ["init", "-q"]);
|
|
606
634
|
await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
|
|
607
635
|
await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
|
|
608
|
-
await rm2(
|
|
636
|
+
await rm2(path6.join(into, ".git"), { recursive: true, force: true });
|
|
609
637
|
}
|
|
610
638
|
function cachedRepo(home, url, rev) {
|
|
611
639
|
const dir = repoDir(home, url, rev);
|
|
@@ -614,12 +642,12 @@ function cachedRepo(home, url, rev) {
|
|
|
614
642
|
async function ensureRepo(home, url, rev) {
|
|
615
643
|
const dir = repoDir(home, url, rev);
|
|
616
644
|
if (existsSync2(dir)) return dir;
|
|
617
|
-
const parent =
|
|
645
|
+
const parent = path6.dirname(dir);
|
|
618
646
|
await withLock(
|
|
619
647
|
parent,
|
|
620
648
|
async () => {
|
|
621
649
|
if (existsSync2(dir)) return;
|
|
622
|
-
const temp =
|
|
650
|
+
const temp = path6.join(parent, `.tmp-${randomUUID()}`);
|
|
623
651
|
try {
|
|
624
652
|
await fetchCheckout(url, rev, temp);
|
|
625
653
|
await rename(temp, dir);
|
|
@@ -662,7 +690,21 @@ function fixedRepo(dir, label) {
|
|
|
662
690
|
}
|
|
663
691
|
|
|
664
692
|
// src/core/pipeline.ts
|
|
665
|
-
import { createHash as
|
|
693
|
+
import { createHash as createHash5 } from "crypto";
|
|
694
|
+
import path15 from "path";
|
|
695
|
+
|
|
696
|
+
// src/core/content.ts
|
|
697
|
+
var WORKTREE = { kind: "worktree" };
|
|
698
|
+
function contentReader(root, source) {
|
|
699
|
+
switch (source.kind) {
|
|
700
|
+
case "worktree":
|
|
701
|
+
return (file) => readSourceFile(root, file);
|
|
702
|
+
case "index":
|
|
703
|
+
return (file) => fileInIndex(root, file);
|
|
704
|
+
case "commit":
|
|
705
|
+
return (file) => fileAtCommit(root, source.ref, file);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
666
708
|
|
|
667
709
|
// src/core/baseline/changes.ts
|
|
668
710
|
import { diffArrays } from "diff";
|
|
@@ -734,7 +776,7 @@ function snapshotOf(text) {
|
|
|
734
776
|
}
|
|
735
777
|
|
|
736
778
|
// src/core/baseline/baseline.ts
|
|
737
|
-
async function computeChanges(root, files, sources) {
|
|
779
|
+
async function computeChanges(root, files, sources, read = contentReader(root, WORKTREE)) {
|
|
738
780
|
const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
|
|
739
781
|
for (const file of files) {
|
|
740
782
|
if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
|
|
@@ -745,7 +787,7 @@ async function computeChanges(root, files, sources) {
|
|
|
745
787
|
before = commitText === null ? null : snapshotOf(commitText);
|
|
746
788
|
}
|
|
747
789
|
if (!before) continue;
|
|
748
|
-
const current = await
|
|
790
|
+
const current = await read(file);
|
|
749
791
|
if (current === null) continue;
|
|
750
792
|
if (commitText !== null) changes.fromCommit.set(file, commitText);
|
|
751
793
|
const after = snapshotOf(current);
|
|
@@ -821,10 +863,59 @@ async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
|
|
|
821
863
|
};
|
|
822
864
|
}
|
|
823
865
|
|
|
866
|
+
// src/core/suppress.ts
|
|
867
|
+
var ID = RULE_ID.source.slice(1, -1);
|
|
868
|
+
var IGNORE = new RegExp(
|
|
869
|
+
String.raw`rulecast-ignore:\s*(?<rule>${ID})(?:\s+(?<reason>\S.*?))?\s*(?:\*\/|-->)?\s*$`
|
|
870
|
+
);
|
|
871
|
+
function parseIgnore(line) {
|
|
872
|
+
const match = IGNORE.exec(line);
|
|
873
|
+
if (match?.groups === void 0) return null;
|
|
874
|
+
return { rule: match.groups.rule, reason: match.groups.reason ?? null };
|
|
875
|
+
}
|
|
876
|
+
async function suppress(findings, read) {
|
|
877
|
+
const result = { findings: [], ignored: [], warnings: [] };
|
|
878
|
+
if (findings.length === 0) return result;
|
|
879
|
+
const lines = /* @__PURE__ */ new Map();
|
|
880
|
+
const linesOf = async (file) => {
|
|
881
|
+
if (!lines.has(file)) lines.set(file, (await read(file))?.split("\n") ?? null);
|
|
882
|
+
return lines.get(file);
|
|
883
|
+
};
|
|
884
|
+
const warned = /* @__PURE__ */ new Set();
|
|
885
|
+
for (const finding of findings) {
|
|
886
|
+
const source = await linesOf(finding.match.file);
|
|
887
|
+
let ignore = null;
|
|
888
|
+
let at = 0;
|
|
889
|
+
for (const line of [finding.match.line, finding.match.line - 1]) {
|
|
890
|
+
const parsed = line >= 1 ? parseIgnore(source?.[line - 1] ?? "") : null;
|
|
891
|
+
if (parsed !== null && parsed.rule === finding.rule.id) {
|
|
892
|
+
ignore = parsed;
|
|
893
|
+
at = line;
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (ignore === null) {
|
|
898
|
+
result.findings.push(finding);
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
if (ignore.reason === null) {
|
|
902
|
+
const where = `${finding.match.file}:${at}`;
|
|
903
|
+
if (!warned.has(where)) {
|
|
904
|
+
warned.add(where);
|
|
905
|
+
result.warnings.push(`${where}: rulecast-ignore needs a reason after the rule id; the finding was kept`);
|
|
906
|
+
}
|
|
907
|
+
result.findings.push(finding);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
result.ignored.push({ ...finding, reason: ignore.reason });
|
|
911
|
+
}
|
|
912
|
+
return result;
|
|
913
|
+
}
|
|
914
|
+
|
|
824
915
|
// src/core/detection/run.ts
|
|
825
916
|
var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
|
|
826
917
|
async function runDetection(input) {
|
|
827
|
-
const output = { findings: [], errors: [], timedOut: [] };
|
|
918
|
+
const output = { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
|
|
828
919
|
const byKind = /* @__PURE__ */ new Map();
|
|
829
920
|
for (const selection of input.selections) {
|
|
830
921
|
const kind = selection.rule.detector.kind;
|
|
@@ -841,7 +932,7 @@ async function runDetection(input) {
|
|
|
841
932
|
const late = () => Date.now() > deadlineAt;
|
|
842
933
|
const perKind = await Promise.all(
|
|
843
934
|
[...byKind].map(async ([kind, selections]) => {
|
|
844
|
-
const detector = input.registry.get(kind);
|
|
935
|
+
const detector = input.detection.registry.get(kind);
|
|
845
936
|
if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
|
|
846
937
|
try {
|
|
847
938
|
const rules = await Promise.all(
|
|
@@ -849,7 +940,7 @@ async function runDetection(input) {
|
|
|
849
940
|
id: rule.id,
|
|
850
941
|
config: rule.detector.config,
|
|
851
942
|
files,
|
|
852
|
-
context: await input.contextFor(rule)
|
|
943
|
+
context: await input.detection.contextFor(rule)
|
|
853
944
|
}))
|
|
854
945
|
);
|
|
855
946
|
const result = await Promise.race([
|
|
@@ -857,10 +948,11 @@ async function runDetection(input) {
|
|
|
857
948
|
event: input.event,
|
|
858
949
|
rules,
|
|
859
950
|
read: input.read,
|
|
951
|
+
fromDisk: input.fromDisk ?? true,
|
|
860
952
|
changes: input.changes,
|
|
861
|
-
cache: input.cacheFor(kind),
|
|
862
|
-
settings: input.settings,
|
|
863
|
-
cwd: input.root,
|
|
953
|
+
cache: input.detection.cacheFor(kind),
|
|
954
|
+
settings: input.detection.settings,
|
|
955
|
+
cwd: input.detection.root,
|
|
864
956
|
signal: controller.signal,
|
|
865
957
|
deadlineAt
|
|
866
958
|
}),
|
|
@@ -908,20 +1000,21 @@ async function runDetection(input) {
|
|
|
908
1000
|
if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
|
|
909
1001
|
}
|
|
910
1002
|
}
|
|
911
|
-
|
|
1003
|
+
const suppressed = await suppress(output.findings, input.read);
|
|
1004
|
+
return { ...output, ...suppressed };
|
|
912
1005
|
}
|
|
913
1006
|
|
|
914
1007
|
// src/core/baseline/store.ts
|
|
915
|
-
import
|
|
1008
|
+
import path8 from "path";
|
|
916
1009
|
|
|
917
1010
|
// src/core/jsonl.ts
|
|
918
1011
|
import { mkdir as mkdir3, open, readFile as readFile3 } from "fs/promises";
|
|
919
|
-
import
|
|
1012
|
+
import path7 from "path";
|
|
920
1013
|
var CorruptStoreError = class extends Error {
|
|
921
1014
|
};
|
|
922
1015
|
async function appendRecords(file, records) {
|
|
923
1016
|
if (records.length === 0) return;
|
|
924
|
-
await mkdir3(
|
|
1017
|
+
await mkdir3(path7.dirname(file), { recursive: true });
|
|
925
1018
|
const handle = await open(file, "a+");
|
|
926
1019
|
try {
|
|
927
1020
|
await dropPartialRecord(handle);
|
|
@@ -973,7 +1066,7 @@ async function readRecords(file) {
|
|
|
973
1066
|
|
|
974
1067
|
// src/core/baseline/store.ts
|
|
975
1068
|
function storeFile(sessionDir2) {
|
|
976
|
-
return
|
|
1069
|
+
return path8.join(sessionDir2, "baseline.jsonl");
|
|
977
1070
|
}
|
|
978
1071
|
function encodeLines(lines) {
|
|
979
1072
|
return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
|
|
@@ -984,8 +1077,8 @@ function decodeLines(encoded) {
|
|
|
984
1077
|
aligned.set(bytes);
|
|
985
1078
|
return new Uint32Array(aligned.buffer);
|
|
986
1079
|
}
|
|
987
|
-
function startRecord(commit) {
|
|
988
|
-
return { t: "start", commit };
|
|
1080
|
+
function startRecord(commit, configHash2 = null) {
|
|
1081
|
+
return { t: "start", commit, configHash: configHash2 };
|
|
989
1082
|
}
|
|
990
1083
|
function snapshotRecord(file, snapshot) {
|
|
991
1084
|
return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
|
|
@@ -996,29 +1089,37 @@ function fingerprintRecord(file, rule, ranges) {
|
|
|
996
1089
|
async function appendBaseline(sessionDir2, records) {
|
|
997
1090
|
await appendRecords(storeFile(sessionDir2), records);
|
|
998
1091
|
}
|
|
1092
|
+
function mergeFingerprints(into, records) {
|
|
1093
|
+
for (const record of records) {
|
|
1094
|
+
if (record.t !== "fingerprint") continue;
|
|
1095
|
+
const byRule = into.get(record.file) ?? /* @__PURE__ */ new Map();
|
|
1096
|
+
into.set(record.file, byRule);
|
|
1097
|
+
if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
|
|
1098
|
+
}
|
|
1099
|
+
return into;
|
|
1100
|
+
}
|
|
999
1101
|
async function readBaseline(sessionDir2) {
|
|
1000
1102
|
const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
|
|
1001
|
-
|
|
1103
|
+
const records = await readRecords(storeFile(sessionDir2));
|
|
1104
|
+
for (const record of records) {
|
|
1002
1105
|
if (record.t === "start" && !state.started) {
|
|
1003
1106
|
state.started = true;
|
|
1004
1107
|
state.startCommit = record.commit;
|
|
1108
|
+
state.startConfigHash = record.configHash ?? null;
|
|
1005
1109
|
} else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
|
|
1006
1110
|
state.snapshots.set(record.file, { fileHash: record.fileHash, lines: decodeLines(record.lines) });
|
|
1007
|
-
} else if (record.t === "fingerprint") {
|
|
1008
|
-
const byRule = state.fingerprints.get(record.file) ?? /* @__PURE__ */ new Map();
|
|
1009
|
-
state.fingerprints.set(record.file, byRule);
|
|
1010
|
-
if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
|
|
1011
1111
|
}
|
|
1012
1112
|
}
|
|
1113
|
+
mergeFingerprints(state.fingerprints, records);
|
|
1013
1114
|
return state;
|
|
1014
1115
|
}
|
|
1015
1116
|
|
|
1016
1117
|
// src/core/baseline/record.ts
|
|
1017
1118
|
async function recordFingerprints(input) {
|
|
1018
1119
|
if (!input.rules.some((rule) => rule.scope === "container")) return [];
|
|
1019
|
-
const guards = (kind) => input.registry.get(kind)?.guards === true;
|
|
1120
|
+
const guards = (kind) => input.detection.registry.get(kind)?.guards === true;
|
|
1020
1121
|
let selections = selectDetectorRules(input.rules, input.event, input.files, input.disabled).filter(
|
|
1021
|
-
(selection) => selection.rule.scope === "container" && selection.rule.detector.kind !==
|
|
1122
|
+
(selection) => selection.rule.scope === "container" && input.detection.registry.get(selection.rule.detector.kind)?.metered !== true && (input.source === "disk" || guards(selection.rule.detector.kind))
|
|
1022
1123
|
);
|
|
1023
1124
|
if (input.ceiling) {
|
|
1024
1125
|
const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
|
|
@@ -1026,7 +1127,7 @@ async function recordFingerprints(input) {
|
|
|
1026
1127
|
}
|
|
1027
1128
|
if (selections.length === 0) return [];
|
|
1028
1129
|
const output = await runDetection({
|
|
1029
|
-
|
|
1130
|
+
detection: input.detection,
|
|
1030
1131
|
event: input.event,
|
|
1031
1132
|
selections,
|
|
1032
1133
|
// No change set: the baseline content is being measured whole, and there is nothing it is a
|
|
@@ -1034,10 +1135,6 @@ async function recordFingerprints(input) {
|
|
|
1034
1135
|
// exactly right here.
|
|
1035
1136
|
changes: /* @__PURE__ */ new Map(),
|
|
1036
1137
|
read: input.read,
|
|
1037
|
-
registry: input.registry,
|
|
1038
|
-
cacheFor: input.cacheFor,
|
|
1039
|
-
contextFor: input.contextFor,
|
|
1040
|
-
settings: input.settings,
|
|
1041
1138
|
timeoutMs: input.timeoutMs
|
|
1042
1139
|
});
|
|
1043
1140
|
const unmeasured = /* @__PURE__ */ new Set();
|
|
@@ -1058,9 +1155,101 @@ async function recordFingerprints(input) {
|
|
|
1058
1155
|
return records;
|
|
1059
1156
|
}
|
|
1060
1157
|
|
|
1158
|
+
// src/core/baseline/stage.ts
|
|
1159
|
+
async function touchRecords(input, state, files) {
|
|
1160
|
+
return (await snapshotRecords(input, state, files, null)).records;
|
|
1161
|
+
}
|
|
1162
|
+
var START_SNAPSHOT_MAX = 200;
|
|
1163
|
+
async function startRecords(input, state, files) {
|
|
1164
|
+
return snapshotRecords(input, state, files, {
|
|
1165
|
+
max: START_SNAPSHOT_MAX,
|
|
1166
|
+
deadlineAt: Date.now() + input.limits.editDeadlineMs
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
async function snapshotRecords(input, state, files, bounds) {
|
|
1170
|
+
const records = [];
|
|
1171
|
+
const snapshotted = [];
|
|
1172
|
+
const pending = files.filter((file) => !state.snapshots.has(file));
|
|
1173
|
+
let taken = 0;
|
|
1174
|
+
for (const file of pending) {
|
|
1175
|
+
if (bounds !== null && (taken >= bounds.max || Date.now() >= bounds.deadlineAt)) break;
|
|
1176
|
+
taken++;
|
|
1177
|
+
const text = await readSourceFile(input.root, file);
|
|
1178
|
+
if (text === null) continue;
|
|
1179
|
+
records.push(snapshotRecord(file, snapshotOf(text)));
|
|
1180
|
+
snapshotted.push(file);
|
|
1181
|
+
}
|
|
1182
|
+
const skipped = pending.length - taken;
|
|
1183
|
+
const timeoutMs = bounds === null ? input.limits.editDeadlineMs : bounds.deadlineAt - Date.now();
|
|
1184
|
+
if (snapshotted.length === 0 || timeoutMs <= 0) return { records, skipped };
|
|
1185
|
+
records.push(
|
|
1186
|
+
...await recordFingerprints({
|
|
1187
|
+
detection: input.detection,
|
|
1188
|
+
files: snapshotted,
|
|
1189
|
+
rules: input.rules,
|
|
1190
|
+
disabled: input.disabled,
|
|
1191
|
+
read: (file) => readSourceFile(input.root, file),
|
|
1192
|
+
event: "edit",
|
|
1193
|
+
source: "disk",
|
|
1194
|
+
timeoutMs,
|
|
1195
|
+
ceiling: { maxFileBytes: input.limits.maxFileBytes, sizeOf: (file) => fileBytes(input.root, file) }
|
|
1196
|
+
})
|
|
1197
|
+
);
|
|
1198
|
+
return { records, skipped };
|
|
1199
|
+
}
|
|
1200
|
+
async function changesFor(input, event, state, files, read) {
|
|
1201
|
+
let snapshots = state?.snapshots ?? /* @__PURE__ */ new Map();
|
|
1202
|
+
let fallbackCommit = state?.startCommit ?? null;
|
|
1203
|
+
if (event.kind === "verify" && event.baseCommit) {
|
|
1204
|
+
fallbackCommit = event.baseCommit;
|
|
1205
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
1206
|
+
}
|
|
1207
|
+
const changes = await computeChanges(input.root, files, { snapshots, fallbackCommit }, read);
|
|
1208
|
+
const kept = event.kind === "verify" && state !== null && !event.baseCommit ? files.filter((file) => changes.sets.get(file)?.changedLines.length !== 0) : [...files];
|
|
1209
|
+
const fingerprints = new Map(
|
|
1210
|
+
[...state?.fingerprints ?? []].map(([file, byRule]) => [file, new Map(byRule)])
|
|
1211
|
+
);
|
|
1212
|
+
if (event.kind !== "verify" || changes.fromCommit.size === 0) {
|
|
1213
|
+
return { files: kept, changes, fingerprints, records: [] };
|
|
1214
|
+
}
|
|
1215
|
+
const texts = changes.fromCommit;
|
|
1216
|
+
const missing = [...texts.keys()].filter((file) => !fingerprints.has(file));
|
|
1217
|
+
const records = missing.length === 0 ? [] : await recordFingerprints({
|
|
1218
|
+
detection: input.detection,
|
|
1219
|
+
files: missing,
|
|
1220
|
+
rules: input.rules,
|
|
1221
|
+
disabled: input.disabled,
|
|
1222
|
+
read: async (file) => texts.get(file) ?? null,
|
|
1223
|
+
event: "verify",
|
|
1224
|
+
source: "memory",
|
|
1225
|
+
timeoutMs: input.limits.verifyMs
|
|
1226
|
+
});
|
|
1227
|
+
return { files: kept, changes, fingerprints: mergeFingerprints(fingerprints, records), records };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1061
1230
|
// src/core/delivery/persist.ts
|
|
1062
|
-
import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1063
|
-
import
|
|
1231
|
+
import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1232
|
+
import path9 from "path";
|
|
1233
|
+
var AGENT_DIR = ".rulecast";
|
|
1234
|
+
function agentDir(root) {
|
|
1235
|
+
const dir = path9.join(root, AGENT_DIR);
|
|
1236
|
+
mkdirSync2(dir, { recursive: true });
|
|
1237
|
+
const ignore = path9.join(dir, ".gitignore");
|
|
1238
|
+
if (!existsSync3(ignore)) writeFileSync2(ignore, "# Written by rulecast: files its hooks point an agent at.\n*\n");
|
|
1239
|
+
return dir;
|
|
1240
|
+
}
|
|
1241
|
+
function copyIntoProject(root, home, file) {
|
|
1242
|
+
const rest = path9.relative(home, file);
|
|
1243
|
+
if (rest.startsWith("..") || path9.isAbsolute(rest)) return null;
|
|
1244
|
+
try {
|
|
1245
|
+
const target = path9.join(agentDir(root), rest);
|
|
1246
|
+
mkdirSync2(path9.dirname(target), { recursive: true });
|
|
1247
|
+
copyFileSync(file, target);
|
|
1248
|
+
return path9.join(AGENT_DIR, rest).split(path9.sep).join("/");
|
|
1249
|
+
} catch {
|
|
1250
|
+
return null;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1064
1253
|
var KEEP = 3;
|
|
1065
1254
|
var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
|
|
1066
1255
|
var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
@@ -1076,15 +1265,14 @@ function markdown(delivery, now) {
|
|
|
1076
1265
|
""
|
|
1077
1266
|
].join("\n");
|
|
1078
1267
|
}
|
|
1079
|
-
function writeOverflow(
|
|
1268
|
+
function writeOverflow(dir, session, delivery, now = /* @__PURE__ */ new Date()) {
|
|
1080
1269
|
const prefix = safeSegment2(session ?? "no-session");
|
|
1081
|
-
const
|
|
1082
|
-
const file = path8.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
|
|
1270
|
+
const file = path9.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
|
|
1083
1271
|
try {
|
|
1084
1272
|
mkdirSync2(dir, { recursive: true });
|
|
1085
1273
|
writeFileSync2(file, markdown(delivery, now));
|
|
1086
1274
|
const mine = readdirSync(dir).filter((name) => name.startsWith(`${prefix}-`)).sort();
|
|
1087
|
-
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(
|
|
1275
|
+
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path9.join(dir, old), { force: true });
|
|
1088
1276
|
return file;
|
|
1089
1277
|
} catch {
|
|
1090
1278
|
return null;
|
|
@@ -1102,16 +1290,16 @@ async function resolveRuleContext(resolver, context) {
|
|
|
1102
1290
|
}
|
|
1103
1291
|
function createReferenceResolver(root) {
|
|
1104
1292
|
const texts = /* @__PURE__ */ new Map();
|
|
1105
|
-
const read = (
|
|
1106
|
-
let text = texts.get(
|
|
1293
|
+
const read = (path16) => {
|
|
1294
|
+
let text = texts.get(path16);
|
|
1107
1295
|
if (!text) {
|
|
1108
|
-
text = readSourceFile(root,
|
|
1109
|
-
texts.set(
|
|
1296
|
+
text = readSourceFile(root, path16);
|
|
1297
|
+
texts.set(path16, text);
|
|
1110
1298
|
}
|
|
1111
1299
|
return text;
|
|
1112
1300
|
};
|
|
1113
|
-
const contentOf = async (
|
|
1114
|
-
const text = await read(
|
|
1301
|
+
const contentOf = async (path16, anchor) => {
|
|
1302
|
+
const text = await read(path16);
|
|
1115
1303
|
if (text === null) return null;
|
|
1116
1304
|
if (anchor === null) return text.trimEnd();
|
|
1117
1305
|
const section = sectionRange(text, anchor);
|
|
@@ -1123,19 +1311,108 @@ function createReferenceResolver(root) {
|
|
|
1123
1311
|
if (content === null) return { spec, found: false };
|
|
1124
1312
|
return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
|
|
1125
1313
|
},
|
|
1126
|
-
async currentHash(
|
|
1127
|
-
const content = await contentOf(
|
|
1314
|
+
async currentHash(path16, anchor) {
|
|
1315
|
+
const content = await contentOf(path16, anchor);
|
|
1128
1316
|
return content === null ? null : fnv1a(content);
|
|
1129
1317
|
},
|
|
1130
|
-
async contains(
|
|
1318
|
+
async contains(path16, parent, child) {
|
|
1131
1319
|
if (parent === null) return true;
|
|
1132
1320
|
if (child === null) return false;
|
|
1133
|
-
const text = await read(
|
|
1321
|
+
const text = await read(path16);
|
|
1134
1322
|
return text !== null && sectionContains(text, parent, child);
|
|
1135
1323
|
}
|
|
1136
1324
|
};
|
|
1137
1325
|
}
|
|
1138
1326
|
|
|
1327
|
+
// src/core/detection/context.ts
|
|
1328
|
+
function detectionFor(project, registry, stateDir, resolver) {
|
|
1329
|
+
return {
|
|
1330
|
+
root: project.root,
|
|
1331
|
+
registry,
|
|
1332
|
+
settings: { llm: project.config.llm },
|
|
1333
|
+
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1334
|
+
contextFor: (rule) => resolveRuleContext(resolver, rule.context)
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/core/detection/scratch.ts
|
|
1339
|
+
import { mkdir as mkdir4, mkdtemp, rm as rm3, writeFile } from "fs/promises";
|
|
1340
|
+
import path10 from "path";
|
|
1341
|
+
async function withScratchTree(root, prefix, entries, fn) {
|
|
1342
|
+
const dir = path10.basename(await mkdtemp(path10.join(root, prefix)));
|
|
1343
|
+
try {
|
|
1344
|
+
for (const [file, content] of entries) {
|
|
1345
|
+
const full = path10.join(root, dir, file);
|
|
1346
|
+
await mkdir4(path10.dirname(full), { recursive: true });
|
|
1347
|
+
await writeFile(full, content);
|
|
1348
|
+
}
|
|
1349
|
+
return await fn(dir);
|
|
1350
|
+
} finally {
|
|
1351
|
+
await rm3(path10.join(root, dir), { recursive: true, force: true });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/core/detection/materialise.ts
|
|
1356
|
+
var STAGED_DIR = ".rulecast-staged-";
|
|
1357
|
+
async function runDetectionFrom(input, source) {
|
|
1358
|
+
if (source.kind === "worktree") return runDetection(input);
|
|
1359
|
+
const registry = input.detection.registry;
|
|
1360
|
+
const direct = [];
|
|
1361
|
+
const copied = [];
|
|
1362
|
+
for (const selection of input.selections) {
|
|
1363
|
+
const detector = registry.get(selection.rule.detector.kind);
|
|
1364
|
+
const readsContent = detector?.guards === true || detector?.takesContent?.(selection.rule.detector.config) === true;
|
|
1365
|
+
if (readsContent || detector === void 0) direct.push(selection);
|
|
1366
|
+
else copied.push(selection);
|
|
1367
|
+
}
|
|
1368
|
+
const outputs = await Promise.all([
|
|
1369
|
+
direct.length === 0 ? null : runDetection({ ...input, selections: direct, fromDisk: false }),
|
|
1370
|
+
copied.length === 0 ? null : fromScratch(input, copied)
|
|
1371
|
+
]);
|
|
1372
|
+
return merge(outputs.filter((output) => output !== null));
|
|
1373
|
+
}
|
|
1374
|
+
async function fromScratch(input, selections) {
|
|
1375
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1376
|
+
for (const file of new Set(selections.flatMap((selection) => selection.files))) {
|
|
1377
|
+
const text = await input.read(file);
|
|
1378
|
+
if (text !== null) entries.set(file, text);
|
|
1379
|
+
}
|
|
1380
|
+
if (entries.size === 0) return { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
|
|
1381
|
+
return withScratchTree(input.detection.root, STAGED_DIR, entries, async (dir) => {
|
|
1382
|
+
const prefix = `${dir}/`;
|
|
1383
|
+
const strip = (file) => file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
1384
|
+
const output = await runDetection({
|
|
1385
|
+
...input,
|
|
1386
|
+
selections: selections.map((selection) => ({
|
|
1387
|
+
...selection,
|
|
1388
|
+
files: selection.files.filter((file) => entries.has(file)).map((file) => prefix + file)
|
|
1389
|
+
})).filter((selection) => selection.files.length > 0),
|
|
1390
|
+
changes: new Map([...input.changes].map(([file, change]) => [prefix + file, change])),
|
|
1391
|
+
// Anything reading through `read` under a scratch path is asking about the real file.
|
|
1392
|
+
read: (file) => input.read(strip(file))
|
|
1393
|
+
});
|
|
1394
|
+
return {
|
|
1395
|
+
findings: output.findings.map(({ rule, match }) => ({ rule, match: { ...match, file: strip(match.file) } })),
|
|
1396
|
+
errors: output.errors.map((error) => ({ ...error, message: error.message.replaceAll(prefix, "") })),
|
|
1397
|
+
timedOut: output.timedOut,
|
|
1398
|
+
ignored: output.ignored.map((ignored) => ({
|
|
1399
|
+
...ignored,
|
|
1400
|
+
match: { ...ignored.match, file: strip(ignored.match.file) }
|
|
1401
|
+
})),
|
|
1402
|
+
warnings: output.warnings.map((warning) => warning.replaceAll(prefix, ""))
|
|
1403
|
+
};
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
function merge(outputs) {
|
|
1407
|
+
return {
|
|
1408
|
+
findings: outputs.flatMap((output) => output.findings),
|
|
1409
|
+
errors: outputs.flatMap((output) => output.errors),
|
|
1410
|
+
timedOut: outputs.flatMap((output) => output.timedOut),
|
|
1411
|
+
ignored: outputs.flatMap((output) => output.ignored),
|
|
1412
|
+
warnings: outputs.flatMap((output) => output.warnings)
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1139
1416
|
// src/core/detection/proposal.ts
|
|
1140
1417
|
function propose(current, intent) {
|
|
1141
1418
|
if (intent.content !== void 0) {
|
|
@@ -1161,8 +1438,8 @@ function propose(current, intent) {
|
|
|
1161
1438
|
}
|
|
1162
1439
|
return { content: content + rest, inserts };
|
|
1163
1440
|
}
|
|
1164
|
-
function isInserted(proposal, match,
|
|
1165
|
-
if (
|
|
1441
|
+
function isInserted(proposal, match, wholeFile) {
|
|
1442
|
+
if (wholeFile) return true;
|
|
1166
1443
|
const starts = lineStarts(proposal.content);
|
|
1167
1444
|
const lineStart = starts[match.line - 1];
|
|
1168
1445
|
if (lineStart === void 0) return false;
|
|
@@ -1172,7 +1449,7 @@ function isInserted(proposal, match, detector) {
|
|
|
1172
1449
|
}
|
|
1173
1450
|
|
|
1174
1451
|
// src/core/session/decide.ts
|
|
1175
|
-
import
|
|
1452
|
+
import path11 from "path";
|
|
1176
1453
|
|
|
1177
1454
|
// src/core/session/state.ts
|
|
1178
1455
|
function preexistingKey(rule, file) {
|
|
@@ -1180,7 +1457,20 @@ function preexistingKey(rule, file) {
|
|
|
1180
1457
|
}
|
|
1181
1458
|
var refusalKey = preexistingKey;
|
|
1182
1459
|
function emptyWork() {
|
|
1183
|
-
return {
|
|
1460
|
+
return {
|
|
1461
|
+
edited: [],
|
|
1462
|
+
stopBlocks: /* @__PURE__ */ new Map(),
|
|
1463
|
+
refusals: /* @__PURE__ */ new Map(),
|
|
1464
|
+
disabled: /* @__PURE__ */ new Map(),
|
|
1465
|
+
accessed: /* @__PURE__ */ new Map(),
|
|
1466
|
+
noticed: /* @__PURE__ */ new Set(),
|
|
1467
|
+
editedVia: /* @__PURE__ */ new Map(),
|
|
1468
|
+
swept: [],
|
|
1469
|
+
latestTree: /* @__PURE__ */ new Map(),
|
|
1470
|
+
sessionLatestTree: null,
|
|
1471
|
+
beforeTrees: /* @__PURE__ */ new Map(),
|
|
1472
|
+
startTree: null
|
|
1473
|
+
};
|
|
1184
1474
|
}
|
|
1185
1475
|
function emptyContext() {
|
|
1186
1476
|
return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
|
|
@@ -1191,6 +1481,19 @@ function foldWork(records) {
|
|
|
1191
1481
|
switch (record.t) {
|
|
1192
1482
|
case "edited":
|
|
1193
1483
|
state.edited = [...state.edited.filter((file) => file !== record.file), record.file];
|
|
1484
|
+
state.editedVia.set(record.file, record.via ?? "tool");
|
|
1485
|
+
state.swept = state.swept.filter((file) => file !== record.file);
|
|
1486
|
+
break;
|
|
1487
|
+
case "swept":
|
|
1488
|
+
state.swept = [...state.swept.filter((file) => file !== record.file), record.file];
|
|
1489
|
+
break;
|
|
1490
|
+
case "tree":
|
|
1491
|
+
state.latestTree.set(record.agent, record.state);
|
|
1492
|
+
state.sessionLatestTree = record.state;
|
|
1493
|
+
if (record.phase === "before" && record.toolUseId !== void 0) {
|
|
1494
|
+
state.beforeTrees.set(record.toolUseId, record.state);
|
|
1495
|
+
}
|
|
1496
|
+
if (record.phase === "start" && state.startTree === null) state.startTree = record.state;
|
|
1194
1497
|
break;
|
|
1195
1498
|
case "accessed": {
|
|
1196
1499
|
const files = state.accessed.get(record.agent) ?? [];
|
|
@@ -1211,10 +1514,20 @@ function foldWork(records) {
|
|
|
1211
1514
|
case "disabled":
|
|
1212
1515
|
if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
|
|
1213
1516
|
break;
|
|
1517
|
+
case "noticed":
|
|
1518
|
+
state.noticed.add(record.key);
|
|
1519
|
+
break;
|
|
1214
1520
|
}
|
|
1215
1521
|
}
|
|
1216
1522
|
return state;
|
|
1217
1523
|
}
|
|
1524
|
+
function referenceState(work, agent, toolUseId) {
|
|
1525
|
+
const before = toolUseId === void 0 ? void 0 : work.beforeTrees.get(toolUseId);
|
|
1526
|
+
return before ?? work.latestTree.get(agent) ?? work.sessionLatestTree;
|
|
1527
|
+
}
|
|
1528
|
+
function dirtyAtStart(work, file) {
|
|
1529
|
+
return work.startTree !== null && file in work.startTree.entries;
|
|
1530
|
+
}
|
|
1218
1531
|
function foldContext(records) {
|
|
1219
1532
|
let state = emptyContext();
|
|
1220
1533
|
for (const record of records) {
|
|
@@ -1240,15 +1553,10 @@ function foldContext(records) {
|
|
|
1240
1553
|
}
|
|
1241
1554
|
|
|
1242
1555
|
// src/core/session/decide.ts
|
|
1243
|
-
var ITEM_OVERHEAD = 64;
|
|
1244
|
-
var HEADER_OVERHEAD = 128;
|
|
1245
|
-
var OVERFLOW_NOTICE = 256;
|
|
1246
|
-
var REFERENCE_OVERHEAD = 96;
|
|
1247
|
-
var BACKLOG_HEADING = "backlog in files you touched (not from your edit):";
|
|
1248
1556
|
var WARNING_SHARE = 0.15;
|
|
1249
1557
|
var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
|
|
1250
1558
|
function locationOf(spec) {
|
|
1251
|
-
return
|
|
1559
|
+
return path11.isAbsolute(spec.path) ? { location: spec.path } : {};
|
|
1252
1560
|
}
|
|
1253
1561
|
function capturesFor(rule, match) {
|
|
1254
1562
|
const names = templateBindings(rule.message ?? "");
|
|
@@ -1311,37 +1619,43 @@ async function isCovered(spec, delivered, resolver) {
|
|
|
1311
1619
|
}
|
|
1312
1620
|
return false;
|
|
1313
1621
|
}
|
|
1314
|
-
async function
|
|
1622
|
+
async function assemble(input) {
|
|
1315
1623
|
const delivery = emptyDelivery();
|
|
1316
|
-
|
|
1624
|
+
if (input.via !== void 0) delivery.via = input.via;
|
|
1317
1625
|
const context = [];
|
|
1626
|
+
const touches = input.touches ?? [];
|
|
1627
|
+
const state = input.context ?? emptyContext();
|
|
1318
1628
|
const fresh = input.findings.filter((finding) => finding.status === "new");
|
|
1319
1629
|
delivery.findings = renderFindings(fresh);
|
|
1320
|
-
const
|
|
1630
|
+
const swept = new Set(input.swept ?? []);
|
|
1631
|
+
const sweptWithFindings = [...new Set(fresh.map(({ match }) => match.file))].filter((file) => swept.has(file));
|
|
1632
|
+
if (sweptWithFindings.length > 0) delivery.swept = sweptWithFindings.sort();
|
|
1633
|
+
if ((input.refused ?? []).length > 0) delivery.refused = [...input.refused ?? []];
|
|
1321
1634
|
for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
|
|
1322
1635
|
const summaries = /* @__PURE__ */ new Map();
|
|
1323
1636
|
for (const { rule, match, status } of input.findings) {
|
|
1324
1637
|
if (status !== "preexisting") continue;
|
|
1325
1638
|
const key = preexistingKey(rule.id, match.file);
|
|
1326
|
-
if (
|
|
1639
|
+
if (state.preexisting.has(key)) continue;
|
|
1327
1640
|
const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
|
|
1328
1641
|
summary.count++;
|
|
1329
1642
|
summaries.set(key, summary);
|
|
1330
1643
|
}
|
|
1331
1644
|
delivery.preexistingSummary = [...summaries.values()];
|
|
1332
|
-
const unwarned = input.warnings.filter((warning) => !
|
|
1645
|
+
const unwarned = (input.warnings ?? []).filter((warning) => !state.warned.has(warning.key));
|
|
1333
1646
|
delivery.warnings = unwarned.map((warning) => warning.text);
|
|
1334
|
-
delivery.touches =
|
|
1335
|
-
for (const rule of
|
|
1336
|
-
const delivered = [...
|
|
1337
|
-
|
|
1338
|
-
|
|
1647
|
+
delivery.touches = touches.map((rule) => rule.id);
|
|
1648
|
+
for (const rule of touches) context.push({ t: "touched", rule: rule.id });
|
|
1649
|
+
const delivered = [...state.delivered];
|
|
1650
|
+
const agentRead = input.agentRead ?? null;
|
|
1651
|
+
if (agentRead !== null) {
|
|
1652
|
+
const hash = await input.resolver.currentHash(agentRead, null);
|
|
1339
1653
|
if (hash !== null) {
|
|
1340
|
-
delivered.push({ path:
|
|
1341
|
-
context.push({ t: "delivered", path:
|
|
1654
|
+
delivered.push({ path: agentRead, anchor: null, hash });
|
|
1655
|
+
context.push({ t: "delivered", path: agentRead, anchor: null, hash });
|
|
1342
1656
|
}
|
|
1343
1657
|
}
|
|
1344
|
-
const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...
|
|
1658
|
+
const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...touches];
|
|
1345
1659
|
const candidates = [];
|
|
1346
1660
|
for (const spec of referencesInOrder(ruleOrder)) {
|
|
1347
1661
|
const resolved = await input.resolver.resolve(spec);
|
|
@@ -1358,47 +1672,72 @@ async function decide(input) {
|
|
|
1358
1672
|
delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
|
|
1359
1673
|
}
|
|
1360
1674
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1675
|
+
return { delivery, candidates, fresh, unwarned, touches, context };
|
|
1676
|
+
}
|
|
1677
|
+
function trim(assembled, limits) {
|
|
1678
|
+
const { candidates, unwarned, touches } = assembled;
|
|
1679
|
+
const delivery = {
|
|
1680
|
+
...assembled.delivery,
|
|
1681
|
+
findings: [...assembled.delivery.findings],
|
|
1682
|
+
preexistingSummary: [...assembled.delivery.preexistingSummary],
|
|
1683
|
+
references: [...assembled.delivery.references],
|
|
1684
|
+
warnings: [...assembled.delivery.warnings],
|
|
1369
1685
|
omitted: { findings: [], rules: 0, preexisting: 0 }
|
|
1370
1686
|
};
|
|
1687
|
+
const context = [];
|
|
1688
|
+
const rulesById = new Map(assembled.fresh.map(({ rule }) => [rule.id, rule]));
|
|
1689
|
+
const limit = limits.maxContextChars;
|
|
1690
|
+
const render = { maxMatchesPerRule: limits.maxMatchesPerRule };
|
|
1691
|
+
const swept = new Set(delivery.swept ?? []);
|
|
1692
|
+
const blockOf = (finding) => swept.has(finding.file) ? `${SWEPT_BLOCK}${finding.rule}` : finding.rule;
|
|
1693
|
+
const ruleOf = (block) => block.startsWith(SWEPT_BLOCK) ? block.slice(SWEPT_BLOCK.length) : block;
|
|
1371
1694
|
const byRule = /* @__PURE__ */ new Map();
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1695
|
+
const ordered = [
|
|
1696
|
+
...delivery.findings.filter((finding) => !swept.has(finding.file)),
|
|
1697
|
+
...delivery.findings.filter((finding) => swept.has(finding.file))
|
|
1698
|
+
];
|
|
1699
|
+
for (const finding of ordered) {
|
|
1700
|
+
const group = byRule.get(blockOf(finding));
|
|
1701
|
+
if (group === void 0) byRule.set(blockOf(finding), [finding]);
|
|
1375
1702
|
else group.push(finding);
|
|
1376
1703
|
}
|
|
1377
|
-
|
|
1704
|
+
const conventions = delivery.references.length > 0 || delivery.preexistingSummary.length > 0;
|
|
1705
|
+
let used = deliveryCost.header(
|
|
1706
|
+
byRule.size,
|
|
1707
|
+
new Set(delivery.findings.map((finding) => finding.file)),
|
|
1708
|
+
conventions,
|
|
1709
|
+
delivery.via
|
|
1710
|
+
) + (delivery.references.length > 0 ? deliveryCost.referencesEnd : 0) + (swept.size > 0 ? deliveryCost.sweptFrame : 0) + // Never cut: what to undo is the one thing the agent must not miss.
|
|
1711
|
+
(delivery.refused ?? []).reduce((sum, refused) => sum + deliveryCost.refused(refused), 0);
|
|
1378
1712
|
const fits = (size) => limit === null || used + size <= limit;
|
|
1379
1713
|
const kept = /* @__PURE__ */ new Map();
|
|
1380
|
-
const
|
|
1714
|
+
const specs = /* @__PURE__ */ new Map();
|
|
1715
|
+
for (const rule of [...rulesById.values(), ...touches]) for (const spec of rule.context) specs.set(spec.ref, spec);
|
|
1716
|
+
const lineCost = (ref) => {
|
|
1717
|
+
const spec = specs.get(ref);
|
|
1718
|
+
return deliveryCost.referenceLine(ref, spec === void 0 ? void 0 : locationOf(spec).location);
|
|
1719
|
+
};
|
|
1381
1720
|
const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
|
|
1382
1721
|
const charged = /* @__PURE__ */ new Set();
|
|
1383
1722
|
const orphaned = /* @__PURE__ */ new Set();
|
|
1384
1723
|
const floorWithin = (cap) => {
|
|
1385
|
-
const spent = { chars: 0, kept: /* @__PURE__ */ new Map(), refs: /* @__PURE__ */ new Set(), dropped:
|
|
1724
|
+
const spent = { chars: 0, kept: /* @__PURE__ */ new Map(), refs: /* @__PURE__ */ new Set(), dropped: /* @__PURE__ */ new Set() };
|
|
1386
1725
|
const uncharged = (rule) => [
|
|
1387
1726
|
...new Set(rule.context.map((spec) => spec.ref).filter((ref) => resolvedRefs.has(ref) && !spent.refs.has(ref)))
|
|
1388
1727
|
];
|
|
1389
1728
|
for (const [id, findings] of byRule) {
|
|
1390
|
-
const rule = rulesById.get(id);
|
|
1729
|
+
const rule = rulesById.get(ruleOf(id));
|
|
1391
1730
|
const refs = rule === void 0 ? [] : uncharged(rule);
|
|
1392
|
-
const size = measureRuleBlock(id, [findings[0]], delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
|
|
1731
|
+
const size = measureRuleBlock(ruleOf(id), [findings[0]], findings, delivery.templates[ruleOf(id)], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
|
|
1393
1732
|
if (used + spent.chars + size > cap) {
|
|
1394
|
-
spent.dropped
|
|
1733
|
+
spent.dropped.add(id);
|
|
1395
1734
|
continue;
|
|
1396
1735
|
}
|
|
1397
1736
|
spent.chars += size;
|
|
1398
1737
|
for (const ref of refs) spent.refs.add(ref);
|
|
1399
1738
|
spent.kept.set(id, [findings[0]]);
|
|
1400
1739
|
}
|
|
1401
|
-
for (const touch of
|
|
1740
|
+
for (const touch of touches) {
|
|
1402
1741
|
for (const ref of uncharged(touch)) {
|
|
1403
1742
|
spent.chars += lineCost(ref);
|
|
1404
1743
|
spent.refs.add(ref);
|
|
@@ -1406,21 +1745,25 @@ async function decide(input) {
|
|
|
1406
1745
|
}
|
|
1407
1746
|
return spent;
|
|
1408
1747
|
};
|
|
1748
|
+
let droppedBlocks = 0;
|
|
1409
1749
|
if (limit !== null) {
|
|
1410
1750
|
let floor = floorWithin(limit);
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1751
|
+
const notice = deliveryCost.overflowNotice(byRule.size);
|
|
1752
|
+
if (floor.dropped.size > 0) floor = floorWithin(limit - notice);
|
|
1753
|
+
used += floor.chars + (floor.dropped.size > 0 ? notice : 0);
|
|
1754
|
+
const keptRules = new Set([...floor.kept.keys()].map(ruleOf));
|
|
1755
|
+
droppedBlocks = floor.dropped.size;
|
|
1756
|
+
delivery.omitted.rules = new Set([...floor.dropped].map(ruleOf).filter((rule) => !keptRules.has(rule))).size;
|
|
1414
1757
|
for (const [id, findings] of floor.kept) kept.set(id, findings);
|
|
1415
1758
|
for (const ref of floor.refs) charged.add(ref);
|
|
1416
1759
|
for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
|
|
1417
1760
|
}
|
|
1418
|
-
const warningCost = (text) => text
|
|
1761
|
+
const warningCost = (text) => deliveryCost.warning(text);
|
|
1419
1762
|
if (limit === null) {
|
|
1420
1763
|
for (const warning of unwarned) context.push({ t: "warned", key: warning.key });
|
|
1421
1764
|
} else {
|
|
1422
1765
|
const ceiling = Math.max(0, Math.min(Math.floor(limit * WARNING_SHARE), limit - used));
|
|
1423
|
-
let spent = 0;
|
|
1766
|
+
let spent = unwarned.length > 0 ? deliveryCost.warningsFrame : 0;
|
|
1424
1767
|
let held = 0;
|
|
1425
1768
|
while (held < unwarned.length && spent + warningCost(unwarned[held].text) <= ceiling) {
|
|
1426
1769
|
spent += warningCost(unwarned[held].text);
|
|
@@ -1439,10 +1782,10 @@ async function decide(input) {
|
|
|
1439
1782
|
spent += warningCost(moreWarnings(cut));
|
|
1440
1783
|
delivery.warnings.push(moreWarnings(cut));
|
|
1441
1784
|
}
|
|
1442
|
-
used += spent;
|
|
1785
|
+
used += delivery.warnings.length > 0 ? spent : 0;
|
|
1443
1786
|
}
|
|
1444
1787
|
for (const { index, resolved } of candidates) {
|
|
1445
|
-
const size = resolved.content.
|
|
1788
|
+
const size = deliveryCost.referenceContent(resolved.spec.ref, resolved.content, locationOf(resolved.spec).location);
|
|
1446
1789
|
if (orphaned.has(resolved.spec.ref)) continue;
|
|
1447
1790
|
if (!fits(size)) {
|
|
1448
1791
|
delivery.references[index] = {
|
|
@@ -1456,14 +1799,16 @@ async function decide(input) {
|
|
|
1456
1799
|
used += size;
|
|
1457
1800
|
context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
|
|
1458
1801
|
}
|
|
1459
|
-
|
|
1802
|
+
const frame = delivery.preexistingSummary.length > 0 ? deliveryCost.backlogFrame(delivery.preexistingSummary.length) : 0;
|
|
1803
|
+
if (frame > 0 && !fits(frame)) delivery.preexistingSummary = [];
|
|
1804
|
+
else used += frame;
|
|
1460
1805
|
const summariesKept = [];
|
|
1461
1806
|
for (const summary of delivery.preexistingSummary) {
|
|
1462
|
-
if (!fits(
|
|
1807
|
+
if (!fits(deliveryCost.backlogSummary(summary))) {
|
|
1463
1808
|
delivery.omitted.preexisting++;
|
|
1464
1809
|
continue;
|
|
1465
1810
|
}
|
|
1466
|
-
used +=
|
|
1811
|
+
used += deliveryCost.backlogSummary(summary);
|
|
1467
1812
|
summariesKept.push(summary);
|
|
1468
1813
|
context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
|
|
1469
1814
|
}
|
|
@@ -1474,9 +1819,9 @@ async function decide(input) {
|
|
|
1474
1819
|
added = false;
|
|
1475
1820
|
for (const [rule, findings] of byRule) {
|
|
1476
1821
|
const shown = kept.get(rule);
|
|
1477
|
-
if (shown === void 0 || shown.length >= Math.min(findings.length,
|
|
1478
|
-
const template = delivery.templates[rule];
|
|
1479
|
-
const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
|
|
1822
|
+
if (shown === void 0 || shown.length >= Math.min(findings.length, limits.maxMatchesPerRule)) continue;
|
|
1823
|
+
const template = delivery.templates[ruleOf(rule)];
|
|
1824
|
+
const delta = measureRuleBlock(ruleOf(rule), [...shown, findings[shown.length]], findings, template, render) - measureRuleBlock(ruleOf(rule), shown, findings, template, render);
|
|
1480
1825
|
if (!fits(delta)) continue;
|
|
1481
1826
|
used += delta;
|
|
1482
1827
|
shown.push(findings[shown.length]);
|
|
@@ -1489,27 +1834,38 @@ async function decide(input) {
|
|
|
1489
1834
|
const shownFiles = new Set(shown.map((finding) => finding.file));
|
|
1490
1835
|
const dropped = findings.slice(shown.length);
|
|
1491
1836
|
delivery.omitted.findings.push({
|
|
1492
|
-
rule,
|
|
1837
|
+
rule: ruleOf(rule),
|
|
1493
1838
|
count: dropped.length,
|
|
1494
|
-
files: new Set(dropped.map((finding) => finding.file).filter((file) => !shownFiles.has(file))).size
|
|
1839
|
+
files: new Set(dropped.map((finding) => finding.file).filter((file) => !shownFiles.has(file))).size,
|
|
1840
|
+
...rule === ruleOf(rule) ? {} : { swept: true }
|
|
1495
1841
|
});
|
|
1496
1842
|
}
|
|
1497
|
-
delivery.findings = delivery.findings.filter((finding) => kept.get(finding
|
|
1843
|
+
delivery.findings = delivery.findings.filter((finding) => kept.get(blockOf(finding))?.includes(finding) === true);
|
|
1498
1844
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
} else delivery.stop = "capReached";
|
|
1507
|
-
if (delivery.stop === "allow") return { delivery, overflow: null, work, context: [] };
|
|
1508
|
-
if (delivery.stop === "capReached") {
|
|
1509
|
-
return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context: [] };
|
|
1510
|
-
}
|
|
1845
|
+
return { delivery, overflow: droppedBlocks > 0 ? assembled.delivery : null, context };
|
|
1846
|
+
}
|
|
1847
|
+
function gate(fresh, work, agent, maxBlocks, swept = /* @__PURE__ */ new Set()) {
|
|
1848
|
+
const blocking = fresh.filter(({ match }) => !swept.has(match.file));
|
|
1849
|
+
const refused = ({ rule, match }) => rule.refuseWrite && work.editedVia.get(match.file) === "shell";
|
|
1850
|
+
if (!blocking.some((finding) => finding.rule.severity === "error" || refused(finding))) {
|
|
1851
|
+
return { stop: "allow", work: [] };
|
|
1511
1852
|
}
|
|
1512
|
-
|
|
1853
|
+
if ((work.stopBlocks.get(agent) ?? 0) < maxBlocks) return { stop: "block", work: [{ t: "stopBlock", agent }] };
|
|
1854
|
+
return { stop: "capReached", work: [] };
|
|
1855
|
+
}
|
|
1856
|
+
async function decide(input) {
|
|
1857
|
+
const assembled = await assemble(input);
|
|
1858
|
+
const trimmed = trim(assembled, {
|
|
1859
|
+
maxContextChars: input.maxContextChars,
|
|
1860
|
+
maxMatchesPerRule: input.maxMatchesPerRule
|
|
1861
|
+
});
|
|
1862
|
+
const context = [...assembled.context, ...trimmed.context];
|
|
1863
|
+
if (!input.stopGate) return { delivery: trimmed.delivery, overflow: trimmed.overflow, work: [], context };
|
|
1864
|
+
const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks, new Set(input.swept ?? []));
|
|
1865
|
+
const delivery = { ...trimmed.delivery, stop: gated.stop };
|
|
1866
|
+
if (gated.stop === "allow") return { delivery, overflow: null, work: gated.work, context: [] };
|
|
1867
|
+
if (gated.stop === "capReached") return { delivery, overflow: trimmed.overflow, work: gated.work, context: [] };
|
|
1868
|
+
return { delivery, overflow: trimmed.overflow, work: gated.work, context };
|
|
1513
1869
|
}
|
|
1514
1870
|
|
|
1515
1871
|
// src/core/guard.ts
|
|
@@ -1519,7 +1875,7 @@ async function guardWrite(input) {
|
|
|
1519
1875
|
if (file === void 0 || intent === void 0) return emptyDelivery();
|
|
1520
1876
|
const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
|
|
1521
1877
|
if (rules.length === 0) return emptyDelivery();
|
|
1522
|
-
const proposal = propose(await readSourceFile(input.root, file), intent);
|
|
1878
|
+
const proposal = propose(await readSourceFile(input.detection.root, file), intent);
|
|
1523
1879
|
if (proposal === null) {
|
|
1524
1880
|
input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
|
|
1525
1881
|
return emptyDelivery();
|
|
@@ -1532,19 +1888,18 @@ async function guardWrite(input) {
|
|
|
1532
1888
|
const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
|
|
1533
1889
|
if (selections.length === 0) return emptyDelivery();
|
|
1534
1890
|
const output = await runDetection({
|
|
1535
|
-
|
|
1891
|
+
detection: input.detection,
|
|
1536
1892
|
event: "edit",
|
|
1537
1893
|
selections,
|
|
1538
1894
|
changes: /* @__PURE__ */ new Map(),
|
|
1539
1895
|
// The proposed file for the one being written; anything else a detector reaches for is real.
|
|
1540
|
-
read: async (name) => name === file ? proposal.content : readSourceFile(input.root, name),
|
|
1541
|
-
registry: input.registry,
|
|
1542
|
-
cacheFor: (kind) => diskCache(detectorCacheDir(input.stateDir, kind)),
|
|
1543
|
-
contextFor: (rule) => resolveRuleContext(input.resolver, rule.context),
|
|
1544
|
-
settings: { llm: input.config.llm },
|
|
1896
|
+
read: async (name) => name === file ? proposal.content : readSourceFile(input.detection.root, name),
|
|
1545
1897
|
timeoutMs: input.config.timeouts.editDeadlineMs
|
|
1546
1898
|
});
|
|
1547
|
-
const
|
|
1899
|
+
const wholeFile = (kind) => input.detection.registry.get(kind)?.wholeFile === true;
|
|
1900
|
+
const evidence = output.findings.filter(
|
|
1901
|
+
({ rule, match }) => isInserted(proposal, match, wholeFile(rule.detector.kind))
|
|
1902
|
+
);
|
|
1548
1903
|
if (evidence.length === 0) return emptyDelivery();
|
|
1549
1904
|
const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
|
|
1550
1905
|
const refusing = evidence.filter(({ rule }) => !spent(rule.id));
|
|
@@ -1552,41 +1907,72 @@ async function guardWrite(input) {
|
|
|
1552
1907
|
input.log(`guard: ${file} has spent its refusals; allowing the write`);
|
|
1553
1908
|
return emptyDelivery();
|
|
1554
1909
|
}
|
|
1555
|
-
const
|
|
1556
|
-
agent: "main",
|
|
1910
|
+
const assembled = await assemble({
|
|
1557
1911
|
findings: refusing.map(({ rule, match }) => ({ rule, match, status: "new" })),
|
|
1558
|
-
touches: [],
|
|
1559
|
-
agentRead: null,
|
|
1560
|
-
warnings: [],
|
|
1561
|
-
work: input.work,
|
|
1562
|
-
// An empty context, so the sections come with the refusal even if they were delivered earlier:
|
|
1563
|
-
// the message has to stand on its own, and nothing here is recorded as delivered because the
|
|
1564
|
-
// write it explains never happened.
|
|
1565
|
-
context: emptyContext(),
|
|
1566
1912
|
resolver: input.resolver,
|
|
1567
|
-
maxBytes: input.config.context.maxBytes
|
|
1568
|
-
|
|
1913
|
+
maxBytes: input.config.context.maxBytes
|
|
1914
|
+
});
|
|
1915
|
+
const { delivery, context } = trim(assembled, {
|
|
1569
1916
|
maxContextChars: input.maxContextChars,
|
|
1570
|
-
maxMatchesPerRule: input.config.maxMatchesPerRule
|
|
1571
|
-
stopGate: false
|
|
1917
|
+
maxMatchesPerRule: input.config.maxMatchesPerRule
|
|
1572
1918
|
});
|
|
1919
|
+
await input.recordContext(context.filter((record) => record.t === "delivered"));
|
|
1573
1920
|
await input.record(
|
|
1574
1921
|
[...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
|
|
1575
1922
|
);
|
|
1576
1923
|
return delivery;
|
|
1577
1924
|
}
|
|
1578
1925
|
|
|
1926
|
+
// src/core/session/oversight.ts
|
|
1927
|
+
import { createHash as createHash3 } from "crypto";
|
|
1928
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1929
|
+
import path12 from "path";
|
|
1930
|
+
function notices(input) {
|
|
1931
|
+
const out = [];
|
|
1932
|
+
for (const { file, rule } of input.refused ?? []) {
|
|
1933
|
+
const key = `refused:${rule}:${file}`;
|
|
1934
|
+
if (input.told.has(key)) continue;
|
|
1935
|
+
out.push({ key, text: `rulecast: the agent changed a protected file with Bash: ${file} (${rule})` });
|
|
1936
|
+
}
|
|
1937
|
+
const configKey = `config:${input.currentConfigHash ?? "gone"}`;
|
|
1938
|
+
if (input.startConfigHash !== null && input.startConfigHash !== input.currentConfigHash && !input.told.has(configKey)) {
|
|
1939
|
+
out.push({
|
|
1940
|
+
key: configKey,
|
|
1941
|
+
text: `rulecast: ${CONFIG_FILE} changed during this session; findings may have been silenced. Review: git diff ${CONFIG_FILE}`
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
const keyOf = ({ rule, match }) => `ignore:${rule}:${match.file}:${match.line}`;
|
|
1945
|
+
const added = input.ignored.filter((ignored) => ignored.status === "new" && !input.told.has(keyOf(ignored)));
|
|
1946
|
+
if (added.length > 0) {
|
|
1947
|
+
const sites = added.map(({ rule, match, reason }) => `${match.file}:${match.line} (${rule}: "${reason}")`);
|
|
1948
|
+
const comments = added.length === 1 ? "a rulecast-ignore comment" : `${added.length} rulecast-ignore comments`;
|
|
1949
|
+
out.push({
|
|
1950
|
+
key: added.map(keyOf).join("\n"),
|
|
1951
|
+
text: `rulecast: the agent added ${comments} this session: ${sites.join(", ")}`
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
1954
|
+
return out;
|
|
1955
|
+
}
|
|
1956
|
+
async function configHash(root) {
|
|
1957
|
+
try {
|
|
1958
|
+
return createHash3("sha256").update(await readFile4(path12.join(root, CONFIG_FILE))).digest("hex");
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
if (isNotFound(error)) return null;
|
|
1961
|
+
throw error;
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1579
1965
|
// src/core/session/session.ts
|
|
1580
|
-
import
|
|
1966
|
+
import path13 from "path";
|
|
1581
1967
|
function safeSegment3(value) {
|
|
1582
1968
|
const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1583
1969
|
return /^\.*$/.test(segment) ? `_${segment}` : segment;
|
|
1584
1970
|
}
|
|
1585
1971
|
function sessionDir(stateDir, sessionId) {
|
|
1586
|
-
return
|
|
1972
|
+
return path13.join(stateDir, "sessions", safeSegment3(sessionId));
|
|
1587
1973
|
}
|
|
1588
|
-
var workFile = (dir) =>
|
|
1589
|
-
var contextFile = (dir, agent) =>
|
|
1974
|
+
var workFile = (dir) => path13.join(dir, "work.jsonl");
|
|
1975
|
+
var contextFile = (dir, agent) => path13.join(dir, `context.${safeSegment3(agent)}.jsonl`);
|
|
1590
1976
|
async function appendWork(dir, records) {
|
|
1591
1977
|
await appendRecords(workFile(dir), records);
|
|
1592
1978
|
}
|
|
@@ -1609,11 +1995,87 @@ async function commitSession(dir, agent, decideWith) {
|
|
|
1609
1995
|
});
|
|
1610
1996
|
}
|
|
1611
1997
|
|
|
1998
|
+
// src/core/session/tree.ts
|
|
1999
|
+
import { createHash as createHash4 } from "crypto";
|
|
2000
|
+
import { lstat, readFile as readFile5 } from "fs/promises";
|
|
2001
|
+
import path14 from "path";
|
|
2002
|
+
var HASHED_MAX_BYTES = 1024 * 1024;
|
|
2003
|
+
var GONE = -1;
|
|
2004
|
+
function isGone(state, file) {
|
|
2005
|
+
return state.entries[file]?.[1] === GONE;
|
|
2006
|
+
}
|
|
2007
|
+
function parseStatus(stdout) {
|
|
2008
|
+
const fields = stdout.split("\0");
|
|
2009
|
+
let head = null;
|
|
2010
|
+
const paths = [];
|
|
2011
|
+
for (let index = 0; index < fields.length; index++) {
|
|
2012
|
+
const field = fields[index];
|
|
2013
|
+
if (field.startsWith("# branch.oid ")) {
|
|
2014
|
+
const oid = field.slice("# branch.oid ".length);
|
|
2015
|
+
head = oid === "(initial)" ? null : oid;
|
|
2016
|
+
} else if (field.startsWith("1 ")) {
|
|
2017
|
+
paths.push(field.split(" ").slice(8).join(" "));
|
|
2018
|
+
} else if (field.startsWith("2 ")) {
|
|
2019
|
+
paths.push(field.split(" ").slice(9).join(" "));
|
|
2020
|
+
const original = fields[++index];
|
|
2021
|
+
if (original) paths.push(original);
|
|
2022
|
+
} else if (field.startsWith("u ")) {
|
|
2023
|
+
paths.push(field.split(" ").slice(10).join(" "));
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { head, paths };
|
|
2027
|
+
}
|
|
2028
|
+
async function treeState(root) {
|
|
2029
|
+
const [status, untracked, prefix] = await Promise.all([
|
|
2030
|
+
git(root, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=no", "--", "."]),
|
|
2031
|
+
git(root, ["ls-files", "-z", "--others", "--exclude-standard"]),
|
|
2032
|
+
git(root, ["rev-parse", "--show-prefix"])
|
|
2033
|
+
]);
|
|
2034
|
+
if (!status.ok || !untracked.ok || !prefix.ok) return null;
|
|
2035
|
+
const top = prefix.stdout.trim();
|
|
2036
|
+
const { head, paths } = parseStatus(status.stdout);
|
|
2037
|
+
const files = [
|
|
2038
|
+
...paths.filter((listed) => listed.startsWith(top)).map((listed) => listed.slice(top.length)),
|
|
2039
|
+
...untracked.stdout.split("\0").filter(Boolean)
|
|
2040
|
+
];
|
|
2041
|
+
const entries = {};
|
|
2042
|
+
await Promise.all(
|
|
2043
|
+
files.map(async (file) => {
|
|
2044
|
+
try {
|
|
2045
|
+
const stats = await lstat(path14.join(root, file));
|
|
2046
|
+
entries[file] = stats.isFile() && stats.size <= HASHED_MAX_BYTES ? [stats.mtimeMs, stats.size, await contentHash(path14.join(root, file))] : [stats.mtimeMs, stats.size];
|
|
2047
|
+
} catch (error) {
|
|
2048
|
+
if (!isNotFound(error)) throw error;
|
|
2049
|
+
entries[file] = [0, GONE];
|
|
2050
|
+
}
|
|
2051
|
+
})
|
|
2052
|
+
);
|
|
2053
|
+
return { head, entries };
|
|
2054
|
+
}
|
|
2055
|
+
async function contentHash(file) {
|
|
2056
|
+
return createHash4("sha1").update(await readFile5(file)).digest("hex").slice(0, 16);
|
|
2057
|
+
}
|
|
2058
|
+
function same([mtime, size, hash], [wasMtime, wasSize, wasHash]) {
|
|
2059
|
+
if (hash !== void 0 && wasHash !== void 0) return hash === wasHash && size === wasSize;
|
|
2060
|
+
return mtime === wasMtime && size === wasSize;
|
|
2061
|
+
}
|
|
2062
|
+
function treeChanges(before, after) {
|
|
2063
|
+
if (before.head !== after.head) return { gitOperation: true };
|
|
2064
|
+
const files = /* @__PURE__ */ new Set();
|
|
2065
|
+
for (const [file, entry] of Object.entries(after.entries)) {
|
|
2066
|
+
const was = before.entries[file];
|
|
2067
|
+
if (was === void 0 || !same(entry, was)) files.add(file);
|
|
2068
|
+
}
|
|
2069
|
+
for (const file of Object.keys(before.entries)) if (!(file in after.entries)) files.add(file);
|
|
2070
|
+
return { gitOperation: false, files: [...files].sort() };
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1612
2073
|
// src/core/pipeline.ts
|
|
2074
|
+
var TREE_UNAVAILABLE = "rulecast cannot see the working tree here; edits made with Bash are checked only by git hooks and CI";
|
|
1613
2075
|
var SUMMARY_THRESHOLD = 3;
|
|
1614
2076
|
var DEFAULT_HINT = "rulecast validate";
|
|
1615
2077
|
function summaryKey(prefix, ids) {
|
|
1616
|
-
const digest =
|
|
2078
|
+
const digest = createHash5("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
|
|
1617
2079
|
return `${prefix}:${digest}`;
|
|
1618
2080
|
}
|
|
1619
2081
|
function compileWarnings(errors) {
|
|
@@ -1636,8 +2098,9 @@ First: ${diagnosticText(errors[0])}`
|
|
|
1636
2098
|
}
|
|
1637
2099
|
];
|
|
1638
2100
|
}
|
|
1639
|
-
function detectorWarnings(errors) {
|
|
1640
|
-
const
|
|
2101
|
+
function detectorWarnings(errors, scope) {
|
|
2102
|
+
const outcome = scope === "session" ? "Disabled for this session." : "Skipped for this run.";
|
|
2103
|
+
const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. ${outcome}`;
|
|
1641
2104
|
const byKind = /* @__PURE__ */ new Map();
|
|
1642
2105
|
for (const error of errors) {
|
|
1643
2106
|
const group = byKind.get(error.kind);
|
|
@@ -1658,14 +2121,15 @@ function detectorWarnings(errors) {
|
|
|
1658
2121
|
const ids = group.flatMap((error) => error.rules);
|
|
1659
2122
|
summarised.push({
|
|
1660
2123
|
key: summaryKey(`detector:${kind}`, ids),
|
|
1661
|
-
text: `${ids.length} ${kind} rules were disabled this session \u2014 see debug.log.
|
|
2124
|
+
text: `${ids.length} ${kind} rules ${scope === "session" ? "were disabled this session" : "were skipped in this run"} \u2014 see debug.log.
|
|
1662
2125
|
First: ${text(group[0])}`
|
|
1663
2126
|
});
|
|
1664
2127
|
}
|
|
1665
2128
|
return summarised;
|
|
1666
2129
|
}
|
|
1667
2130
|
async function runPipeline(options) {
|
|
1668
|
-
const { project, stateDir,
|
|
2131
|
+
const { project, stateDir, registry } = options;
|
|
2132
|
+
let event = options.event;
|
|
1669
2133
|
const { root, config } = project;
|
|
1670
2134
|
const log = options.log ?? (() => {
|
|
1671
2135
|
});
|
|
@@ -1691,6 +2155,27 @@ async function runPipeline(options) {
|
|
|
1691
2155
|
if (session) await appendWork(session.dir, [{ t: "prompt", agent: session.agent }]);
|
|
1692
2156
|
return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
1693
2157
|
}
|
|
2158
|
+
const empty = () => ({ work: emptyWork(), context: emptyContext() });
|
|
2159
|
+
const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
|
|
2160
|
+
if (event.kind === "shell-before") {
|
|
2161
|
+
const open2 = session;
|
|
2162
|
+
const now = open2 ? await treeState(root) : null;
|
|
2163
|
+
if (open2 && now !== null) {
|
|
2164
|
+
const { work } = await readingStore(() => openSession(open2.dir, open2.agent), empty());
|
|
2165
|
+
const reference = referenceState(work, open2.agent);
|
|
2166
|
+
const gap = reference === null ? null : treeChanges(reference, now);
|
|
2167
|
+
const edited = new Set(work.edited);
|
|
2168
|
+
const swept2 = gap === null || gap.gitOperation ? [] : relevant(gap.files.filter((file) => !isGone(now, file) && !edited.has(file)));
|
|
2169
|
+
const id = event.toolUseId === void 0 ? {} : { toolUseId: event.toolUseId };
|
|
2170
|
+
if (session !== null) {
|
|
2171
|
+
await appendWork(open2.dir, [
|
|
2172
|
+
...swept2.map((file) => ({ t: "swept", file })),
|
|
2173
|
+
{ t: "tree", phase: "before", agent: open2.agent, ...id, state: now }
|
|
2174
|
+
]);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2178
|
+
}
|
|
1694
2179
|
if (event.kind === "reset") {
|
|
1695
2180
|
if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
|
|
1696
2181
|
if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
@@ -1705,23 +2190,36 @@ async function runPipeline(options) {
|
|
|
1705
2190
|
const dir = session.dir;
|
|
1706
2191
|
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
1707
2192
|
if (session && !baseline.started) {
|
|
1708
|
-
await appendBaseline(dir, [startRecord(await headCommit(root))]);
|
|
2193
|
+
await appendBaseline(dir, [startRecord(await headCommit(root), await configHash(root))]);
|
|
1709
2194
|
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
1710
2195
|
}
|
|
1711
2196
|
}
|
|
1712
|
-
const empty = () => ({ work: emptyWork(), context: emptyContext() });
|
|
1713
2197
|
const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
|
|
1714
2198
|
const disabled = new Set(view.work.disabled.keys());
|
|
1715
2199
|
const resolver = createReferenceResolver(root);
|
|
2200
|
+
const detection = detectionFor(project, registry, stateDir, resolver);
|
|
1716
2201
|
const workRecords = [];
|
|
1717
|
-
|
|
2202
|
+
let via;
|
|
2203
|
+
let swept = [];
|
|
2204
|
+
const refused = [];
|
|
2205
|
+
if (event.kind === "shell-after") {
|
|
2206
|
+
const open2 = session;
|
|
2207
|
+
const now = open2 ? await treeState(root) : null;
|
|
2208
|
+
if (!open2 || now === null) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2209
|
+
const reference = referenceState(view.work, open2.agent, event.toolUseId);
|
|
2210
|
+
const id = event.toolUseId === void 0 ? {} : { toolUseId: event.toolUseId };
|
|
2211
|
+
await appendWork(open2.dir, [{ t: "tree", phase: "after", agent: open2.agent, ...id, state: now }]);
|
|
2212
|
+
const call = reference === null ? null : treeChanges(reference, now);
|
|
2213
|
+
const files = call === null || call.gitOperation ? [] : relevant(call.files.filter((file) => !isGone(now, file)));
|
|
2214
|
+
if (files.length === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
2215
|
+
via = "shell";
|
|
2216
|
+
event = { ...event, kind: "edit", files };
|
|
2217
|
+
}
|
|
1718
2218
|
if (event.kind === "guard") {
|
|
1719
2219
|
const open2 = session;
|
|
1720
2220
|
const delivery = await guardWrite({
|
|
1721
|
-
|
|
2221
|
+
detection,
|
|
1722
2222
|
config,
|
|
1723
|
-
registry,
|
|
1724
|
-
stateDir,
|
|
1725
2223
|
event,
|
|
1726
2224
|
rules: project.rules,
|
|
1727
2225
|
disabled,
|
|
@@ -1730,12 +2228,16 @@ async function runPipeline(options) {
|
|
|
1730
2228
|
maxContextChars: options.maxContextChars,
|
|
1731
2229
|
log,
|
|
1732
2230
|
record: open2 ? (records) => appendWork(open2.dir, records) : async () => {
|
|
2231
|
+
},
|
|
2232
|
+
recordContext: open2 ? (records) => appendContext(open2.dir, open2.agent, records) : async () => {
|
|
1733
2233
|
}
|
|
1734
2234
|
});
|
|
1735
2235
|
return { delivery, failed, deadlineMissed };
|
|
1736
2236
|
}
|
|
1737
2237
|
let touches = [];
|
|
1738
2238
|
let findings = [];
|
|
2239
|
+
let ignored = [];
|
|
2240
|
+
let told = [];
|
|
1739
2241
|
let agentRead = null;
|
|
1740
2242
|
if (event.kind === "touch" || event.kind === "edit") {
|
|
1741
2243
|
touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
|
|
@@ -1751,96 +2253,73 @@ async function runPipeline(options) {
|
|
|
1751
2253
|
const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
|
|
1752
2254
|
touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
|
|
1753
2255
|
}
|
|
2256
|
+
const baselineInput = {
|
|
2257
|
+
root,
|
|
2258
|
+
detection,
|
|
2259
|
+
rules: project.rules,
|
|
2260
|
+
disabled,
|
|
2261
|
+
limits: {
|
|
2262
|
+
editDeadlineMs: config.timeouts.editDeadlineMs,
|
|
2263
|
+
verifyMs: config.timeouts.verifyMs,
|
|
2264
|
+
maxFileBytes: config.maxFileBytes
|
|
2265
|
+
}
|
|
2266
|
+
};
|
|
1754
2267
|
if (event.kind === "touch") {
|
|
1755
2268
|
if (event.completeRead) agentRead = event.files[0] ?? null;
|
|
1756
|
-
if (session)
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
records.push(
|
|
1768
|
-
...await recordFingerprints({
|
|
1769
|
-
root,
|
|
1770
|
-
files: snapshotted,
|
|
1771
|
-
rules: project.rules,
|
|
1772
|
-
disabled,
|
|
1773
|
-
registry,
|
|
1774
|
-
read: (file) => readSourceFile(root, file),
|
|
1775
|
-
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1776
|
-
contextFor: (rule) => resolveRuleContext(resolver, rule.context),
|
|
1777
|
-
settings: { llm: config.llm },
|
|
1778
|
-
event: "edit",
|
|
1779
|
-
source: "disk",
|
|
1780
|
-
timeoutMs: config.timeouts.editDeadlineMs,
|
|
1781
|
-
ceiling: { maxFileBytes: config.maxFileBytes, sizeOf: (file) => fileBytes(root, file) }
|
|
1782
|
-
})
|
|
1783
|
-
);
|
|
2269
|
+
if (session) await appendBaseline(session.dir, await touchRecords(baselineInput, baseline, relevant(event.files)));
|
|
2270
|
+
}
|
|
2271
|
+
if (event.kind === "start" && session) {
|
|
2272
|
+
const tree = await treeState(root);
|
|
2273
|
+
if (tree === null) warnings.push({ key: "tree-unavailable", text: TREE_UNAVAILABLE });
|
|
2274
|
+
else {
|
|
2275
|
+
await appendWork(session.dir, [{ t: "tree", phase: "start", agent: session.agent, state: tree }]);
|
|
2276
|
+
const start = await startRecords(baselineInput, baseline, relevant(Object.keys(tree.entries).sort()));
|
|
2277
|
+
if (start.records.length > 0) await appendBaseline(session.dir, start.records);
|
|
2278
|
+
if (start.skipped > 0) {
|
|
2279
|
+
log(`session start: ${start.skipped} dirty files past the snapshot limits; their baseline is the start commit`);
|
|
1784
2280
|
}
|
|
1785
|
-
await appendBaseline(session.dir, records);
|
|
1786
2281
|
}
|
|
1787
2282
|
}
|
|
1788
2283
|
if (event.kind === "edit" || event.kind === "verify") {
|
|
1789
|
-
let
|
|
1790
|
-
let snapshots = session ? baseline.snapshots : /* @__PURE__ */ new Map();
|
|
1791
|
-
let fallbackCommit = session ? baseline.startCommit : null;
|
|
2284
|
+
let requested = relevant(event.files);
|
|
1792
2285
|
if (event.kind === "edit" && session) {
|
|
1793
2286
|
await appendWork(
|
|
1794
2287
|
session.dir,
|
|
1795
|
-
|
|
2288
|
+
requested.map((file) => ({ t: "edited", file, ...via === void 0 ? {} : { via } }))
|
|
1796
2289
|
);
|
|
1797
2290
|
}
|
|
1798
|
-
if (event.kind === "verify") {
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2291
|
+
if (event.kind === "verify" && event.files.length === 0 && session) {
|
|
2292
|
+
requested = relevant(view.work.edited);
|
|
2293
|
+
if (options.stopGate === true) {
|
|
2294
|
+
swept = await sweep(session, new Set(requested));
|
|
2295
|
+
requested = [...requested, ...swept];
|
|
1802
2296
|
}
|
|
1803
|
-
if (event.files.length === 0 && session) files = relevant(view.work.edited);
|
|
1804
2297
|
}
|
|
1805
|
-
const
|
|
2298
|
+
const read = contentReader(root, event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE);
|
|
2299
|
+
const stage = await changesFor(
|
|
2300
|
+
baselineInput,
|
|
2301
|
+
{ kind: event.kind, baseCommit: event.baseCommit },
|
|
2302
|
+
session ? baseline : null,
|
|
2303
|
+
requested,
|
|
2304
|
+
read
|
|
2305
|
+
);
|
|
2306
|
+
const { files, changes: baselineChanges, fingerprints } = stage;
|
|
1806
2307
|
const changes = baselineChanges.sets;
|
|
1807
|
-
if (
|
|
1808
|
-
files = files.filter((file) => changes.get(file)?.changedLines.length !== 0);
|
|
1809
|
-
}
|
|
1810
|
-
if (event.kind === "verify" && baselineChanges.fromCommit.size > 0) {
|
|
1811
|
-
const texts = baselineChanges.fromCommit;
|
|
1812
|
-
const missing = [...texts.keys()].filter((file) => !baseline.fingerprints.has(file));
|
|
1813
|
-
const records = missing.length === 0 ? [] : await recordFingerprints({
|
|
1814
|
-
root,
|
|
1815
|
-
files: missing,
|
|
1816
|
-
rules: project.rules,
|
|
1817
|
-
disabled,
|
|
1818
|
-
registry,
|
|
1819
|
-
read: async (file) => texts.get(file) ?? null,
|
|
1820
|
-
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1821
|
-
contextFor: (rule) => resolveRuleContext(resolver, rule.context),
|
|
1822
|
-
settings: { llm: config.llm },
|
|
1823
|
-
event: "verify",
|
|
1824
|
-
source: "memory",
|
|
1825
|
-
timeoutMs: config.timeouts.verifyMs
|
|
1826
|
-
});
|
|
1827
|
-
for (const record of records) {
|
|
1828
|
-
if (record.t !== "fingerprint") continue;
|
|
1829
|
-
const byRule = baseline.fingerprints.get(record.file) ?? /* @__PURE__ */ new Map();
|
|
1830
|
-
baseline.fingerprints.set(record.file, byRule);
|
|
1831
|
-
byRule.set(record.rule, record.ranges);
|
|
1832
|
-
}
|
|
1833
|
-
if (session && records.length > 0) await appendBaseline(session.dir, records);
|
|
1834
|
-
}
|
|
2308
|
+
if (session && stage.records.length > 0) await appendBaseline(session.dir, stage.records);
|
|
1835
2309
|
const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
|
|
1836
2310
|
let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
|
|
1837
2311
|
(selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
|
|
1838
2312
|
);
|
|
1839
|
-
|
|
2313
|
+
const overBudget = [];
|
|
1840
2314
|
if (event.kind === "verify") {
|
|
1841
|
-
const
|
|
1842
|
-
selections
|
|
1843
|
-
|
|
2315
|
+
const recent = [...view.work.edited].reverse();
|
|
2316
|
+
for (const kind of new Set(selections.map((selection) => selection.rule.detector.kind))) {
|
|
2317
|
+
const budget = registry.get(kind)?.fileBudget?.(detection.settings);
|
|
2318
|
+
if (budget === void 0) continue;
|
|
2319
|
+
const budgeted = applyFileBudget(kind, selections, budget.max, recent);
|
|
2320
|
+
selections = budgeted.selections;
|
|
2321
|
+
if (budgeted.skipped.length > 0) overBudget.push({ kind, ...budget, skipped: budgeted.skipped });
|
|
2322
|
+
}
|
|
1844
2323
|
}
|
|
1845
2324
|
if (event.kind === "edit") {
|
|
1846
2325
|
const ceiling = await applySizeCeiling(
|
|
@@ -1854,35 +2333,64 @@ async function runPipeline(options) {
|
|
|
1854
2333
|
log(`over max_file_bytes (${config.maxFileBytes}), not checked on this edit: ${ceiling.skipped.join(", ")}`);
|
|
1855
2334
|
}
|
|
1856
2335
|
}
|
|
1857
|
-
const output = await
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
});
|
|
2336
|
+
const output = await runDetectionFrom(
|
|
2337
|
+
{
|
|
2338
|
+
detection,
|
|
2339
|
+
event: event.kind,
|
|
2340
|
+
selections,
|
|
2341
|
+
changes,
|
|
2342
|
+
read,
|
|
2343
|
+
timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
|
|
2344
|
+
},
|
|
2345
|
+
event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE
|
|
2346
|
+
);
|
|
1869
2347
|
findings = output.findings.map(({ rule, match }) => ({
|
|
1870
2348
|
rule,
|
|
1871
2349
|
match,
|
|
1872
|
-
status: classify(match, rule.scope, rule.id, baselineChanges,
|
|
2350
|
+
status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
|
|
1873
2351
|
}));
|
|
1874
2352
|
for (const error of output.errors) {
|
|
1875
2353
|
failed = true;
|
|
1876
2354
|
log(`${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}`);
|
|
1877
2355
|
for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
|
|
1878
2356
|
}
|
|
1879
|
-
warnings.push(...detectorWarnings(output.errors));
|
|
1880
|
-
|
|
2357
|
+
warnings.push(...detectorWarnings(output.errors, session === null ? "run" : "session"));
|
|
2358
|
+
ignored = output.ignored;
|
|
2359
|
+
const shellEdited = (file) => via === "shell" ? requested.includes(file) : view.work.editedVia.get(file) === "shell";
|
|
2360
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2361
|
+
for (const { rule, match, status } of findings) {
|
|
2362
|
+
if (status !== "new" || !rule.refuseWrite || !shellEdited(match.file)) continue;
|
|
2363
|
+
if (seen.has(`${rule.id} ${match.file}`)) continue;
|
|
2364
|
+
seen.add(`${rule.id} ${match.file}`);
|
|
2365
|
+
refused.push({ file: match.file, rule: rule.id, dirtyAtStart: dirtyAtStart(view.work, match.file) });
|
|
2366
|
+
}
|
|
2367
|
+
const atStop = options.stopGate === true && event.kind === "verify" && session !== null;
|
|
2368
|
+
if (session !== null && (atStop || refused.length > 0)) {
|
|
2369
|
+
told = notices({
|
|
2370
|
+
startConfigHash: atStop ? baseline.startConfigHash ?? null : null,
|
|
2371
|
+
currentConfigHash: atStop ? await configHash(root) : null,
|
|
2372
|
+
ignored: atStop ? ignored.map(({ rule, match, reason }) => ({
|
|
2373
|
+
rule: rule.id,
|
|
2374
|
+
match,
|
|
2375
|
+
reason,
|
|
2376
|
+
status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
|
|
2377
|
+
})) : [],
|
|
2378
|
+
told: view.work.noticed,
|
|
2379
|
+
refused
|
|
2380
|
+
});
|
|
2381
|
+
for (const notice of told) for (const key of notice.key.split("\n")) workRecords.push({ t: "noticed", key });
|
|
2382
|
+
}
|
|
2383
|
+
for (const text of output.warnings) warnings.push({ key: `ignore:${text}`, text });
|
|
2384
|
+
for (const { kind, max, setting, skipped } of overBudget) {
|
|
1881
2385
|
warnings.push({
|
|
1882
|
-
key:
|
|
1883
|
-
text:
|
|
2386
|
+
key: `${kind}-budget:${skipped.join(",")}`,
|
|
2387
|
+
text: `${kind} rules checked ${max} files; ${skipped.length} were not checked: ${skipped.join(", ")}. Raise ${setting}, or run rulecast run --files on them.`
|
|
1884
2388
|
});
|
|
1885
2389
|
}
|
|
2390
|
+
const hintFor = (kind) => {
|
|
2391
|
+
const hint = registry.get(kind)?.timeoutHint;
|
|
2392
|
+
return hint === void 0 ? "" : ` (${hint})`;
|
|
2393
|
+
};
|
|
1886
2394
|
for (const timeout of output.timedOut) {
|
|
1887
2395
|
if (event.kind === "edit") {
|
|
1888
2396
|
log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
|
|
@@ -1893,11 +2401,26 @@ async function runPipeline(options) {
|
|
|
1893
2401
|
key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
|
|
1894
2402
|
// Naming the lever matters: a field trial measured a single haiku call on a 135-line file
|
|
1895
2403
|
// at 89 s against a 60 s default, and "timed out" alone leaves nobody knowing what to do.
|
|
1896
|
-
text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${timeout.kind
|
|
2404
|
+
text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${hintFor(timeout.kind)}.`
|
|
1897
2405
|
});
|
|
1898
2406
|
}
|
|
1899
2407
|
}
|
|
1900
2408
|
}
|
|
2409
|
+
async function sweep(open2, edited) {
|
|
2410
|
+
const now = await treeState(root);
|
|
2411
|
+
let found = [];
|
|
2412
|
+
if (now !== null) {
|
|
2413
|
+
const reference = referenceState(view.work, open2.agent);
|
|
2414
|
+
const gap = reference === null ? null : treeChanges(reference, now);
|
|
2415
|
+
if (gap !== null && !gap.gitOperation) found = relevant(gap.files.filter((file) => !edited.has(file)));
|
|
2416
|
+
await appendWork(open2.dir, [
|
|
2417
|
+
...found.map((file) => ({ t: "swept", file })),
|
|
2418
|
+
{ t: "tree", phase: "stop", agent: open2.agent, state: now }
|
|
2419
|
+
]);
|
|
2420
|
+
}
|
|
2421
|
+
const all = [.../* @__PURE__ */ new Set([...relevant(view.work.swept), ...found])].filter((file) => !edited.has(file));
|
|
2422
|
+
return now === null ? all : all.filter((file) => !isGone(now, file));
|
|
2423
|
+
}
|
|
1901
2424
|
const inputFor = (state) => ({
|
|
1902
2425
|
agent: session?.agent ?? "main",
|
|
1903
2426
|
findings,
|
|
@@ -1911,19 +2434,35 @@ async function runPipeline(options) {
|
|
|
1911
2434
|
maxBlocks: config.stopGate.maxBlocks,
|
|
1912
2435
|
maxContextChars: options.maxContextChars,
|
|
1913
2436
|
maxMatchesPerRule: config.maxMatchesPerRule,
|
|
1914
|
-
stopGate: options.stopGate === true && event.kind === "verify" && session !== null
|
|
2437
|
+
stopGate: options.stopGate === true && event.kind === "verify" && session !== null,
|
|
2438
|
+
...via === void 0 ? {} : { via },
|
|
2439
|
+
...swept.length === 0 ? {} : { swept },
|
|
2440
|
+
...refused.length === 0 ? {} : { refused }
|
|
1915
2441
|
});
|
|
2442
|
+
const home = path15.dirname(path15.dirname(stateDir));
|
|
1916
2443
|
const persisted = (decision) => {
|
|
1917
2444
|
if (decision.overflow !== null) {
|
|
1918
|
-
|
|
2445
|
+
let written = null;
|
|
2446
|
+
try {
|
|
2447
|
+
written = writeOverflow(path15.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
|
|
2448
|
+
} catch {
|
|
2449
|
+
written = null;
|
|
2450
|
+
}
|
|
2451
|
+
decision.delivery.overflowPath = written === null ? null : path15.relative(root, written).split(path15.sep).join("/");
|
|
2452
|
+
}
|
|
2453
|
+
for (const reference of decision.delivery.references) {
|
|
2454
|
+
if (reference.location === void 0 || !path15.isAbsolute(reference.location)) continue;
|
|
2455
|
+
const copied = copyIntoProject(root, home, reference.location);
|
|
2456
|
+
if (copied !== null) reference.location = copied;
|
|
1919
2457
|
}
|
|
2458
|
+
if (told.length > 0) decision.delivery.notices = told.map((notice) => notice.text);
|
|
1920
2459
|
return decision.delivery;
|
|
1921
2460
|
};
|
|
1922
|
-
if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
|
|
2461
|
+
if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed, ignored };
|
|
1923
2462
|
await appendWork(session.dir, workRecords);
|
|
1924
2463
|
try {
|
|
1925
2464
|
const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
|
|
1926
|
-
return { delivery: persisted(decision), failed, deadlineMissed };
|
|
2465
|
+
return { delivery: persisted(decision), failed, deadlineMissed, ignored };
|
|
1927
2466
|
} catch (error) {
|
|
1928
2467
|
if (error instanceof CorruptStoreError) {
|
|
1929
2468
|
log(`session store unreadable: ${error.message}`);
|
|
@@ -1932,7 +2471,7 @@ async function runPipeline(options) {
|
|
|
1932
2471
|
warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
|
|
1933
2472
|
} else throw error;
|
|
1934
2473
|
const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
|
|
1935
|
-
return { delivery: persisted(decision), failed, deadlineMissed };
|
|
2474
|
+
return { delivery: persisted(decision), failed, deadlineMissed, ignored };
|
|
1936
2475
|
}
|
|
1937
2476
|
}
|
|
1938
2477
|
|
|
@@ -1940,10 +2479,12 @@ export {
|
|
|
1940
2479
|
defaultConfig,
|
|
1941
2480
|
CONFIG_FILE,
|
|
1942
2481
|
MANIFEST_FILE,
|
|
2482
|
+
readYamlFile,
|
|
1943
2483
|
readConfigData,
|
|
1944
2484
|
parseConfig,
|
|
1945
2485
|
readManifest,
|
|
1946
2486
|
git,
|
|
2487
|
+
headCommit,
|
|
1947
2488
|
mergeBase,
|
|
1948
2489
|
changedFilesSince,
|
|
1949
2490
|
allFiles,
|
|
@@ -1964,14 +2505,16 @@ export {
|
|
|
1964
2505
|
diagnosticText,
|
|
1965
2506
|
compile,
|
|
1966
2507
|
compileManifest,
|
|
2508
|
+
createReferenceResolver,
|
|
1967
2509
|
rulesOfKind,
|
|
1968
2510
|
checkableKinds,
|
|
1969
2511
|
checkDetectors,
|
|
2512
|
+
detectionFor,
|
|
1970
2513
|
runDetection,
|
|
1971
2514
|
cachedRepos,
|
|
1972
2515
|
fetchingRepos,
|
|
1973
2516
|
fixedRepo,
|
|
1974
|
-
|
|
1975
|
-
|
|
2517
|
+
WORKTREE,
|
|
2518
|
+
withScratchTree,
|
|
1976
2519
|
runPipeline
|
|
1977
2520
|
};
|