@syv-ai/rulecast 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -9
- package/dist/{chunk-HZUATJ53.js → chunk-DQUCE2IK.js} +219 -68
- package/dist/{chunk-KOUHBT7F.js → chunk-IKONOTME.js} +177 -129
- package/dist/{chunk-7KUPYT35.js → chunk-WUFU4LW2.js} +577 -265
- package/dist/cli.js +586 -147
- package/dist/index.d.ts +75 -4
- package/dist/index.js +31 -3
- package/dist/internal.d.ts +13 -10
- package/dist/internal.js +2 -2
- package/dist/{types-C8b7hrnG.d.ts → types-Dcra5LXR.d.ts} +96 -3
- package/package.json +3 -2
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BACKLOG_HINT,
|
|
3
2
|
DeadlineError,
|
|
4
3
|
LLM_PROVIDERS,
|
|
5
4
|
VERSION,
|
|
6
5
|
compileFilter,
|
|
7
6
|
compileRule,
|
|
7
|
+
deliveryCost,
|
|
8
8
|
detectorCacheDir,
|
|
9
9
|
diskCache,
|
|
10
10
|
emptyDelivery,
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
sectionRange,
|
|
25
25
|
sectionText,
|
|
26
26
|
templateBindings
|
|
27
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-DQUCE2IK.js";
|
|
28
28
|
|
|
29
29
|
// src/core/config/load.ts
|
|
30
30
|
import { readFile } from "fs/promises";
|
|
@@ -59,6 +59,8 @@ var ruleKeys = {
|
|
|
59
59
|
*/
|
|
60
60
|
scope: z.enum(RULE_SCOPES).optional(),
|
|
61
61
|
refuse_write: z.boolean().optional(),
|
|
62
|
+
/** false: compiled and listed, selected for nothing. The way to switch a catalog rule off without deleting it. */
|
|
63
|
+
enabled: z.boolean().optional(),
|
|
62
64
|
detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
|
|
63
65
|
message: z.string().optional(),
|
|
64
66
|
context: z.array(referenceInputSchema).optional(),
|
|
@@ -150,13 +152,15 @@ function readConfigData(root) {
|
|
|
150
152
|
}
|
|
151
153
|
function parseConfig(data) {
|
|
152
154
|
const result = configSchema.safeParse(data);
|
|
153
|
-
if (!result.success)
|
|
155
|
+
if (!result.success) {
|
|
156
|
+
return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error, { schema: configSchema })}` };
|
|
157
|
+
}
|
|
154
158
|
return { ok: true, value: result.data };
|
|
155
159
|
}
|
|
156
|
-
async function readManifest(dir) {
|
|
157
|
-
const loaded = await readYamlFile(
|
|
160
|
+
async function readManifest(dir, file = path.join(dir, MANIFEST_FILE)) {
|
|
161
|
+
const loaded = await readYamlFile(file);
|
|
158
162
|
if (!loaded.ok) return loaded;
|
|
159
|
-
if (!Array.isArray(loaded.value)) return { ok: false, message: `${
|
|
163
|
+
if (!Array.isArray(loaded.value)) return { ok: false, message: `${path.basename(file)}: must be a list of rules` };
|
|
160
164
|
return { ok: true, value: loaded.value };
|
|
161
165
|
}
|
|
162
166
|
|
|
@@ -197,6 +201,7 @@ function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
|
|
|
197
201
|
|
|
198
202
|
// src/core/compile/project.ts
|
|
199
203
|
import { readFile as readFile2 } from "fs/promises";
|
|
204
|
+
import path3 from "path";
|
|
200
205
|
function diagnosticText(diagnostic) {
|
|
201
206
|
const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
|
|
202
207
|
return `${diagnostic.source}${rule}: ${diagnostic.message}`;
|
|
@@ -274,7 +279,7 @@ async function compile(options) {
|
|
|
274
279
|
}
|
|
275
280
|
for (const raw of entry.rules) {
|
|
276
281
|
const rule = ruleSchema.safeParse(raw);
|
|
277
|
-
if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
|
|
282
|
+
if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error, { schema: ruleSchema }));
|
|
278
283
|
else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
|
|
279
284
|
}
|
|
280
285
|
continue;
|
|
@@ -311,7 +316,7 @@ async function compile(options) {
|
|
|
311
316
|
for (const raw of entry.rules) {
|
|
312
317
|
const override = overrideSchema.safeParse(raw);
|
|
313
318
|
if (!override.success) {
|
|
314
|
-
error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
|
|
319
|
+
error(CONFIG_FILE, idOf(raw), formatZodError(override.error, { schema: overrideSchema }));
|
|
315
320
|
continue;
|
|
316
321
|
}
|
|
317
322
|
const { id } = override.data;
|
|
@@ -322,7 +327,7 @@ async function compile(options) {
|
|
|
322
327
|
}
|
|
323
328
|
const base = ruleSchema.safeParse(candidates[0]);
|
|
324
329
|
if (!base.success) {
|
|
325
|
-
error(source, id,
|
|
330
|
+
error(source, id, formatZodError(base.error, { prefix: "manifest", schema: ruleSchema }));
|
|
326
331
|
continue;
|
|
327
332
|
}
|
|
328
333
|
const data2 = { ...base.data, ...override.data };
|
|
@@ -337,12 +342,13 @@ async function compile(options) {
|
|
|
337
342
|
);
|
|
338
343
|
return { root, config, rules, diagnostics };
|
|
339
344
|
}
|
|
340
|
-
async function compileManifest(dir, registry) {
|
|
341
|
-
const
|
|
345
|
+
async function compileManifest(dir, registry, file = path3.join(dir, MANIFEST_FILE)) {
|
|
346
|
+
const source = path3.basename(file);
|
|
347
|
+
const manifest = await readManifest(dir, file);
|
|
342
348
|
if (!manifest.ok) {
|
|
343
349
|
return {
|
|
344
350
|
rules: [],
|
|
345
|
-
diagnostics: [{ source
|
|
351
|
+
diagnostics: [{ source, rule: null, message: manifest.message, level: "error" }]
|
|
346
352
|
};
|
|
347
353
|
}
|
|
348
354
|
const diagnostics = [];
|
|
@@ -351,18 +357,23 @@ async function compileManifest(dir, registry) {
|
|
|
351
357
|
for (const raw of manifest.value) {
|
|
352
358
|
const rule = ruleSchema.safeParse(raw);
|
|
353
359
|
if (!rule.success) {
|
|
354
|
-
diagnostics.push({
|
|
360
|
+
diagnostics.push({
|
|
361
|
+
source,
|
|
362
|
+
rule: idOf(raw),
|
|
363
|
+
message: formatZodError(rule.error, { schema: ruleSchema }),
|
|
364
|
+
level: "error"
|
|
365
|
+
});
|
|
355
366
|
continue;
|
|
356
367
|
}
|
|
357
368
|
const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
|
|
358
369
|
if (typeof result === "string") {
|
|
359
370
|
diagnostics.push({
|
|
360
|
-
source
|
|
371
|
+
source,
|
|
361
372
|
rule: rule.data.alias ?? rule.data.id,
|
|
362
373
|
message: result,
|
|
363
374
|
level: "error"
|
|
364
375
|
});
|
|
365
|
-
} else compiled.push({ rule: result, source
|
|
376
|
+
} else compiled.push({ rule: result, source });
|
|
366
377
|
}
|
|
367
378
|
const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
|
|
368
379
|
return { rules, diagnostics };
|
|
@@ -371,7 +382,7 @@ async function compileManifest(dir, registry) {
|
|
|
371
382
|
// src/core/detection/select.ts
|
|
372
383
|
function selectDetectorRules(rules, event, files, disabled) {
|
|
373
384
|
return rules.filter(
|
|
374
|
-
(rule) => isDetectorRule(rule) && rule.stages.includes(event) && !disabled.has(rule.id)
|
|
385
|
+
(rule) => isDetectorRule(rule) && rule.enabled && rule.stages.includes(event) && !disabled.has(rule.id)
|
|
375
386
|
).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
|
|
376
387
|
}
|
|
377
388
|
function rulesOfKind(rules, kind) {
|
|
@@ -379,7 +390,7 @@ function rulesOfKind(rules, kind) {
|
|
|
379
390
|
}
|
|
380
391
|
function selectTouchRules(rules, files, touched, disabled) {
|
|
381
392
|
return rules.filter(
|
|
382
|
-
(rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
|
|
393
|
+
(rule) => rule.enabled && rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
|
|
383
394
|
);
|
|
384
395
|
}
|
|
385
396
|
|
|
@@ -429,7 +440,7 @@ async function checkDetectors(options) {
|
|
|
429
440
|
import { randomUUID } from "crypto";
|
|
430
441
|
import { existsSync as existsSync2 } from "fs";
|
|
431
442
|
import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
|
|
432
|
-
import
|
|
443
|
+
import path6 from "path";
|
|
433
444
|
|
|
434
445
|
// src/core/git.ts
|
|
435
446
|
import { execFile } from "child_process";
|
|
@@ -475,10 +486,26 @@ async function fileAtCommit(cwd, commit, file) {
|
|
|
475
486
|
const result = await git(cwd, ["show", `${commit}:./${file}`]);
|
|
476
487
|
return result.ok ? result.stdout : null;
|
|
477
488
|
}
|
|
489
|
+
async function fileInIndex(cwd, file) {
|
|
490
|
+
const result = await git(cwd, ["show", `:./${file}`]);
|
|
491
|
+
return result.ok ? result.stdout : null;
|
|
492
|
+
}
|
|
493
|
+
async function isShallow(cwd) {
|
|
494
|
+
const result = await git(cwd, ["rev-parse", "--is-shallow-repository"]);
|
|
495
|
+
return result.ok && result.stdout.trim() === "true";
|
|
496
|
+
}
|
|
497
|
+
var FETCH_HISTORY = "fetch full history (actions/checkout: fetch-depth: 0)";
|
|
478
498
|
async function mergeBase(cwd, ref, other = "HEAD") {
|
|
479
499
|
const result = await git(cwd, ["merge-base", other, ref]);
|
|
480
|
-
if (
|
|
481
|
-
|
|
500
|
+
if (result.ok) return result.stdout.trim();
|
|
501
|
+
const shallow = await isShallow(cwd);
|
|
502
|
+
const resolves = (await git(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).ok;
|
|
503
|
+
if (!resolves) {
|
|
504
|
+
throw new Error(`"${ref}" is not a commit here${shallow ? `; this is a shallow clone: ${FETCH_HISTORY}` : ""}`);
|
|
505
|
+
}
|
|
506
|
+
if (shallow) throw new Error(`no merge base with ${ref} in a shallow clone: ${FETCH_HISTORY}`);
|
|
507
|
+
const detail = result.stderr.trim();
|
|
508
|
+
throw new Error(`no merge base with ${ref}${detail ? `: ${detail}` : ": the histories are unrelated"}`);
|
|
482
509
|
}
|
|
483
510
|
async function changedFilesSince(cwd, commit) {
|
|
484
511
|
const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
|
|
@@ -509,7 +536,7 @@ async function changedFilesBetween(cwd, from, to) {
|
|
|
509
536
|
|
|
510
537
|
// src/core/session/lock.ts
|
|
511
538
|
import { mkdir, rm, stat } from "fs/promises";
|
|
512
|
-
import
|
|
539
|
+
import path4 from "path";
|
|
513
540
|
var LockTimeoutError = class extends Error {
|
|
514
541
|
};
|
|
515
542
|
var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
|
|
@@ -517,7 +544,7 @@ function isAlreadyExists(error) {
|
|
|
517
544
|
return error instanceof Error && error.code === "EEXIST";
|
|
518
545
|
}
|
|
519
546
|
async function withLock(dir, fn, options = DEFAULTS) {
|
|
520
|
-
const lock =
|
|
547
|
+
const lock = path4.join(dir, ".lock");
|
|
521
548
|
await mkdir(dir, { recursive: true });
|
|
522
549
|
const started = Date.now();
|
|
523
550
|
for (; ; ) {
|
|
@@ -550,7 +577,7 @@ async function withLock(dir, fn, options = DEFAULTS) {
|
|
|
550
577
|
|
|
551
578
|
// src/core/repos/layout.ts
|
|
552
579
|
import { createHash as createHash2 } from "crypto";
|
|
553
|
-
import
|
|
580
|
+
import path5 from "path";
|
|
554
581
|
import { fileURLToPath } from "url";
|
|
555
582
|
var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
556
583
|
var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
|
|
@@ -558,7 +585,7 @@ function segmentsOf(repoPath) {
|
|
|
558
585
|
return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
|
|
559
586
|
}
|
|
560
587
|
function localName(file) {
|
|
561
|
-
return
|
|
588
|
+
return path5.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
|
|
562
589
|
}
|
|
563
590
|
function parseUrl(url) {
|
|
564
591
|
if (SCHEME.test(url)) {
|
|
@@ -585,7 +612,7 @@ function repoSlug(url) {
|
|
|
585
612
|
return safeSegment(`local_${parsed.name}_${hash}`);
|
|
586
613
|
}
|
|
587
614
|
function repoDir(home, url, rev) {
|
|
588
|
-
return
|
|
615
|
+
return path5.join(home, "repos", repoSlug(url), safeSegment(rev));
|
|
589
616
|
}
|
|
590
617
|
function repoLabel(url, rev) {
|
|
591
618
|
return `${repoName(url)}@${rev}`;
|
|
@@ -605,7 +632,7 @@ async function fetchCheckout(url, rev, into) {
|
|
|
605
632
|
await run(into, ["init", "-q"]);
|
|
606
633
|
await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
|
|
607
634
|
await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
|
|
608
|
-
await rm2(
|
|
635
|
+
await rm2(path6.join(into, ".git"), { recursive: true, force: true });
|
|
609
636
|
}
|
|
610
637
|
function cachedRepo(home, url, rev) {
|
|
611
638
|
const dir = repoDir(home, url, rev);
|
|
@@ -614,12 +641,12 @@ function cachedRepo(home, url, rev) {
|
|
|
614
641
|
async function ensureRepo(home, url, rev) {
|
|
615
642
|
const dir = repoDir(home, url, rev);
|
|
616
643
|
if (existsSync2(dir)) return dir;
|
|
617
|
-
const parent =
|
|
644
|
+
const parent = path6.dirname(dir);
|
|
618
645
|
await withLock(
|
|
619
646
|
parent,
|
|
620
647
|
async () => {
|
|
621
648
|
if (existsSync2(dir)) return;
|
|
622
|
-
const temp =
|
|
649
|
+
const temp = path6.join(parent, `.tmp-${randomUUID()}`);
|
|
623
650
|
try {
|
|
624
651
|
await fetchCheckout(url, rev, temp);
|
|
625
652
|
await rename(temp, dir);
|
|
@@ -662,7 +689,21 @@ function fixedRepo(dir, label) {
|
|
|
662
689
|
}
|
|
663
690
|
|
|
664
691
|
// src/core/pipeline.ts
|
|
665
|
-
import { createHash as
|
|
692
|
+
import { createHash as createHash4 } from "crypto";
|
|
693
|
+
import path14 from "path";
|
|
694
|
+
|
|
695
|
+
// src/core/content.ts
|
|
696
|
+
var WORKTREE = { kind: "worktree" };
|
|
697
|
+
function contentReader(root, source) {
|
|
698
|
+
switch (source.kind) {
|
|
699
|
+
case "worktree":
|
|
700
|
+
return (file) => readSourceFile(root, file);
|
|
701
|
+
case "index":
|
|
702
|
+
return (file) => fileInIndex(root, file);
|
|
703
|
+
case "commit":
|
|
704
|
+
return (file) => fileAtCommit(root, source.ref, file);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
666
707
|
|
|
667
708
|
// src/core/baseline/changes.ts
|
|
668
709
|
import { diffArrays } from "diff";
|
|
@@ -734,7 +775,7 @@ function snapshotOf(text) {
|
|
|
734
775
|
}
|
|
735
776
|
|
|
736
777
|
// src/core/baseline/baseline.ts
|
|
737
|
-
async function computeChanges(root, files, sources) {
|
|
778
|
+
async function computeChanges(root, files, sources, read = contentReader(root, WORKTREE)) {
|
|
738
779
|
const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
|
|
739
780
|
for (const file of files) {
|
|
740
781
|
if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
|
|
@@ -745,7 +786,7 @@ async function computeChanges(root, files, sources) {
|
|
|
745
786
|
before = commitText === null ? null : snapshotOf(commitText);
|
|
746
787
|
}
|
|
747
788
|
if (!before) continue;
|
|
748
|
-
const current = await
|
|
789
|
+
const current = await read(file);
|
|
749
790
|
if (current === null) continue;
|
|
750
791
|
if (commitText !== null) changes.fromCommit.set(file, commitText);
|
|
751
792
|
const after = snapshotOf(current);
|
|
@@ -821,10 +862,59 @@ async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
|
|
|
821
862
|
};
|
|
822
863
|
}
|
|
823
864
|
|
|
865
|
+
// src/core/suppress.ts
|
|
866
|
+
var ID = RULE_ID.source.slice(1, -1);
|
|
867
|
+
var IGNORE = new RegExp(
|
|
868
|
+
String.raw`rulecast-ignore:\s*(?<rule>${ID})(?:\s+(?<reason>\S.*?))?\s*(?:\*\/|-->)?\s*$`
|
|
869
|
+
);
|
|
870
|
+
function parseIgnore(line) {
|
|
871
|
+
const match = IGNORE.exec(line);
|
|
872
|
+
if (match?.groups === void 0) return null;
|
|
873
|
+
return { rule: match.groups.rule, reason: match.groups.reason ?? null };
|
|
874
|
+
}
|
|
875
|
+
async function suppress(findings, read) {
|
|
876
|
+
const result = { findings: [], ignored: [], warnings: [] };
|
|
877
|
+
if (findings.length === 0) return result;
|
|
878
|
+
const lines = /* @__PURE__ */ new Map();
|
|
879
|
+
const linesOf = async (file) => {
|
|
880
|
+
if (!lines.has(file)) lines.set(file, (await read(file))?.split("\n") ?? null);
|
|
881
|
+
return lines.get(file);
|
|
882
|
+
};
|
|
883
|
+
const warned = /* @__PURE__ */ new Set();
|
|
884
|
+
for (const finding of findings) {
|
|
885
|
+
const source = await linesOf(finding.match.file);
|
|
886
|
+
let ignore = null;
|
|
887
|
+
let at = 0;
|
|
888
|
+
for (const line of [finding.match.line, finding.match.line - 1]) {
|
|
889
|
+
const parsed = line >= 1 ? parseIgnore(source?.[line - 1] ?? "") : null;
|
|
890
|
+
if (parsed !== null && parsed.rule === finding.rule.id) {
|
|
891
|
+
ignore = parsed;
|
|
892
|
+
at = line;
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (ignore === null) {
|
|
897
|
+
result.findings.push(finding);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (ignore.reason === null) {
|
|
901
|
+
const where = `${finding.match.file}:${at}`;
|
|
902
|
+
if (!warned.has(where)) {
|
|
903
|
+
warned.add(where);
|
|
904
|
+
result.warnings.push(`${where}: rulecast-ignore needs a reason after the rule id; the finding was kept`);
|
|
905
|
+
}
|
|
906
|
+
result.findings.push(finding);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
result.ignored.push({ ...finding, reason: ignore.reason });
|
|
910
|
+
}
|
|
911
|
+
return result;
|
|
912
|
+
}
|
|
913
|
+
|
|
824
914
|
// src/core/detection/run.ts
|
|
825
915
|
var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
|
|
826
916
|
async function runDetection(input) {
|
|
827
|
-
const output = { findings: [], errors: [], timedOut: [] };
|
|
917
|
+
const output = { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
|
|
828
918
|
const byKind = /* @__PURE__ */ new Map();
|
|
829
919
|
for (const selection of input.selections) {
|
|
830
920
|
const kind = selection.rule.detector.kind;
|
|
@@ -841,7 +931,7 @@ async function runDetection(input) {
|
|
|
841
931
|
const late = () => Date.now() > deadlineAt;
|
|
842
932
|
const perKind = await Promise.all(
|
|
843
933
|
[...byKind].map(async ([kind, selections]) => {
|
|
844
|
-
const detector = input.registry.get(kind);
|
|
934
|
+
const detector = input.detection.registry.get(kind);
|
|
845
935
|
if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
|
|
846
936
|
try {
|
|
847
937
|
const rules = await Promise.all(
|
|
@@ -849,7 +939,7 @@ async function runDetection(input) {
|
|
|
849
939
|
id: rule.id,
|
|
850
940
|
config: rule.detector.config,
|
|
851
941
|
files,
|
|
852
|
-
context: await input.contextFor(rule)
|
|
942
|
+
context: await input.detection.contextFor(rule)
|
|
853
943
|
}))
|
|
854
944
|
);
|
|
855
945
|
const result = await Promise.race([
|
|
@@ -857,10 +947,11 @@ async function runDetection(input) {
|
|
|
857
947
|
event: input.event,
|
|
858
948
|
rules,
|
|
859
949
|
read: input.read,
|
|
950
|
+
fromDisk: input.fromDisk ?? true,
|
|
860
951
|
changes: input.changes,
|
|
861
|
-
cache: input.cacheFor(kind),
|
|
862
|
-
settings: input.settings,
|
|
863
|
-
cwd: input.root,
|
|
952
|
+
cache: input.detection.cacheFor(kind),
|
|
953
|
+
settings: input.detection.settings,
|
|
954
|
+
cwd: input.detection.root,
|
|
864
955
|
signal: controller.signal,
|
|
865
956
|
deadlineAt
|
|
866
957
|
}),
|
|
@@ -908,20 +999,21 @@ async function runDetection(input) {
|
|
|
908
999
|
if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
|
|
909
1000
|
}
|
|
910
1001
|
}
|
|
911
|
-
|
|
1002
|
+
const suppressed = await suppress(output.findings, input.read);
|
|
1003
|
+
return { ...output, ...suppressed };
|
|
912
1004
|
}
|
|
913
1005
|
|
|
914
1006
|
// src/core/baseline/store.ts
|
|
915
|
-
import
|
|
1007
|
+
import path8 from "path";
|
|
916
1008
|
|
|
917
1009
|
// src/core/jsonl.ts
|
|
918
1010
|
import { mkdir as mkdir3, open, readFile as readFile3 } from "fs/promises";
|
|
919
|
-
import
|
|
1011
|
+
import path7 from "path";
|
|
920
1012
|
var CorruptStoreError = class extends Error {
|
|
921
1013
|
};
|
|
922
1014
|
async function appendRecords(file, records) {
|
|
923
1015
|
if (records.length === 0) return;
|
|
924
|
-
await mkdir3(
|
|
1016
|
+
await mkdir3(path7.dirname(file), { recursive: true });
|
|
925
1017
|
const handle = await open(file, "a+");
|
|
926
1018
|
try {
|
|
927
1019
|
await dropPartialRecord(handle);
|
|
@@ -973,7 +1065,7 @@ async function readRecords(file) {
|
|
|
973
1065
|
|
|
974
1066
|
// src/core/baseline/store.ts
|
|
975
1067
|
function storeFile(sessionDir2) {
|
|
976
|
-
return
|
|
1068
|
+
return path8.join(sessionDir2, "baseline.jsonl");
|
|
977
1069
|
}
|
|
978
1070
|
function encodeLines(lines) {
|
|
979
1071
|
return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
|
|
@@ -984,8 +1076,8 @@ function decodeLines(encoded) {
|
|
|
984
1076
|
aligned.set(bytes);
|
|
985
1077
|
return new Uint32Array(aligned.buffer);
|
|
986
1078
|
}
|
|
987
|
-
function startRecord(commit) {
|
|
988
|
-
return { t: "start", commit };
|
|
1079
|
+
function startRecord(commit, configHash2 = null) {
|
|
1080
|
+
return { t: "start", commit, configHash: configHash2 };
|
|
989
1081
|
}
|
|
990
1082
|
function snapshotRecord(file, snapshot) {
|
|
991
1083
|
return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
|
|
@@ -996,29 +1088,37 @@ function fingerprintRecord(file, rule, ranges) {
|
|
|
996
1088
|
async function appendBaseline(sessionDir2, records) {
|
|
997
1089
|
await appendRecords(storeFile(sessionDir2), records);
|
|
998
1090
|
}
|
|
1091
|
+
function mergeFingerprints(into, records) {
|
|
1092
|
+
for (const record of records) {
|
|
1093
|
+
if (record.t !== "fingerprint") continue;
|
|
1094
|
+
const byRule = into.get(record.file) ?? /* @__PURE__ */ new Map();
|
|
1095
|
+
into.set(record.file, byRule);
|
|
1096
|
+
if (!byRule.has(record.rule)) byRule.set(record.rule, record.ranges);
|
|
1097
|
+
}
|
|
1098
|
+
return into;
|
|
1099
|
+
}
|
|
999
1100
|
async function readBaseline(sessionDir2) {
|
|
1000
1101
|
const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
|
|
1001
|
-
|
|
1102
|
+
const records = await readRecords(storeFile(sessionDir2));
|
|
1103
|
+
for (const record of records) {
|
|
1002
1104
|
if (record.t === "start" && !state.started) {
|
|
1003
1105
|
state.started = true;
|
|
1004
1106
|
state.startCommit = record.commit;
|
|
1107
|
+
state.startConfigHash = record.configHash ?? null;
|
|
1005
1108
|
} else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
|
|
1006
1109
|
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
1110
|
}
|
|
1012
1111
|
}
|
|
1112
|
+
mergeFingerprints(state.fingerprints, records);
|
|
1013
1113
|
return state;
|
|
1014
1114
|
}
|
|
1015
1115
|
|
|
1016
1116
|
// src/core/baseline/record.ts
|
|
1017
1117
|
async function recordFingerprints(input) {
|
|
1018
1118
|
if (!input.rules.some((rule) => rule.scope === "container")) return [];
|
|
1019
|
-
const guards = (kind) => input.registry.get(kind)?.guards === true;
|
|
1119
|
+
const guards = (kind) => input.detection.registry.get(kind)?.guards === true;
|
|
1020
1120
|
let selections = selectDetectorRules(input.rules, input.event, input.files, input.disabled).filter(
|
|
1021
|
-
(selection) => selection.rule.scope === "container" && selection.rule.detector.kind !==
|
|
1121
|
+
(selection) => selection.rule.scope === "container" && input.detection.registry.get(selection.rule.detector.kind)?.metered !== true && (input.source === "disk" || guards(selection.rule.detector.kind))
|
|
1022
1122
|
);
|
|
1023
1123
|
if (input.ceiling) {
|
|
1024
1124
|
const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
|
|
@@ -1026,7 +1126,7 @@ async function recordFingerprints(input) {
|
|
|
1026
1126
|
}
|
|
1027
1127
|
if (selections.length === 0) return [];
|
|
1028
1128
|
const output = await runDetection({
|
|
1029
|
-
|
|
1129
|
+
detection: input.detection,
|
|
1030
1130
|
event: input.event,
|
|
1031
1131
|
selections,
|
|
1032
1132
|
// No change set: the baseline content is being measured whole, and there is nothing it is a
|
|
@@ -1034,10 +1134,6 @@ async function recordFingerprints(input) {
|
|
|
1034
1134
|
// exactly right here.
|
|
1035
1135
|
changes: /* @__PURE__ */ new Map(),
|
|
1036
1136
|
read: input.read,
|
|
1037
|
-
registry: input.registry,
|
|
1038
|
-
cacheFor: input.cacheFor,
|
|
1039
|
-
contextFor: input.contextFor,
|
|
1040
|
-
settings: input.settings,
|
|
1041
1137
|
timeoutMs: input.timeoutMs
|
|
1042
1138
|
});
|
|
1043
1139
|
const unmeasured = /* @__PURE__ */ new Set();
|
|
@@ -1058,9 +1154,86 @@ async function recordFingerprints(input) {
|
|
|
1058
1154
|
return records;
|
|
1059
1155
|
}
|
|
1060
1156
|
|
|
1157
|
+
// src/core/baseline/stage.ts
|
|
1158
|
+
async function touchRecords(input, state, files) {
|
|
1159
|
+
const records = [];
|
|
1160
|
+
const snapshotted = [];
|
|
1161
|
+
for (const file of files) {
|
|
1162
|
+
if (state.snapshots.has(file)) continue;
|
|
1163
|
+
const text = await readSourceFile(input.root, file);
|
|
1164
|
+
if (text === null) continue;
|
|
1165
|
+
records.push(snapshotRecord(file, snapshotOf(text)));
|
|
1166
|
+
snapshotted.push(file);
|
|
1167
|
+
}
|
|
1168
|
+
if (snapshotted.length === 0) return records;
|
|
1169
|
+
records.push(
|
|
1170
|
+
...await recordFingerprints({
|
|
1171
|
+
detection: input.detection,
|
|
1172
|
+
files: snapshotted,
|
|
1173
|
+
rules: input.rules,
|
|
1174
|
+
disabled: input.disabled,
|
|
1175
|
+
read: (file) => readSourceFile(input.root, file),
|
|
1176
|
+
event: "edit",
|
|
1177
|
+
source: "disk",
|
|
1178
|
+
timeoutMs: input.limits.editDeadlineMs,
|
|
1179
|
+
ceiling: { maxFileBytes: input.limits.maxFileBytes, sizeOf: (file) => fileBytes(input.root, file) }
|
|
1180
|
+
})
|
|
1181
|
+
);
|
|
1182
|
+
return records;
|
|
1183
|
+
}
|
|
1184
|
+
async function changesFor(input, event, state, files, read) {
|
|
1185
|
+
let snapshots = state?.snapshots ?? /* @__PURE__ */ new Map();
|
|
1186
|
+
let fallbackCommit = state?.startCommit ?? null;
|
|
1187
|
+
if (event.kind === "verify" && event.baseCommit) {
|
|
1188
|
+
fallbackCommit = event.baseCommit;
|
|
1189
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
1190
|
+
}
|
|
1191
|
+
const changes = await computeChanges(input.root, files, { snapshots, fallbackCommit }, read);
|
|
1192
|
+
const kept = event.kind === "verify" && state !== null && !event.baseCommit ? files.filter((file) => changes.sets.get(file)?.changedLines.length !== 0) : [...files];
|
|
1193
|
+
const fingerprints = new Map(
|
|
1194
|
+
[...state?.fingerprints ?? []].map(([file, byRule]) => [file, new Map(byRule)])
|
|
1195
|
+
);
|
|
1196
|
+
if (event.kind !== "verify" || changes.fromCommit.size === 0) {
|
|
1197
|
+
return { files: kept, changes, fingerprints, records: [] };
|
|
1198
|
+
}
|
|
1199
|
+
const texts = changes.fromCommit;
|
|
1200
|
+
const missing = [...texts.keys()].filter((file) => !fingerprints.has(file));
|
|
1201
|
+
const records = missing.length === 0 ? [] : await recordFingerprints({
|
|
1202
|
+
detection: input.detection,
|
|
1203
|
+
files: missing,
|
|
1204
|
+
rules: input.rules,
|
|
1205
|
+
disabled: input.disabled,
|
|
1206
|
+
read: async (file) => texts.get(file) ?? null,
|
|
1207
|
+
event: "verify",
|
|
1208
|
+
source: "memory",
|
|
1209
|
+
timeoutMs: input.limits.verifyMs
|
|
1210
|
+
});
|
|
1211
|
+
return { files: kept, changes, fingerprints: mergeFingerprints(fingerprints, records), records };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1061
1214
|
// src/core/delivery/persist.ts
|
|
1062
|
-
import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1063
|
-
import
|
|
1215
|
+
import { copyFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1216
|
+
import path9 from "path";
|
|
1217
|
+
var AGENT_DIR = ".rulecast";
|
|
1218
|
+
function agentDir(root) {
|
|
1219
|
+
const dir = path9.join(root, AGENT_DIR);
|
|
1220
|
+
mkdirSync2(dir, { recursive: true });
|
|
1221
|
+
const ignore = path9.join(dir, ".gitignore");
|
|
1222
|
+
if (!existsSync3(ignore)) writeFileSync2(ignore, "# Written by rulecast: files its hooks point an agent at.\n*\n");
|
|
1223
|
+
return dir;
|
|
1224
|
+
}
|
|
1225
|
+
function copyIntoProject(root, home, file) {
|
|
1226
|
+
const rest = path9.relative(home, file);
|
|
1227
|
+
if (rest.startsWith("..") || path9.isAbsolute(rest)) return null;
|
|
1228
|
+
try {
|
|
1229
|
+
const target = path9.join(agentDir(root), rest);
|
|
1230
|
+
mkdirSync2(path9.dirname(target), { recursive: true });
|
|
1231
|
+
copyFileSync(file, target);
|
|
1232
|
+
return path9.join(AGENT_DIR, rest).split(path9.sep).join("/");
|
|
1233
|
+
} catch {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1064
1237
|
var KEEP = 3;
|
|
1065
1238
|
var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
|
|
1066
1239
|
var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
@@ -1076,15 +1249,14 @@ function markdown(delivery, now) {
|
|
|
1076
1249
|
""
|
|
1077
1250
|
].join("\n");
|
|
1078
1251
|
}
|
|
1079
|
-
function writeOverflow(
|
|
1252
|
+
function writeOverflow(dir, session, delivery, now = /* @__PURE__ */ new Date()) {
|
|
1080
1253
|
const prefix = safeSegment2(session ?? "no-session");
|
|
1081
|
-
const
|
|
1082
|
-
const file = path8.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
|
|
1254
|
+
const file = path9.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
|
|
1083
1255
|
try {
|
|
1084
1256
|
mkdirSync2(dir, { recursive: true });
|
|
1085
1257
|
writeFileSync2(file, markdown(delivery, now));
|
|
1086
1258
|
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(
|
|
1259
|
+
for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) rmSync(path9.join(dir, old), { force: true });
|
|
1088
1260
|
return file;
|
|
1089
1261
|
} catch {
|
|
1090
1262
|
return null;
|
|
@@ -1102,16 +1274,16 @@ async function resolveRuleContext(resolver, context) {
|
|
|
1102
1274
|
}
|
|
1103
1275
|
function createReferenceResolver(root) {
|
|
1104
1276
|
const texts = /* @__PURE__ */ new Map();
|
|
1105
|
-
const read = (
|
|
1106
|
-
let text = texts.get(
|
|
1277
|
+
const read = (path15) => {
|
|
1278
|
+
let text = texts.get(path15);
|
|
1107
1279
|
if (!text) {
|
|
1108
|
-
text = readSourceFile(root,
|
|
1109
|
-
texts.set(
|
|
1280
|
+
text = readSourceFile(root, path15);
|
|
1281
|
+
texts.set(path15, text);
|
|
1110
1282
|
}
|
|
1111
1283
|
return text;
|
|
1112
1284
|
};
|
|
1113
|
-
const contentOf = async (
|
|
1114
|
-
const text = await read(
|
|
1285
|
+
const contentOf = async (path15, anchor) => {
|
|
1286
|
+
const text = await read(path15);
|
|
1115
1287
|
if (text === null) return null;
|
|
1116
1288
|
if (anchor === null) return text.trimEnd();
|
|
1117
1289
|
const section = sectionRange(text, anchor);
|
|
@@ -1123,19 +1295,108 @@ function createReferenceResolver(root) {
|
|
|
1123
1295
|
if (content === null) return { spec, found: false };
|
|
1124
1296
|
return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
|
|
1125
1297
|
},
|
|
1126
|
-
async currentHash(
|
|
1127
|
-
const content = await contentOf(
|
|
1298
|
+
async currentHash(path15, anchor) {
|
|
1299
|
+
const content = await contentOf(path15, anchor);
|
|
1128
1300
|
return content === null ? null : fnv1a(content);
|
|
1129
1301
|
},
|
|
1130
|
-
async contains(
|
|
1302
|
+
async contains(path15, parent, child) {
|
|
1131
1303
|
if (parent === null) return true;
|
|
1132
1304
|
if (child === null) return false;
|
|
1133
|
-
const text = await read(
|
|
1305
|
+
const text = await read(path15);
|
|
1134
1306
|
return text !== null && sectionContains(text, parent, child);
|
|
1135
1307
|
}
|
|
1136
1308
|
};
|
|
1137
1309
|
}
|
|
1138
1310
|
|
|
1311
|
+
// src/core/detection/context.ts
|
|
1312
|
+
function detectionFor(project, registry, stateDir, resolver) {
|
|
1313
|
+
return {
|
|
1314
|
+
root: project.root,
|
|
1315
|
+
registry,
|
|
1316
|
+
settings: { llm: project.config.llm },
|
|
1317
|
+
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1318
|
+
contextFor: (rule) => resolveRuleContext(resolver, rule.context)
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// src/core/detection/scratch.ts
|
|
1323
|
+
import { mkdir as mkdir4, mkdtemp, rm as rm3, writeFile } from "fs/promises";
|
|
1324
|
+
import path10 from "path";
|
|
1325
|
+
async function withScratchTree(root, prefix, entries, fn) {
|
|
1326
|
+
const dir = path10.basename(await mkdtemp(path10.join(root, prefix)));
|
|
1327
|
+
try {
|
|
1328
|
+
for (const [file, content] of entries) {
|
|
1329
|
+
const full = path10.join(root, dir, file);
|
|
1330
|
+
await mkdir4(path10.dirname(full), { recursive: true });
|
|
1331
|
+
await writeFile(full, content);
|
|
1332
|
+
}
|
|
1333
|
+
return await fn(dir);
|
|
1334
|
+
} finally {
|
|
1335
|
+
await rm3(path10.join(root, dir), { recursive: true, force: true });
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// src/core/detection/materialise.ts
|
|
1340
|
+
var STAGED_DIR = ".rulecast-staged-";
|
|
1341
|
+
async function runDetectionFrom(input, source) {
|
|
1342
|
+
if (source.kind === "worktree") return runDetection(input);
|
|
1343
|
+
const registry = input.detection.registry;
|
|
1344
|
+
const direct = [];
|
|
1345
|
+
const copied = [];
|
|
1346
|
+
for (const selection of input.selections) {
|
|
1347
|
+
const detector = registry.get(selection.rule.detector.kind);
|
|
1348
|
+
const readsContent = detector?.guards === true || detector?.takesContent?.(selection.rule.detector.config) === true;
|
|
1349
|
+
if (readsContent || detector === void 0) direct.push(selection);
|
|
1350
|
+
else copied.push(selection);
|
|
1351
|
+
}
|
|
1352
|
+
const outputs = await Promise.all([
|
|
1353
|
+
direct.length === 0 ? null : runDetection({ ...input, selections: direct, fromDisk: false }),
|
|
1354
|
+
copied.length === 0 ? null : fromScratch(input, copied)
|
|
1355
|
+
]);
|
|
1356
|
+
return merge(outputs.filter((output) => output !== null));
|
|
1357
|
+
}
|
|
1358
|
+
async function fromScratch(input, selections) {
|
|
1359
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1360
|
+
for (const file of new Set(selections.flatMap((selection) => selection.files))) {
|
|
1361
|
+
const text = await input.read(file);
|
|
1362
|
+
if (text !== null) entries.set(file, text);
|
|
1363
|
+
}
|
|
1364
|
+
if (entries.size === 0) return { findings: [], errors: [], timedOut: [], ignored: [], warnings: [] };
|
|
1365
|
+
return withScratchTree(input.detection.root, STAGED_DIR, entries, async (dir) => {
|
|
1366
|
+
const prefix = `${dir}/`;
|
|
1367
|
+
const strip = (file) => file.startsWith(prefix) ? file.slice(prefix.length) : file;
|
|
1368
|
+
const output = await runDetection({
|
|
1369
|
+
...input,
|
|
1370
|
+
selections: selections.map((selection) => ({
|
|
1371
|
+
...selection,
|
|
1372
|
+
files: selection.files.filter((file) => entries.has(file)).map((file) => prefix + file)
|
|
1373
|
+
})).filter((selection) => selection.files.length > 0),
|
|
1374
|
+
changes: new Map([...input.changes].map(([file, change]) => [prefix + file, change])),
|
|
1375
|
+
// Anything reading through `read` under a scratch path is asking about the real file.
|
|
1376
|
+
read: (file) => input.read(strip(file))
|
|
1377
|
+
});
|
|
1378
|
+
return {
|
|
1379
|
+
findings: output.findings.map(({ rule, match }) => ({ rule, match: { ...match, file: strip(match.file) } })),
|
|
1380
|
+
errors: output.errors.map((error) => ({ ...error, message: error.message.replaceAll(prefix, "") })),
|
|
1381
|
+
timedOut: output.timedOut,
|
|
1382
|
+
ignored: output.ignored.map((ignored) => ({
|
|
1383
|
+
...ignored,
|
|
1384
|
+
match: { ...ignored.match, file: strip(ignored.match.file) }
|
|
1385
|
+
})),
|
|
1386
|
+
warnings: output.warnings.map((warning) => warning.replaceAll(prefix, ""))
|
|
1387
|
+
};
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
function merge(outputs) {
|
|
1391
|
+
return {
|
|
1392
|
+
findings: outputs.flatMap((output) => output.findings),
|
|
1393
|
+
errors: outputs.flatMap((output) => output.errors),
|
|
1394
|
+
timedOut: outputs.flatMap((output) => output.timedOut),
|
|
1395
|
+
ignored: outputs.flatMap((output) => output.ignored),
|
|
1396
|
+
warnings: outputs.flatMap((output) => output.warnings)
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1139
1400
|
// src/core/detection/proposal.ts
|
|
1140
1401
|
function propose(current, intent) {
|
|
1141
1402
|
if (intent.content !== void 0) {
|
|
@@ -1161,8 +1422,8 @@ function propose(current, intent) {
|
|
|
1161
1422
|
}
|
|
1162
1423
|
return { content: content + rest, inserts };
|
|
1163
1424
|
}
|
|
1164
|
-
function isInserted(proposal, match,
|
|
1165
|
-
if (
|
|
1425
|
+
function isInserted(proposal, match, wholeFile) {
|
|
1426
|
+
if (wholeFile) return true;
|
|
1166
1427
|
const starts = lineStarts(proposal.content);
|
|
1167
1428
|
const lineStart = starts[match.line - 1];
|
|
1168
1429
|
if (lineStart === void 0) return false;
|
|
@@ -1172,7 +1433,7 @@ function isInserted(proposal, match, detector) {
|
|
|
1172
1433
|
}
|
|
1173
1434
|
|
|
1174
1435
|
// src/core/session/decide.ts
|
|
1175
|
-
import
|
|
1436
|
+
import path11 from "path";
|
|
1176
1437
|
|
|
1177
1438
|
// src/core/session/state.ts
|
|
1178
1439
|
function preexistingKey(rule, file) {
|
|
@@ -1180,7 +1441,14 @@ function preexistingKey(rule, file) {
|
|
|
1180
1441
|
}
|
|
1181
1442
|
var refusalKey = preexistingKey;
|
|
1182
1443
|
function emptyWork() {
|
|
1183
|
-
return {
|
|
1444
|
+
return {
|
|
1445
|
+
edited: [],
|
|
1446
|
+
stopBlocks: /* @__PURE__ */ new Map(),
|
|
1447
|
+
refusals: /* @__PURE__ */ new Map(),
|
|
1448
|
+
disabled: /* @__PURE__ */ new Map(),
|
|
1449
|
+
accessed: /* @__PURE__ */ new Map(),
|
|
1450
|
+
noticed: /* @__PURE__ */ new Set()
|
|
1451
|
+
};
|
|
1184
1452
|
}
|
|
1185
1453
|
function emptyContext() {
|
|
1186
1454
|
return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
|
|
@@ -1211,6 +1479,9 @@ function foldWork(records) {
|
|
|
1211
1479
|
case "disabled":
|
|
1212
1480
|
if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
|
|
1213
1481
|
break;
|
|
1482
|
+
case "noticed":
|
|
1483
|
+
state.noticed.add(record.key);
|
|
1484
|
+
break;
|
|
1214
1485
|
}
|
|
1215
1486
|
}
|
|
1216
1487
|
return state;
|
|
@@ -1240,15 +1511,10 @@ function foldContext(records) {
|
|
|
1240
1511
|
}
|
|
1241
1512
|
|
|
1242
1513
|
// 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
1514
|
var WARNING_SHARE = 0.15;
|
|
1249
1515
|
var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
|
|
1250
1516
|
function locationOf(spec) {
|
|
1251
|
-
return
|
|
1517
|
+
return path11.isAbsolute(spec.path) ? { location: spec.path } : {};
|
|
1252
1518
|
}
|
|
1253
1519
|
function capturesFor(rule, match) {
|
|
1254
1520
|
const names = templateBindings(rule.message ?? "");
|
|
@@ -1311,37 +1577,38 @@ async function isCovered(spec, delivered, resolver) {
|
|
|
1311
1577
|
}
|
|
1312
1578
|
return false;
|
|
1313
1579
|
}
|
|
1314
|
-
async function
|
|
1580
|
+
async function assemble(input) {
|
|
1315
1581
|
const delivery = emptyDelivery();
|
|
1316
|
-
const work = [];
|
|
1317
1582
|
const context = [];
|
|
1583
|
+
const touches = input.touches ?? [];
|
|
1584
|
+
const state = input.context ?? emptyContext();
|
|
1318
1585
|
const fresh = input.findings.filter((finding) => finding.status === "new");
|
|
1319
1586
|
delivery.findings = renderFindings(fresh);
|
|
1320
|
-
const rulesById = new Map(fresh.map(({ rule }) => [rule.id, rule]));
|
|
1321
1587
|
for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
|
|
1322
1588
|
const summaries = /* @__PURE__ */ new Map();
|
|
1323
1589
|
for (const { rule, match, status } of input.findings) {
|
|
1324
1590
|
if (status !== "preexisting") continue;
|
|
1325
1591
|
const key = preexistingKey(rule.id, match.file);
|
|
1326
|
-
if (
|
|
1592
|
+
if (state.preexisting.has(key)) continue;
|
|
1327
1593
|
const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
|
|
1328
1594
|
summary.count++;
|
|
1329
1595
|
summaries.set(key, summary);
|
|
1330
1596
|
}
|
|
1331
1597
|
delivery.preexistingSummary = [...summaries.values()];
|
|
1332
|
-
const unwarned = input.warnings.filter((warning) => !
|
|
1598
|
+
const unwarned = (input.warnings ?? []).filter((warning) => !state.warned.has(warning.key));
|
|
1333
1599
|
delivery.warnings = unwarned.map((warning) => warning.text);
|
|
1334
|
-
delivery.touches =
|
|
1335
|
-
for (const rule of
|
|
1336
|
-
const delivered = [...
|
|
1337
|
-
|
|
1338
|
-
|
|
1600
|
+
delivery.touches = touches.map((rule) => rule.id);
|
|
1601
|
+
for (const rule of touches) context.push({ t: "touched", rule: rule.id });
|
|
1602
|
+
const delivered = [...state.delivered];
|
|
1603
|
+
const agentRead = input.agentRead ?? null;
|
|
1604
|
+
if (agentRead !== null) {
|
|
1605
|
+
const hash = await input.resolver.currentHash(agentRead, null);
|
|
1339
1606
|
if (hash !== null) {
|
|
1340
|
-
delivered.push({ path:
|
|
1341
|
-
context.push({ t: "delivered", path:
|
|
1607
|
+
delivered.push({ path: agentRead, anchor: null, hash });
|
|
1608
|
+
context.push({ t: "delivered", path: agentRead, anchor: null, hash });
|
|
1342
1609
|
}
|
|
1343
1610
|
}
|
|
1344
|
-
const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...
|
|
1611
|
+
const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...touches];
|
|
1345
1612
|
const candidates = [];
|
|
1346
1613
|
for (const spec of referencesInOrder(ruleOrder)) {
|
|
1347
1614
|
const resolved = await input.resolver.resolve(spec);
|
|
@@ -1358,26 +1625,38 @@ async function decide(input) {
|
|
|
1358
1625
|
delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
|
|
1359
1626
|
}
|
|
1360
1627
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1628
|
+
return { delivery, candidates, fresh, unwarned, touches, context };
|
|
1629
|
+
}
|
|
1630
|
+
function trim(assembled, limits) {
|
|
1631
|
+
const { candidates, unwarned, touches } = assembled;
|
|
1632
|
+
const delivery = {
|
|
1633
|
+
...assembled.delivery,
|
|
1634
|
+
findings: [...assembled.delivery.findings],
|
|
1635
|
+
preexistingSummary: [...assembled.delivery.preexistingSummary],
|
|
1636
|
+
references: [...assembled.delivery.references],
|
|
1637
|
+
warnings: [...assembled.delivery.warnings],
|
|
1369
1638
|
omitted: { findings: [], rules: 0, preexisting: 0 }
|
|
1370
1639
|
};
|
|
1640
|
+
const context = [];
|
|
1641
|
+
const rulesById = new Map(assembled.fresh.map(({ rule }) => [rule.id, rule]));
|
|
1642
|
+
const limit = limits.maxContextChars;
|
|
1643
|
+
const render = { maxMatchesPerRule: limits.maxMatchesPerRule };
|
|
1371
1644
|
const byRule = /* @__PURE__ */ new Map();
|
|
1372
1645
|
for (const finding of delivery.findings) {
|
|
1373
1646
|
const group = byRule.get(finding.rule);
|
|
1374
1647
|
if (group === void 0) byRule.set(finding.rule, [finding]);
|
|
1375
1648
|
else group.push(finding);
|
|
1376
1649
|
}
|
|
1377
|
-
|
|
1650
|
+
const conventions = delivery.references.length > 0 || delivery.preexistingSummary.length > 0;
|
|
1651
|
+
let used = deliveryCost.header(byRule.size, new Set(delivery.findings.map((finding) => finding.file)), conventions) + (delivery.references.length > 0 ? deliveryCost.referencesEnd : 0);
|
|
1378
1652
|
const fits = (size) => limit === null || used + size <= limit;
|
|
1379
1653
|
const kept = /* @__PURE__ */ new Map();
|
|
1380
|
-
const
|
|
1654
|
+
const specs = /* @__PURE__ */ new Map();
|
|
1655
|
+
for (const rule of [...rulesById.values(), ...touches]) for (const spec of rule.context) specs.set(spec.ref, spec);
|
|
1656
|
+
const lineCost = (ref) => {
|
|
1657
|
+
const spec = specs.get(ref);
|
|
1658
|
+
return deliveryCost.referenceLine(ref, spec === void 0 ? void 0 : locationOf(spec).location);
|
|
1659
|
+
};
|
|
1381
1660
|
const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
|
|
1382
1661
|
const charged = /* @__PURE__ */ new Set();
|
|
1383
1662
|
const orphaned = /* @__PURE__ */ new Set();
|
|
@@ -1389,7 +1668,7 @@ async function decide(input) {
|
|
|
1389
1668
|
for (const [id, findings] of byRule) {
|
|
1390
1669
|
const rule = rulesById.get(id);
|
|
1391
1670
|
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);
|
|
1671
|
+
const size = measureRuleBlock(id, [findings[0]], findings, delivery.templates[id], render) + refs.reduce((sum, ref) => sum + lineCost(ref), 0);
|
|
1393
1672
|
if (used + spent.chars + size > cap) {
|
|
1394
1673
|
spent.dropped++;
|
|
1395
1674
|
continue;
|
|
@@ -1398,7 +1677,7 @@ async function decide(input) {
|
|
|
1398
1677
|
for (const ref of refs) spent.refs.add(ref);
|
|
1399
1678
|
spent.kept.set(id, [findings[0]]);
|
|
1400
1679
|
}
|
|
1401
|
-
for (const touch of
|
|
1680
|
+
for (const touch of touches) {
|
|
1402
1681
|
for (const ref of uncharged(touch)) {
|
|
1403
1682
|
spent.chars += lineCost(ref);
|
|
1404
1683
|
spent.refs.add(ref);
|
|
@@ -1408,19 +1687,20 @@ async function decide(input) {
|
|
|
1408
1687
|
};
|
|
1409
1688
|
if (limit !== null) {
|
|
1410
1689
|
let floor = floorWithin(limit);
|
|
1411
|
-
|
|
1412
|
-
|
|
1690
|
+
const notice = deliveryCost.overflowNotice(byRule.size);
|
|
1691
|
+
if (floor.dropped > 0) floor = floorWithin(limit - notice);
|
|
1692
|
+
used += floor.chars + (floor.dropped > 0 ? notice : 0);
|
|
1413
1693
|
delivery.omitted.rules = floor.dropped;
|
|
1414
1694
|
for (const [id, findings] of floor.kept) kept.set(id, findings);
|
|
1415
1695
|
for (const ref of floor.refs) charged.add(ref);
|
|
1416
1696
|
for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
|
|
1417
1697
|
}
|
|
1418
|
-
const warningCost = (text) => text
|
|
1698
|
+
const warningCost = (text) => deliveryCost.warning(text);
|
|
1419
1699
|
if (limit === null) {
|
|
1420
1700
|
for (const warning of unwarned) context.push({ t: "warned", key: warning.key });
|
|
1421
1701
|
} else {
|
|
1422
1702
|
const ceiling = Math.max(0, Math.min(Math.floor(limit * WARNING_SHARE), limit - used));
|
|
1423
|
-
let spent = 0;
|
|
1703
|
+
let spent = unwarned.length > 0 ? deliveryCost.warningsFrame : 0;
|
|
1424
1704
|
let held = 0;
|
|
1425
1705
|
while (held < unwarned.length && spent + warningCost(unwarned[held].text) <= ceiling) {
|
|
1426
1706
|
spent += warningCost(unwarned[held].text);
|
|
@@ -1439,10 +1719,10 @@ async function decide(input) {
|
|
|
1439
1719
|
spent += warningCost(moreWarnings(cut));
|
|
1440
1720
|
delivery.warnings.push(moreWarnings(cut));
|
|
1441
1721
|
}
|
|
1442
|
-
used += spent;
|
|
1722
|
+
used += delivery.warnings.length > 0 ? spent : 0;
|
|
1443
1723
|
}
|
|
1444
1724
|
for (const { index, resolved } of candidates) {
|
|
1445
|
-
const size = resolved.content.
|
|
1725
|
+
const size = deliveryCost.referenceContent(resolved.spec.ref, resolved.content, locationOf(resolved.spec).location);
|
|
1446
1726
|
if (orphaned.has(resolved.spec.ref)) continue;
|
|
1447
1727
|
if (!fits(size)) {
|
|
1448
1728
|
delivery.references[index] = {
|
|
@@ -1456,14 +1736,16 @@ async function decide(input) {
|
|
|
1456
1736
|
used += size;
|
|
1457
1737
|
context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
|
|
1458
1738
|
}
|
|
1459
|
-
|
|
1739
|
+
const frame = delivery.preexistingSummary.length > 0 ? deliveryCost.backlogFrame(delivery.preexistingSummary.length) : 0;
|
|
1740
|
+
if (frame > 0 && !fits(frame)) delivery.preexistingSummary = [];
|
|
1741
|
+
else used += frame;
|
|
1460
1742
|
const summariesKept = [];
|
|
1461
1743
|
for (const summary of delivery.preexistingSummary) {
|
|
1462
|
-
if (!fits(
|
|
1744
|
+
if (!fits(deliveryCost.backlogSummary(summary))) {
|
|
1463
1745
|
delivery.omitted.preexisting++;
|
|
1464
1746
|
continue;
|
|
1465
1747
|
}
|
|
1466
|
-
used +=
|
|
1748
|
+
used += deliveryCost.backlogSummary(summary);
|
|
1467
1749
|
summariesKept.push(summary);
|
|
1468
1750
|
context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
|
|
1469
1751
|
}
|
|
@@ -1474,9 +1756,9 @@ async function decide(input) {
|
|
|
1474
1756
|
added = false;
|
|
1475
1757
|
for (const [rule, findings] of byRule) {
|
|
1476
1758
|
const shown = kept.get(rule);
|
|
1477
|
-
if (shown === void 0 || shown.length >= Math.min(findings.length,
|
|
1759
|
+
if (shown === void 0 || shown.length >= Math.min(findings.length, limits.maxMatchesPerRule)) continue;
|
|
1478
1760
|
const template = delivery.templates[rule];
|
|
1479
|
-
const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
|
|
1761
|
+
const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], findings, template, render) - measureRuleBlock(rule, shown, findings, template, render);
|
|
1480
1762
|
if (!fits(delta)) continue;
|
|
1481
1763
|
used += delta;
|
|
1482
1764
|
shown.push(findings[shown.length]);
|
|
@@ -1496,20 +1778,26 @@ async function decide(input) {
|
|
|
1496
1778
|
}
|
|
1497
1779
|
delivery.findings = delivery.findings.filter((finding) => kept.get(finding.rule)?.includes(finding) === true);
|
|
1498
1780
|
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1781
|
+
return { delivery, overflow: delivery.omitted.rules > 0 ? assembled.delivery : null, context };
|
|
1782
|
+
}
|
|
1783
|
+
function gate(fresh, work, agent, maxBlocks) {
|
|
1784
|
+
if (!fresh.some(({ rule }) => rule.severity === "error")) return { stop: "allow", work: [] };
|
|
1785
|
+
if ((work.stopBlocks.get(agent) ?? 0) < maxBlocks) return { stop: "block", work: [{ t: "stopBlock", agent }] };
|
|
1786
|
+
return { stop: "capReached", work: [] };
|
|
1787
|
+
}
|
|
1788
|
+
async function decide(input) {
|
|
1789
|
+
const assembled = await assemble(input);
|
|
1790
|
+
const trimmed = trim(assembled, {
|
|
1791
|
+
maxContextChars: input.maxContextChars,
|
|
1792
|
+
maxMatchesPerRule: input.maxMatchesPerRule
|
|
1793
|
+
});
|
|
1794
|
+
const context = [...assembled.context, ...trimmed.context];
|
|
1795
|
+
if (!input.stopGate) return { delivery: trimmed.delivery, overflow: trimmed.overflow, work: [], context };
|
|
1796
|
+
const gated = gate(assembled.fresh, input.work, input.agent, input.maxBlocks);
|
|
1797
|
+
const delivery = { ...trimmed.delivery, stop: gated.stop };
|
|
1798
|
+
if (gated.stop === "allow") return { delivery, overflow: null, work: gated.work, context: [] };
|
|
1799
|
+
if (gated.stop === "capReached") return { delivery, overflow: trimmed.overflow, work: gated.work, context: [] };
|
|
1800
|
+
return { delivery, overflow: trimmed.overflow, work: gated.work, context };
|
|
1513
1801
|
}
|
|
1514
1802
|
|
|
1515
1803
|
// src/core/guard.ts
|
|
@@ -1519,7 +1807,7 @@ async function guardWrite(input) {
|
|
|
1519
1807
|
if (file === void 0 || intent === void 0) return emptyDelivery();
|
|
1520
1808
|
const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
|
|
1521
1809
|
if (rules.length === 0) return emptyDelivery();
|
|
1522
|
-
const proposal = propose(await readSourceFile(input.root, file), intent);
|
|
1810
|
+
const proposal = propose(await readSourceFile(input.detection.root, file), intent);
|
|
1523
1811
|
if (proposal === null) {
|
|
1524
1812
|
input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
|
|
1525
1813
|
return emptyDelivery();
|
|
@@ -1532,19 +1820,18 @@ async function guardWrite(input) {
|
|
|
1532
1820
|
const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
|
|
1533
1821
|
if (selections.length === 0) return emptyDelivery();
|
|
1534
1822
|
const output = await runDetection({
|
|
1535
|
-
|
|
1823
|
+
detection: input.detection,
|
|
1536
1824
|
event: "edit",
|
|
1537
1825
|
selections,
|
|
1538
1826
|
changes: /* @__PURE__ */ new Map(),
|
|
1539
1827
|
// 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 },
|
|
1828
|
+
read: async (name) => name === file ? proposal.content : readSourceFile(input.detection.root, name),
|
|
1545
1829
|
timeoutMs: input.config.timeouts.editDeadlineMs
|
|
1546
1830
|
});
|
|
1547
|
-
const
|
|
1831
|
+
const wholeFile = (kind) => input.detection.registry.get(kind)?.wholeFile === true;
|
|
1832
|
+
const evidence = output.findings.filter(
|
|
1833
|
+
({ rule, match }) => isInserted(proposal, match, wholeFile(rule.detector.kind))
|
|
1834
|
+
);
|
|
1548
1835
|
if (evidence.length === 0) return emptyDelivery();
|
|
1549
1836
|
const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
|
|
1550
1837
|
const refusing = evidence.filter(({ rule }) => !spent(rule.id));
|
|
@@ -1552,41 +1839,67 @@ async function guardWrite(input) {
|
|
|
1552
1839
|
input.log(`guard: ${file} has spent its refusals; allowing the write`);
|
|
1553
1840
|
return emptyDelivery();
|
|
1554
1841
|
}
|
|
1555
|
-
const
|
|
1556
|
-
agent: "main",
|
|
1842
|
+
const assembled = await assemble({
|
|
1557
1843
|
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
1844
|
resolver: input.resolver,
|
|
1567
|
-
maxBytes: input.config.context.maxBytes
|
|
1568
|
-
|
|
1845
|
+
maxBytes: input.config.context.maxBytes
|
|
1846
|
+
});
|
|
1847
|
+
const { delivery, context } = trim(assembled, {
|
|
1569
1848
|
maxContextChars: input.maxContextChars,
|
|
1570
|
-
maxMatchesPerRule: input.config.maxMatchesPerRule
|
|
1571
|
-
stopGate: false
|
|
1849
|
+
maxMatchesPerRule: input.config.maxMatchesPerRule
|
|
1572
1850
|
});
|
|
1851
|
+
await input.recordContext(context.filter((record) => record.t === "delivered"));
|
|
1573
1852
|
await input.record(
|
|
1574
1853
|
[...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
|
|
1575
1854
|
);
|
|
1576
1855
|
return delivery;
|
|
1577
1856
|
}
|
|
1578
1857
|
|
|
1858
|
+
// src/core/session/oversight.ts
|
|
1859
|
+
import { createHash as createHash3 } from "crypto";
|
|
1860
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1861
|
+
import path12 from "path";
|
|
1862
|
+
function notices(input) {
|
|
1863
|
+
const out = [];
|
|
1864
|
+
const configKey = `config:${input.currentConfigHash ?? "gone"}`;
|
|
1865
|
+
if (input.startConfigHash !== null && input.startConfigHash !== input.currentConfigHash && !input.told.has(configKey)) {
|
|
1866
|
+
out.push({
|
|
1867
|
+
key: configKey,
|
|
1868
|
+
text: `rulecast: ${CONFIG_FILE} changed during this session; findings may have been silenced. Review: git diff ${CONFIG_FILE}`
|
|
1869
|
+
});
|
|
1870
|
+
}
|
|
1871
|
+
const keyOf = ({ rule, match }) => `ignore:${rule}:${match.file}:${match.line}`;
|
|
1872
|
+
const added = input.ignored.filter((ignored) => ignored.status === "new" && !input.told.has(keyOf(ignored)));
|
|
1873
|
+
if (added.length > 0) {
|
|
1874
|
+
const sites = added.map(({ rule, match, reason }) => `${match.file}:${match.line} (${rule}: "${reason}")`);
|
|
1875
|
+
const comments = added.length === 1 ? "a rulecast-ignore comment" : `${added.length} rulecast-ignore comments`;
|
|
1876
|
+
out.push({
|
|
1877
|
+
key: added.map(keyOf).join("\n"),
|
|
1878
|
+
text: `rulecast: the agent added ${comments} this session: ${sites.join(", ")}`
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
return out;
|
|
1882
|
+
}
|
|
1883
|
+
async function configHash(root) {
|
|
1884
|
+
try {
|
|
1885
|
+
return createHash3("sha256").update(await readFile4(path12.join(root, CONFIG_FILE))).digest("hex");
|
|
1886
|
+
} catch (error) {
|
|
1887
|
+
if (isNotFound(error)) return null;
|
|
1888
|
+
throw error;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1579
1892
|
// src/core/session/session.ts
|
|
1580
|
-
import
|
|
1893
|
+
import path13 from "path";
|
|
1581
1894
|
function safeSegment3(value) {
|
|
1582
1895
|
const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1583
1896
|
return /^\.*$/.test(segment) ? `_${segment}` : segment;
|
|
1584
1897
|
}
|
|
1585
1898
|
function sessionDir(stateDir, sessionId) {
|
|
1586
|
-
return
|
|
1899
|
+
return path13.join(stateDir, "sessions", safeSegment3(sessionId));
|
|
1587
1900
|
}
|
|
1588
|
-
var workFile = (dir) =>
|
|
1589
|
-
var contextFile = (dir, agent) =>
|
|
1901
|
+
var workFile = (dir) => path13.join(dir, "work.jsonl");
|
|
1902
|
+
var contextFile = (dir, agent) => path13.join(dir, `context.${safeSegment3(agent)}.jsonl`);
|
|
1590
1903
|
async function appendWork(dir, records) {
|
|
1591
1904
|
await appendRecords(workFile(dir), records);
|
|
1592
1905
|
}
|
|
@@ -1613,7 +1926,7 @@ async function commitSession(dir, agent, decideWith) {
|
|
|
1613
1926
|
var SUMMARY_THRESHOLD = 3;
|
|
1614
1927
|
var DEFAULT_HINT = "rulecast validate";
|
|
1615
1928
|
function summaryKey(prefix, ids) {
|
|
1616
|
-
const digest =
|
|
1929
|
+
const digest = createHash4("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
|
|
1617
1930
|
return `${prefix}:${digest}`;
|
|
1618
1931
|
}
|
|
1619
1932
|
function compileWarnings(errors) {
|
|
@@ -1636,8 +1949,9 @@ First: ${diagnosticText(errors[0])}`
|
|
|
1636
1949
|
}
|
|
1637
1950
|
];
|
|
1638
1951
|
}
|
|
1639
|
-
function detectorWarnings(errors) {
|
|
1640
|
-
const
|
|
1952
|
+
function detectorWarnings(errors, scope) {
|
|
1953
|
+
const outcome = scope === "session" ? "Disabled for this session." : "Skipped for this run.";
|
|
1954
|
+
const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. ${outcome}`;
|
|
1641
1955
|
const byKind = /* @__PURE__ */ new Map();
|
|
1642
1956
|
for (const error of errors) {
|
|
1643
1957
|
const group = byKind.get(error.kind);
|
|
@@ -1658,7 +1972,7 @@ function detectorWarnings(errors) {
|
|
|
1658
1972
|
const ids = group.flatMap((error) => error.rules);
|
|
1659
1973
|
summarised.push({
|
|
1660
1974
|
key: summaryKey(`detector:${kind}`, ids),
|
|
1661
|
-
text: `${ids.length} ${kind} rules were disabled this session \u2014 see debug.log.
|
|
1975
|
+
text: `${ids.length} ${kind} rules ${scope === "session" ? "were disabled this session" : "were skipped in this run"} \u2014 see debug.log.
|
|
1662
1976
|
First: ${text(group[0])}`
|
|
1663
1977
|
});
|
|
1664
1978
|
}
|
|
@@ -1705,7 +2019,7 @@ async function runPipeline(options) {
|
|
|
1705
2019
|
const dir = session.dir;
|
|
1706
2020
|
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
1707
2021
|
if (session && !baseline.started) {
|
|
1708
|
-
await appendBaseline(dir, [startRecord(await headCommit(root))]);
|
|
2022
|
+
await appendBaseline(dir, [startRecord(await headCommit(root), await configHash(root))]);
|
|
1709
2023
|
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
1710
2024
|
}
|
|
1711
2025
|
}
|
|
@@ -1713,15 +2027,14 @@ async function runPipeline(options) {
|
|
|
1713
2027
|
const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
|
|
1714
2028
|
const disabled = new Set(view.work.disabled.keys());
|
|
1715
2029
|
const resolver = createReferenceResolver(root);
|
|
2030
|
+
const detection = detectionFor(project, registry, stateDir, resolver);
|
|
1716
2031
|
const workRecords = [];
|
|
1717
2032
|
const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
|
|
1718
2033
|
if (event.kind === "guard") {
|
|
1719
2034
|
const open2 = session;
|
|
1720
2035
|
const delivery = await guardWrite({
|
|
1721
|
-
|
|
2036
|
+
detection,
|
|
1722
2037
|
config,
|
|
1723
|
-
registry,
|
|
1724
|
-
stateDir,
|
|
1725
2038
|
event,
|
|
1726
2039
|
rules: project.rules,
|
|
1727
2040
|
disabled,
|
|
@@ -1730,12 +2043,16 @@ async function runPipeline(options) {
|
|
|
1730
2043
|
maxContextChars: options.maxContextChars,
|
|
1731
2044
|
log,
|
|
1732
2045
|
record: open2 ? (records) => appendWork(open2.dir, records) : async () => {
|
|
2046
|
+
},
|
|
2047
|
+
recordContext: open2 ? (records) => appendContext(open2.dir, open2.agent, records) : async () => {
|
|
1733
2048
|
}
|
|
1734
2049
|
});
|
|
1735
2050
|
return { delivery, failed, deadlineMissed };
|
|
1736
2051
|
}
|
|
1737
2052
|
let touches = [];
|
|
1738
2053
|
let findings = [];
|
|
2054
|
+
let ignored = [];
|
|
2055
|
+
let told = [];
|
|
1739
2056
|
let agentRead = null;
|
|
1740
2057
|
if (event.kind === "touch" || event.kind === "edit") {
|
|
1741
2058
|
touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
|
|
@@ -1751,96 +2068,55 @@ async function runPipeline(options) {
|
|
|
1751
2068
|
const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
|
|
1752
2069
|
touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
|
|
1753
2070
|
}
|
|
2071
|
+
const baselineInput = {
|
|
2072
|
+
root,
|
|
2073
|
+
detection,
|
|
2074
|
+
rules: project.rules,
|
|
2075
|
+
disabled,
|
|
2076
|
+
limits: {
|
|
2077
|
+
editDeadlineMs: config.timeouts.editDeadlineMs,
|
|
2078
|
+
verifyMs: config.timeouts.verifyMs,
|
|
2079
|
+
maxFileBytes: config.maxFileBytes
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
1754
2082
|
if (event.kind === "touch") {
|
|
1755
2083
|
if (event.completeRead) agentRead = event.files[0] ?? null;
|
|
1756
|
-
if (session)
|
|
1757
|
-
const records = [];
|
|
1758
|
-
const snapshotted = [];
|
|
1759
|
-
for (const file of relevant(event.files)) {
|
|
1760
|
-
if (baseline.snapshots.has(file)) continue;
|
|
1761
|
-
const text = await readSourceFile(root, file);
|
|
1762
|
-
if (text === null) continue;
|
|
1763
|
-
records.push(snapshotRecord(file, snapshotOf(text)));
|
|
1764
|
-
snapshotted.push(file);
|
|
1765
|
-
}
|
|
1766
|
-
if (snapshotted.length > 0) {
|
|
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
|
-
);
|
|
1784
|
-
}
|
|
1785
|
-
await appendBaseline(session.dir, records);
|
|
1786
|
-
}
|
|
2084
|
+
if (session) await appendBaseline(session.dir, await touchRecords(baselineInput, baseline, relevant(event.files)));
|
|
1787
2085
|
}
|
|
1788
2086
|
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;
|
|
2087
|
+
let requested = relevant(event.files);
|
|
1792
2088
|
if (event.kind === "edit" && session) {
|
|
1793
2089
|
await appendWork(
|
|
1794
2090
|
session.dir,
|
|
1795
|
-
|
|
2091
|
+
requested.map((file) => ({ t: "edited", file }))
|
|
1796
2092
|
);
|
|
1797
2093
|
}
|
|
1798
|
-
if (event.kind === "verify")
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2094
|
+
if (event.kind === "verify" && event.files.length === 0 && session) requested = relevant(view.work.edited);
|
|
2095
|
+
const read = contentReader(root, event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE);
|
|
2096
|
+
const stage = await changesFor(
|
|
2097
|
+
baselineInput,
|
|
2098
|
+
{ kind: event.kind, baseCommit: event.baseCommit },
|
|
2099
|
+
session ? baseline : null,
|
|
2100
|
+
requested,
|
|
2101
|
+
read
|
|
2102
|
+
);
|
|
2103
|
+
const { files, changes: baselineChanges, fingerprints } = stage;
|
|
1806
2104
|
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
|
-
}
|
|
2105
|
+
if (session && stage.records.length > 0) await appendBaseline(session.dir, stage.records);
|
|
1835
2106
|
const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
|
|
1836
2107
|
let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
|
|
1837
2108
|
(selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
|
|
1838
2109
|
);
|
|
1839
|
-
|
|
2110
|
+
const overBudget = [];
|
|
1840
2111
|
if (event.kind === "verify") {
|
|
1841
|
-
const
|
|
1842
|
-
selections
|
|
1843
|
-
|
|
2112
|
+
const recent = [...view.work.edited].reverse();
|
|
2113
|
+
for (const kind of new Set(selections.map((selection) => selection.rule.detector.kind))) {
|
|
2114
|
+
const budget = registry.get(kind)?.fileBudget?.(detection.settings);
|
|
2115
|
+
if (budget === void 0) continue;
|
|
2116
|
+
const budgeted = applyFileBudget(kind, selections, budget.max, recent);
|
|
2117
|
+
selections = budgeted.selections;
|
|
2118
|
+
if (budgeted.skipped.length > 0) overBudget.push({ kind, ...budget, skipped: budgeted.skipped });
|
|
2119
|
+
}
|
|
1844
2120
|
}
|
|
1845
2121
|
if (event.kind === "edit") {
|
|
1846
2122
|
const ceiling = await applySizeCeiling(
|
|
@@ -1854,35 +2130,54 @@ async function runPipeline(options) {
|
|
|
1854
2130
|
log(`over max_file_bytes (${config.maxFileBytes}), not checked on this edit: ${ceiling.skipped.join(", ")}`);
|
|
1855
2131
|
}
|
|
1856
2132
|
}
|
|
1857
|
-
const output = await
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
});
|
|
2133
|
+
const output = await runDetectionFrom(
|
|
2134
|
+
{
|
|
2135
|
+
detection,
|
|
2136
|
+
event: event.kind,
|
|
2137
|
+
selections,
|
|
2138
|
+
changes,
|
|
2139
|
+
read,
|
|
2140
|
+
timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
|
|
2141
|
+
},
|
|
2142
|
+
event.kind === "verify" ? event.content ?? WORKTREE : WORKTREE
|
|
2143
|
+
);
|
|
1869
2144
|
findings = output.findings.map(({ rule, match }) => ({
|
|
1870
2145
|
rule,
|
|
1871
2146
|
match,
|
|
1872
|
-
status: classify(match, rule.scope, rule.id, baselineChanges,
|
|
2147
|
+
status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
|
|
1873
2148
|
}));
|
|
1874
2149
|
for (const error of output.errors) {
|
|
1875
2150
|
failed = true;
|
|
1876
2151
|
log(`${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}`);
|
|
1877
2152
|
for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
|
|
1878
2153
|
}
|
|
1879
|
-
warnings.push(...detectorWarnings(output.errors));
|
|
1880
|
-
|
|
2154
|
+
warnings.push(...detectorWarnings(output.errors, session === null ? "run" : "session"));
|
|
2155
|
+
ignored = output.ignored;
|
|
2156
|
+
if (options.stopGate === true && event.kind === "verify" && session !== null) {
|
|
2157
|
+
told = notices({
|
|
2158
|
+
startConfigHash: baseline.startConfigHash ?? null,
|
|
2159
|
+
currentConfigHash: await configHash(root),
|
|
2160
|
+
ignored: ignored.map(({ rule, match, reason }) => ({
|
|
2161
|
+
rule: rule.id,
|
|
2162
|
+
match,
|
|
2163
|
+
reason,
|
|
2164
|
+
status: classify(match, rule.scope, rule.id, baselineChanges, fingerprints)
|
|
2165
|
+
})),
|
|
2166
|
+
told: view.work.noticed
|
|
2167
|
+
});
|
|
2168
|
+
for (const notice of told) for (const key of notice.key.split("\n")) workRecords.push({ t: "noticed", key });
|
|
2169
|
+
}
|
|
2170
|
+
for (const text of output.warnings) warnings.push({ key: `ignore:${text}`, text });
|
|
2171
|
+
for (const { kind, max, setting, skipped } of overBudget) {
|
|
1881
2172
|
warnings.push({
|
|
1882
|
-
key:
|
|
1883
|
-
text:
|
|
2173
|
+
key: `${kind}-budget:${skipped.join(",")}`,
|
|
2174
|
+
text: `${kind} rules checked ${max} files; ${skipped.length} were not checked: ${skipped.join(", ")}. Raise ${setting}, or run rulecast run --files on them.`
|
|
1884
2175
|
});
|
|
1885
2176
|
}
|
|
2177
|
+
const hintFor = (kind) => {
|
|
2178
|
+
const hint = registry.get(kind)?.timeoutHint;
|
|
2179
|
+
return hint === void 0 ? "" : ` (${hint})`;
|
|
2180
|
+
};
|
|
1886
2181
|
for (const timeout of output.timedOut) {
|
|
1887
2182
|
if (event.kind === "edit") {
|
|
1888
2183
|
log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
|
|
@@ -1893,7 +2188,7 @@ async function runPipeline(options) {
|
|
|
1893
2188
|
key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
|
|
1894
2189
|
// Naming the lever matters: a field trial measured a single haiku call on a 135-line file
|
|
1895
2190
|
// 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
|
|
2191
|
+
text: `${timeout.kind} detector timed out after ${config.timeouts.verifyMs} ms for ${timeout.rules.join(", ")}. Raise timeouts.verify_ms${hintFor(timeout.kind)}.`
|
|
1897
2192
|
});
|
|
1898
2193
|
}
|
|
1899
2194
|
}
|
|
@@ -1913,17 +2208,30 @@ async function runPipeline(options) {
|
|
|
1913
2208
|
maxMatchesPerRule: config.maxMatchesPerRule,
|
|
1914
2209
|
stopGate: options.stopGate === true && event.kind === "verify" && session !== null
|
|
1915
2210
|
});
|
|
2211
|
+
const home = path14.dirname(path14.dirname(stateDir));
|
|
1916
2212
|
const persisted = (decision) => {
|
|
1917
2213
|
if (decision.overflow !== null) {
|
|
1918
|
-
|
|
2214
|
+
let written = null;
|
|
2215
|
+
try {
|
|
2216
|
+
written = writeOverflow(path14.join(agentDir(root), "deliveries"), event.session?.id ?? null, decision.overflow);
|
|
2217
|
+
} catch {
|
|
2218
|
+
written = null;
|
|
2219
|
+
}
|
|
2220
|
+
decision.delivery.overflowPath = written === null ? null : path14.relative(root, written).split(path14.sep).join("/");
|
|
2221
|
+
}
|
|
2222
|
+
for (const reference of decision.delivery.references) {
|
|
2223
|
+
if (reference.location === void 0 || !path14.isAbsolute(reference.location)) continue;
|
|
2224
|
+
const copied = copyIntoProject(root, home, reference.location);
|
|
2225
|
+
if (copied !== null) reference.location = copied;
|
|
1919
2226
|
}
|
|
2227
|
+
if (told.length > 0) decision.delivery.notices = told.map((notice) => notice.text);
|
|
1920
2228
|
return decision.delivery;
|
|
1921
2229
|
};
|
|
1922
|
-
if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
|
|
2230
|
+
if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed, ignored };
|
|
1923
2231
|
await appendWork(session.dir, workRecords);
|
|
1924
2232
|
try {
|
|
1925
2233
|
const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
|
|
1926
|
-
return { delivery: persisted(decision), failed, deadlineMissed };
|
|
2234
|
+
return { delivery: persisted(decision), failed, deadlineMissed, ignored };
|
|
1927
2235
|
} catch (error) {
|
|
1928
2236
|
if (error instanceof CorruptStoreError) {
|
|
1929
2237
|
log(`session store unreadable: ${error.message}`);
|
|
@@ -1932,7 +2240,7 @@ async function runPipeline(options) {
|
|
|
1932
2240
|
warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
|
|
1933
2241
|
} else throw error;
|
|
1934
2242
|
const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
|
|
1935
|
-
return { delivery: persisted(decision), failed, deadlineMissed };
|
|
2243
|
+
return { delivery: persisted(decision), failed, deadlineMissed, ignored };
|
|
1936
2244
|
}
|
|
1937
2245
|
}
|
|
1938
2246
|
|
|
@@ -1940,10 +2248,12 @@ export {
|
|
|
1940
2248
|
defaultConfig,
|
|
1941
2249
|
CONFIG_FILE,
|
|
1942
2250
|
MANIFEST_FILE,
|
|
2251
|
+
readYamlFile,
|
|
1943
2252
|
readConfigData,
|
|
1944
2253
|
parseConfig,
|
|
1945
2254
|
readManifest,
|
|
1946
2255
|
git,
|
|
2256
|
+
headCommit,
|
|
1947
2257
|
mergeBase,
|
|
1948
2258
|
changedFilesSince,
|
|
1949
2259
|
allFiles,
|
|
@@ -1964,14 +2274,16 @@ export {
|
|
|
1964
2274
|
diagnosticText,
|
|
1965
2275
|
compile,
|
|
1966
2276
|
compileManifest,
|
|
2277
|
+
createReferenceResolver,
|
|
1967
2278
|
rulesOfKind,
|
|
1968
2279
|
checkableKinds,
|
|
1969
2280
|
checkDetectors,
|
|
2281
|
+
detectionFor,
|
|
1970
2282
|
runDetection,
|
|
1971
2283
|
cachedRepos,
|
|
1972
2284
|
fetchingRepos,
|
|
1973
2285
|
fixedRepo,
|
|
1974
|
-
|
|
1975
|
-
|
|
2286
|
+
WORKTREE,
|
|
2287
|
+
withScratchTree,
|
|
1976
2288
|
runPipeline
|
|
1977
2289
|
};
|