@syv-ai/rulecast 0.1.1 → 0.3.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 +49 -2
- package/dist/chunk-7KUPYT35.js +1977 -0
- package/dist/chunk-HZUATJ53.js +947 -0
- package/dist/chunk-KOUHBT7F.js +1361 -0
- package/dist/cli.js +326 -42
- package/dist/index.d.ts +33 -763
- package/dist/index.js +15 -37
- package/dist/internal.d.ts +136 -0
- package/dist/internal.js +38 -0
- package/dist/types-C8b7hrnG.d.ts +776 -0
- package/package.json +8 -2
- package/dist/chunk-3HRTZULM.js +0 -3780
|
@@ -0,0 +1,1977 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BACKLOG_HINT,
|
|
3
|
+
DeadlineError,
|
|
4
|
+
LLM_PROVIDERS,
|
|
5
|
+
VERSION,
|
|
6
|
+
compileFilter,
|
|
7
|
+
compileRule,
|
|
8
|
+
detectorCacheDir,
|
|
9
|
+
diskCache,
|
|
10
|
+
emptyDelivery,
|
|
11
|
+
errorMessage,
|
|
12
|
+
fileBytes,
|
|
13
|
+
formatZodError,
|
|
14
|
+
isDetectorRule,
|
|
15
|
+
isNotFound,
|
|
16
|
+
isOlder,
|
|
17
|
+
lineStarts,
|
|
18
|
+
measureRuleBlock,
|
|
19
|
+
readSourceFile,
|
|
20
|
+
referenceInputSchema,
|
|
21
|
+
renderAgentText,
|
|
22
|
+
renderTemplate,
|
|
23
|
+
sectionContains,
|
|
24
|
+
sectionRange,
|
|
25
|
+
sectionText,
|
|
26
|
+
templateBindings
|
|
27
|
+
} from "./chunk-HZUATJ53.js";
|
|
28
|
+
|
|
29
|
+
// src/core/config/load.ts
|
|
30
|
+
import { readFile } from "fs/promises";
|
|
31
|
+
import path from "path";
|
|
32
|
+
import { parse } from "yaml";
|
|
33
|
+
|
|
34
|
+
// src/core/config/schema.ts
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
var STAGES = ["touch", "edit", "verify"];
|
|
37
|
+
var RULE_ID = /^[a-z0-9-]+(\/[a-z0-9-]+)*$/;
|
|
38
|
+
var RULE_SCOPES = ["instance", "container"];
|
|
39
|
+
var idSchema = z.string().regex(RULE_ID, "must be lowercase segments separated by /");
|
|
40
|
+
var versionSchema = z.string().regex(/^\d+\.\d+\.\d+$/, "must be a version like 0.2.0");
|
|
41
|
+
var stagesSchema = z.array(z.enum(STAGES)).nonempty();
|
|
42
|
+
var exampleSchema = z.object({ path: z.string().min(1), code: z.string() }).strict();
|
|
43
|
+
var ruleKeys = {
|
|
44
|
+
alias: idSchema.optional(),
|
|
45
|
+
name: z.string().min(1).optional(),
|
|
46
|
+
description: z.string().optional(),
|
|
47
|
+
files: z.string().optional(),
|
|
48
|
+
exclude: z.string().optional(),
|
|
49
|
+
types: z.array(z.string()).optional(),
|
|
50
|
+
types_or: z.array(z.string()).optional(),
|
|
51
|
+
exclude_types: z.array(z.string()).optional(),
|
|
52
|
+
stages: stagesSchema.optional(),
|
|
53
|
+
minimum_rulecast_version: versionSchema.optional(),
|
|
54
|
+
severity: z.enum(["error", "warning"]).optional(),
|
|
55
|
+
/**
|
|
56
|
+
* What the convention belongs to (§8). `instance`: the token the detector matched, classified by
|
|
57
|
+
* whether the agent's edit touched it. `container`: the node the match spans, classified by
|
|
58
|
+
* whether that node already violated the rule at the baseline.
|
|
59
|
+
*/
|
|
60
|
+
scope: z.enum(RULE_SCOPES).optional(),
|
|
61
|
+
refuse_write: z.boolean().optional(),
|
|
62
|
+
detect: z.record(z.unknown()).refine((value) => Object.keys(value).length === 1, "must name exactly one detector").optional(),
|
|
63
|
+
message: z.string().optional(),
|
|
64
|
+
context: z.array(referenceInputSchema).optional(),
|
|
65
|
+
/** Code the rule must flag (`bad`) and must not (`good`), run by `rulecast test`. */
|
|
66
|
+
examples: z.object({ good: z.array(exampleSchema).default([]), bad: z.array(exampleSchema).default([]) }).strict().optional()
|
|
67
|
+
};
|
|
68
|
+
var overrideSchema = z.object({ id: idSchema, ...ruleKeys }).strict();
|
|
69
|
+
var ruleSchema = overrideSchema.extend({ name: z.string().min(1) });
|
|
70
|
+
var repoSchema = z.object({
|
|
71
|
+
repo: z.string().min(1),
|
|
72
|
+
rev: z.string().min(1).optional(),
|
|
73
|
+
/** Parsed rule by rule in compile, so one bad rule does not reject the config. */
|
|
74
|
+
rules: z.array(z.unknown())
|
|
75
|
+
}).strict();
|
|
76
|
+
var configSchema = z.object({
|
|
77
|
+
repos: z.array(repoSchema),
|
|
78
|
+
minimum_rulecast_version: versionSchema.optional(),
|
|
79
|
+
files: z.string().default(""),
|
|
80
|
+
exclude: z.string().default("^$"),
|
|
81
|
+
default_stages: stagesSchema.optional(),
|
|
82
|
+
context: z.object({
|
|
83
|
+
mode: z.enum(["inject", "read"]).default("inject"),
|
|
84
|
+
max_bytes: z.number().int().positive().default(32768)
|
|
85
|
+
}).strict().default({}),
|
|
86
|
+
max_matches_per_rule: z.number().int().positive().default(10),
|
|
87
|
+
/**
|
|
88
|
+
* Files above this many bytes are skipped by the in-process detectors on edit and guard, where
|
|
89
|
+
* an agent is waiting. `ast-grep` parses in native code, which the vm timeout that bounds
|
|
90
|
+
* `regex` cannot interrupt: a 2.6 MB TypeScript file measured 762 ms, and the cost is linear,
|
|
91
|
+
* so 26 MB would be 7.6 s of a blocked write. verify has seconds to spend and always runs.
|
|
92
|
+
*/
|
|
93
|
+
max_file_bytes: z.number().int().positive().default(1048576),
|
|
94
|
+
timeouts: z.object({
|
|
95
|
+
edit_deadline_ms: z.number().int().positive().default(350),
|
|
96
|
+
verify_ms: z.number().int().positive().default(6e4)
|
|
97
|
+
}).strict().default({}),
|
|
98
|
+
stop_gate: z.object({ max_blocks: z.number().int().nonnegative().default(1) }).strict().default({}),
|
|
99
|
+
refuse_gate: z.object({ max_refusals: z.number().int().nonnegative().default(1) }).strict().default({}),
|
|
100
|
+
llm: z.object({
|
|
101
|
+
// No `model`: every llm rule names its own (plan 6a, Decision 1).
|
|
102
|
+
provider: z.enum(LLM_PROVIDERS).default("claude-code"),
|
|
103
|
+
base_url: z.string().nullable().default(null),
|
|
104
|
+
api_key_env: z.string().default("ANTHROPIC_API_KEY"),
|
|
105
|
+
max_files_per_verify: z.number().int().positive().default(10)
|
|
106
|
+
}).strict().default({})
|
|
107
|
+
}).strict().transform((input) => ({
|
|
108
|
+
repos: input.repos,
|
|
109
|
+
minimumRulecastVersion: input.minimum_rulecast_version ?? null,
|
|
110
|
+
files: input.files,
|
|
111
|
+
exclude: input.exclude,
|
|
112
|
+
defaultStages: input.default_stages ?? null,
|
|
113
|
+
context: { mode: input.context.mode, maxBytes: input.context.max_bytes },
|
|
114
|
+
maxMatchesPerRule: input.max_matches_per_rule,
|
|
115
|
+
maxFileBytes: input.max_file_bytes,
|
|
116
|
+
timeouts: { editDeadlineMs: input.timeouts.edit_deadline_ms, verifyMs: input.timeouts.verify_ms },
|
|
117
|
+
stopGate: { maxBlocks: input.stop_gate.max_blocks },
|
|
118
|
+
refuseGate: { maxRefusals: input.refuse_gate.max_refusals },
|
|
119
|
+
llm: {
|
|
120
|
+
provider: input.llm.provider,
|
|
121
|
+
baseUrl: input.llm.base_url,
|
|
122
|
+
apiKeyEnv: input.llm.api_key_env,
|
|
123
|
+
maxFilesPerVerify: input.llm.max_files_per_verify
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
function defaultConfig() {
|
|
127
|
+
return configSchema.parse({ repos: [] });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/core/config/load.ts
|
|
131
|
+
var CONFIG_FILE = ".rulecast-config.yaml";
|
|
132
|
+
var MANIFEST_FILE = ".rulecast-rules.yaml";
|
|
133
|
+
async function readYamlFile(file) {
|
|
134
|
+
const name = path.basename(file);
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
text = await readFile(file, "utf8");
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (isNotFound(error)) return { ok: false, message: `${name} not found` };
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return { ok: true, value: parse(text) ?? null };
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return { ok: false, message: `${name}: ${errorMessage(error)}` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function readConfigData(root) {
|
|
149
|
+
return readYamlFile(path.join(root, CONFIG_FILE));
|
|
150
|
+
}
|
|
151
|
+
function parseConfig(data) {
|
|
152
|
+
const result = configSchema.safeParse(data);
|
|
153
|
+
if (!result.success) return { ok: false, message: `${CONFIG_FILE}: ${formatZodError(result.error)}` };
|
|
154
|
+
return { ok: true, value: result.data };
|
|
155
|
+
}
|
|
156
|
+
async function readManifest(dir) {
|
|
157
|
+
const loaded = await readYamlFile(path.join(dir, MANIFEST_FILE));
|
|
158
|
+
if (!loaded.ok) return loaded;
|
|
159
|
+
if (!Array.isArray(loaded.value)) return { ok: false, message: `${MANIFEST_FILE}: must be a list of rules` };
|
|
160
|
+
return { ok: true, value: loaded.value };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/core/home.ts
|
|
164
|
+
import { createHash } from "crypto";
|
|
165
|
+
import { appendFileSync, existsSync, mkdirSync, realpathSync, writeFileSync } from "fs";
|
|
166
|
+
import { homedir } from "os";
|
|
167
|
+
import path2 from "path";
|
|
168
|
+
function cacheHome(env) {
|
|
169
|
+
if (env.RULECAST_HOME) return path2.resolve(env.RULECAST_HOME);
|
|
170
|
+
if (env.XDG_CACHE_HOME) return path2.join(path2.resolve(env.XDG_CACHE_HOME), "rulecast");
|
|
171
|
+
return path2.join(homedir(), ".cache", "rulecast");
|
|
172
|
+
}
|
|
173
|
+
function projectStateDir(home, root) {
|
|
174
|
+
const hash = createHash("sha256").update(realpathSync(root)).digest("hex").slice(0, 16);
|
|
175
|
+
return path2.join(home, "projects", hash);
|
|
176
|
+
}
|
|
177
|
+
function ensureProjectState(home, root) {
|
|
178
|
+
const dir = projectStateDir(home, root);
|
|
179
|
+
const file = path2.join(dir, "root");
|
|
180
|
+
if (!existsSync(file)) {
|
|
181
|
+
mkdirSync(dir, { recursive: true });
|
|
182
|
+
writeFileSync(file, `${realpathSync(root)}
|
|
183
|
+
`);
|
|
184
|
+
}
|
|
185
|
+
return dir;
|
|
186
|
+
}
|
|
187
|
+
function debugLogger(stateDir, now = () => /* @__PURE__ */ new Date()) {
|
|
188
|
+
const file = path2.join(stateDir, "debug.log");
|
|
189
|
+
return (line) => {
|
|
190
|
+
try {
|
|
191
|
+
appendFileSync(file, `${now().toISOString()} ${line}
|
|
192
|
+
`);
|
|
193
|
+
} catch {
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/core/compile/project.ts
|
|
199
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
200
|
+
function diagnosticText(diagnostic) {
|
|
201
|
+
const rule = diagnostic.rule ? ` (${diagnostic.rule})` : "";
|
|
202
|
+
return `${diagnostic.source}${rule}: ${diagnostic.message}`;
|
|
203
|
+
}
|
|
204
|
+
function createReader() {
|
|
205
|
+
const texts = /* @__PURE__ */ new Map();
|
|
206
|
+
return (file) => {
|
|
207
|
+
let text = texts.get(file);
|
|
208
|
+
if (!text) {
|
|
209
|
+
text = readFile2(file, "utf8").catch((error) => {
|
|
210
|
+
if (isNotFound(error)) return null;
|
|
211
|
+
throw error;
|
|
212
|
+
});
|
|
213
|
+
texts.set(file, text);
|
|
214
|
+
}
|
|
215
|
+
return text;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function idOf(raw) {
|
|
219
|
+
const id = raw?.id;
|
|
220
|
+
return typeof id === "string" ? id : null;
|
|
221
|
+
}
|
|
222
|
+
function isBranchLike(rev) {
|
|
223
|
+
return !rev.includes(".") && !/^[0-9a-f]+$/i.test(rev);
|
|
224
|
+
}
|
|
225
|
+
function uniqueRules(compiled, diagnostics, message) {
|
|
226
|
+
const counts = /* @__PURE__ */ new Map();
|
|
227
|
+
for (const { rule } of compiled) counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1);
|
|
228
|
+
const rules = [];
|
|
229
|
+
for (const { rule, source } of compiled) {
|
|
230
|
+
if (counts.get(rule.id) === 1) rules.push(rule);
|
|
231
|
+
else diagnostics.push({ source, rule: rule.id, message: message(rule.id), level: "error" });
|
|
232
|
+
}
|
|
233
|
+
return rules;
|
|
234
|
+
}
|
|
235
|
+
async function compile(options) {
|
|
236
|
+
const { root } = options;
|
|
237
|
+
const configFailure = (message, config2 = defaultConfig()) => ({
|
|
238
|
+
root,
|
|
239
|
+
config: config2,
|
|
240
|
+
rules: [],
|
|
241
|
+
diagnostics: [{ source: CONFIG_FILE, rule: null, message, level: "error" }]
|
|
242
|
+
});
|
|
243
|
+
const data = options.configData !== void 0 ? { ok: true, value: options.configData } : await readConfigData(root);
|
|
244
|
+
if (!data.ok) return configFailure(data.message);
|
|
245
|
+
const parsed = parseConfig(data.value);
|
|
246
|
+
if (!parsed.ok) return configFailure(parsed.message);
|
|
247
|
+
const config = parsed.value;
|
|
248
|
+
if (config.minimumRulecastVersion !== null && isOlder(VERSION, config.minimumRulecastVersion)) {
|
|
249
|
+
return configFailure(`requires rulecast ${config.minimumRulecastVersion} or newer (running ${VERSION})`, config);
|
|
250
|
+
}
|
|
251
|
+
const global = compileFilter({
|
|
252
|
+
files: config.files,
|
|
253
|
+
exclude: config.exclude,
|
|
254
|
+
types: [],
|
|
255
|
+
typesOr: [],
|
|
256
|
+
excludeTypes: []
|
|
257
|
+
});
|
|
258
|
+
if (typeof global === "string") return configFailure(global, config);
|
|
259
|
+
const diagnostics = [];
|
|
260
|
+
const compiled = [];
|
|
261
|
+
const context = { config, registry: options.registry, global, read: createReader() };
|
|
262
|
+
const project = { dir: root, label: null };
|
|
263
|
+
const error = (source, rule, message, hint) => diagnostics.push({ source, rule, message, level: "error", ...hint === void 0 ? {} : { hint } });
|
|
264
|
+
const add = async (input, source) => {
|
|
265
|
+
const result = await compileRule(input, context);
|
|
266
|
+
if (typeof result === "string") error(source, input.data.alias ?? input.data.id, result);
|
|
267
|
+
else compiled.push({ rule: result, source });
|
|
268
|
+
};
|
|
269
|
+
for (const [index, entry] of config.repos.entries()) {
|
|
270
|
+
if (entry.repo === "local") {
|
|
271
|
+
if (entry.rev !== void 0) {
|
|
272
|
+
error(CONFIG_FILE, null, `repos[${index}]: local repos take no rev`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
for (const raw of entry.rules) {
|
|
276
|
+
const rule = ruleSchema.safeParse(raw);
|
|
277
|
+
if (!rule.success) error(CONFIG_FILE, idOf(raw), formatZodError(rule.error));
|
|
278
|
+
else await add({ data: rule.data, source: "local", contextRoot: project }, CONFIG_FILE);
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (entry.rev === void 0) {
|
|
283
|
+
error(CONFIG_FILE, null, `repos[${index}] ${entry.repo}: rev is required`);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const source = `${entry.repo}@${entry.rev}`;
|
|
287
|
+
if (isBranchLike(entry.rev)) {
|
|
288
|
+
diagnostics.push({
|
|
289
|
+
source,
|
|
290
|
+
rule: null,
|
|
291
|
+
message: `rev "${entry.rev}" looks like a branch: pin a tag or a full commit SHA`,
|
|
292
|
+
level: "warning"
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
const checkout = await options.repos.checkout(entry.repo, entry.rev);
|
|
296
|
+
if (!checkout.ok) {
|
|
297
|
+
error(source, null, checkout.message, checkout.missing ? "rulecast install" : void 0);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const manifest = await readManifest(checkout.dir);
|
|
301
|
+
if (!manifest.ok) {
|
|
302
|
+
error(source, null, manifest.message);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const byId = /* @__PURE__ */ new Map();
|
|
306
|
+
for (const raw of manifest.value) {
|
|
307
|
+
const id = idOf(raw);
|
|
308
|
+
if (id !== null) byId.set(id, [...byId.get(id) ?? [], raw]);
|
|
309
|
+
}
|
|
310
|
+
const repoRoot = { dir: checkout.dir, label: checkout.label };
|
|
311
|
+
for (const raw of entry.rules) {
|
|
312
|
+
const override = overrideSchema.safeParse(raw);
|
|
313
|
+
if (!override.success) {
|
|
314
|
+
error(CONFIG_FILE, idOf(raw), formatZodError(override.error));
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const { id } = override.data;
|
|
318
|
+
const candidates = byId.get(id) ?? [];
|
|
319
|
+
if (candidates.length !== 1) {
|
|
320
|
+
error(source, id, candidates.length === 0 ? "not in the manifest" : "defined more than once in the manifest");
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const base = ruleSchema.safeParse(candidates[0]);
|
|
324
|
+
if (!base.success) {
|
|
325
|
+
error(source, id, `manifest: ${formatZodError(base.error)}`);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const data2 = { ...base.data, ...override.data };
|
|
329
|
+
const contextRoot = "context" in override.data ? project : repoRoot;
|
|
330
|
+
await add({ data: data2, source: checkout.label, contextRoot }, source);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const rules = uniqueRules(
|
|
334
|
+
compiled,
|
|
335
|
+
diagnostics,
|
|
336
|
+
(id) => `rule "${id}" is configured more than once: give one an alias`
|
|
337
|
+
);
|
|
338
|
+
return { root, config, rules, diagnostics };
|
|
339
|
+
}
|
|
340
|
+
async function compileManifest(dir, registry) {
|
|
341
|
+
const manifest = await readManifest(dir);
|
|
342
|
+
if (!manifest.ok) {
|
|
343
|
+
return {
|
|
344
|
+
rules: [],
|
|
345
|
+
diagnostics: [{ source: MANIFEST_FILE, rule: null, message: manifest.message, level: "error" }]
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
const diagnostics = [];
|
|
349
|
+
const compiled = [];
|
|
350
|
+
const context = { config: defaultConfig(), registry, global: () => true, read: createReader() };
|
|
351
|
+
for (const raw of manifest.value) {
|
|
352
|
+
const rule = ruleSchema.safeParse(raw);
|
|
353
|
+
if (!rule.success) {
|
|
354
|
+
diagnostics.push({ source: MANIFEST_FILE, rule: idOf(raw), message: formatZodError(rule.error), level: "error" });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const result = await compileRule({ data: rule.data, source: "local", contextRoot: { dir, label: null } }, context);
|
|
358
|
+
if (typeof result === "string") {
|
|
359
|
+
diagnostics.push({
|
|
360
|
+
source: MANIFEST_FILE,
|
|
361
|
+
rule: rule.data.alias ?? rule.data.id,
|
|
362
|
+
message: result,
|
|
363
|
+
level: "error"
|
|
364
|
+
});
|
|
365
|
+
} else compiled.push({ rule: result, source: MANIFEST_FILE });
|
|
366
|
+
}
|
|
367
|
+
const rules = uniqueRules(compiled, diagnostics, (id) => `rule "${id}" is defined more than once`);
|
|
368
|
+
return { rules, diagnostics };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/core/detection/select.ts
|
|
372
|
+
function selectDetectorRules(rules, event, files, disabled) {
|
|
373
|
+
return rules.filter(
|
|
374
|
+
(rule) => isDetectorRule(rule) && rule.stages.includes(event) && !disabled.has(rule.id)
|
|
375
|
+
).map((rule) => ({ rule, files: files.filter((file) => rule.matches(file)) })).filter((selection) => selection.files.length > 0);
|
|
376
|
+
}
|
|
377
|
+
function rulesOfKind(rules, kind) {
|
|
378
|
+
return rules.filter(isDetectorRule).filter((rule) => rule.detector.kind === kind).map((rule) => ({ id: rule.id, config: rule.detector.config }));
|
|
379
|
+
}
|
|
380
|
+
function selectTouchRules(rules, files, touched, disabled) {
|
|
381
|
+
return rules.filter(
|
|
382
|
+
(rule) => rule.stages.includes("touch") && !touched.has(rule.id) && !disabled.has(rule.id) && files.some((file) => rule.matches(file))
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// src/core/detection/check.ts
|
|
387
|
+
function checkableKinds(project, registry) {
|
|
388
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
389
|
+
for (const rule of project.rules) {
|
|
390
|
+
const kind = rule.detector?.kind;
|
|
391
|
+
if (kind !== void 0 && registry.get(kind)?.check !== void 0) kinds.add(kind);
|
|
392
|
+
}
|
|
393
|
+
return [...kinds];
|
|
394
|
+
}
|
|
395
|
+
async function checkDetectors(options) {
|
|
396
|
+
const { project, registry } = options;
|
|
397
|
+
const kinds = checkableKinds(project, registry);
|
|
398
|
+
const signal = AbortSignal.timeout(options.timeoutMs);
|
|
399
|
+
const perKind = await Promise.all(
|
|
400
|
+
kinds.map(async (kind) => {
|
|
401
|
+
const detector = registry.get(kind);
|
|
402
|
+
const rules = rulesOfKind(project.rules, kind);
|
|
403
|
+
try {
|
|
404
|
+
const results = await detector.check({
|
|
405
|
+
rules,
|
|
406
|
+
settings: options.settings,
|
|
407
|
+
env: options.env,
|
|
408
|
+
cwd: project.root,
|
|
409
|
+
signal
|
|
410
|
+
});
|
|
411
|
+
return results.map((result) => ({ kind, ...result }));
|
|
412
|
+
} catch (error) {
|
|
413
|
+
return [
|
|
414
|
+
{
|
|
415
|
+
kind,
|
|
416
|
+
what: kind,
|
|
417
|
+
level: "error",
|
|
418
|
+
detail: errorMessage(error),
|
|
419
|
+
rules: rules.map((rule) => rule.id)
|
|
420
|
+
}
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
);
|
|
425
|
+
return perKind.flat();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/core/repos/fetch.ts
|
|
429
|
+
import { randomUUID } from "crypto";
|
|
430
|
+
import { existsSync as existsSync2 } from "fs";
|
|
431
|
+
import { mkdir as mkdir2, rename, rm as rm2 } from "fs/promises";
|
|
432
|
+
import path5 from "path";
|
|
433
|
+
|
|
434
|
+
// src/core/git.ts
|
|
435
|
+
import { execFile } from "child_process";
|
|
436
|
+
import { promisify } from "util";
|
|
437
|
+
var exec = promisify(execFile);
|
|
438
|
+
var INHERITED = [
|
|
439
|
+
"GIT_DIR",
|
|
440
|
+
"GIT_WORK_TREE",
|
|
441
|
+
"GIT_INDEX_FILE",
|
|
442
|
+
"GIT_COMMON_DIR",
|
|
443
|
+
"GIT_OBJECT_DIRECTORY",
|
|
444
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
445
|
+
"GIT_PREFIX"
|
|
446
|
+
];
|
|
447
|
+
function withoutInheritedGitEnv(env) {
|
|
448
|
+
const scrubbed = { ...env };
|
|
449
|
+
for (const name of INHERITED) delete scrubbed[name];
|
|
450
|
+
return scrubbed;
|
|
451
|
+
}
|
|
452
|
+
function environment(extra) {
|
|
453
|
+
return withoutInheritedGitEnv({ ...process.env, ...extra });
|
|
454
|
+
}
|
|
455
|
+
async function git(cwd, args, env) {
|
|
456
|
+
try {
|
|
457
|
+
const { stdout } = await exec("git", args, {
|
|
458
|
+
cwd,
|
|
459
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
460
|
+
env: environment(env)
|
|
461
|
+
});
|
|
462
|
+
return { ok: true, stdout };
|
|
463
|
+
} catch (error) {
|
|
464
|
+
const failure = error;
|
|
465
|
+
if (failure.code === "ENOENT") throw new Error("git is not installed or not on PATH");
|
|
466
|
+
if (typeof failure.code === "number") return { ok: false, stderr: failure.stderr ?? "" };
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function headCommit(cwd) {
|
|
471
|
+
const result = await git(cwd, ["rev-parse", "--verify", "-q", "HEAD"]);
|
|
472
|
+
return result.ok ? result.stdout.trim() : null;
|
|
473
|
+
}
|
|
474
|
+
async function fileAtCommit(cwd, commit, file) {
|
|
475
|
+
const result = await git(cwd, ["show", `${commit}:./${file}`]);
|
|
476
|
+
return result.ok ? result.stdout : null;
|
|
477
|
+
}
|
|
478
|
+
async function mergeBase(cwd, ref, other = "HEAD") {
|
|
479
|
+
const result = await git(cwd, ["merge-base", other, ref]);
|
|
480
|
+
if (!result.ok) throw new Error(`cannot find merge base with ${ref}: ${result.stderr.trim()}`);
|
|
481
|
+
return result.stdout.trim();
|
|
482
|
+
}
|
|
483
|
+
async function changedFilesSince(cwd, commit) {
|
|
484
|
+
const diff = await git(cwd, ["diff", "--name-only", "--relative", "--diff-filter=d", commit]);
|
|
485
|
+
if (!diff.ok) throw new Error(`git diff against ${commit} failed: ${diff.stderr.trim()}`);
|
|
486
|
+
const untracked = await git(cwd, ["ls-files", "--others", "--exclude-standard"]);
|
|
487
|
+
if (!untracked.ok) throw new Error(`git ls-files failed: ${untracked.stderr.trim()}`);
|
|
488
|
+
const files = [...diff.stdout.split("\n"), ...untracked.stdout.split("\n")].filter(Boolean);
|
|
489
|
+
return [...new Set(files)].sort();
|
|
490
|
+
}
|
|
491
|
+
function nulSeparated(stdout) {
|
|
492
|
+
return [...new Set(stdout.split("\0").filter(Boolean))].sort();
|
|
493
|
+
}
|
|
494
|
+
async function allFiles(cwd) {
|
|
495
|
+
const result = await git(cwd, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
496
|
+
if (!result.ok) throw new Error(`git ls-files failed: ${result.stderr.trim()}`);
|
|
497
|
+
return nulSeparated(result.stdout);
|
|
498
|
+
}
|
|
499
|
+
async function stagedFiles(cwd) {
|
|
500
|
+
const result = await git(cwd, ["diff", "--cached", "--name-only", "-z", "--relative", "--diff-filter=d"]);
|
|
501
|
+
if (!result.ok) throw new Error(`git diff --cached failed: ${result.stderr.trim()}`);
|
|
502
|
+
return nulSeparated(result.stdout);
|
|
503
|
+
}
|
|
504
|
+
async function changedFilesBetween(cwd, from, to) {
|
|
505
|
+
const result = await git(cwd, ["diff", "--name-only", "-z", "--relative", "--diff-filter=d", from, to]);
|
|
506
|
+
if (!result.ok) throw new Error(`git diff ${from} ${to} failed: ${result.stderr.trim()}`);
|
|
507
|
+
return nulSeparated(result.stdout);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/core/session/lock.ts
|
|
511
|
+
import { mkdir, rm, stat } from "fs/promises";
|
|
512
|
+
import path3 from "path";
|
|
513
|
+
var LockTimeoutError = class extends Error {
|
|
514
|
+
};
|
|
515
|
+
var DEFAULTS = { waitMs: 2e3, staleMs: 5e3, retryMs: 20 };
|
|
516
|
+
function isAlreadyExists(error) {
|
|
517
|
+
return error instanceof Error && error.code === "EEXIST";
|
|
518
|
+
}
|
|
519
|
+
async function withLock(dir, fn, options = DEFAULTS) {
|
|
520
|
+
const lock = path3.join(dir, ".lock");
|
|
521
|
+
await mkdir(dir, { recursive: true });
|
|
522
|
+
const started = Date.now();
|
|
523
|
+
for (; ; ) {
|
|
524
|
+
try {
|
|
525
|
+
await mkdir(lock);
|
|
526
|
+
break;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (!isAlreadyExists(error)) throw error;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
const { mtimeMs } = await stat(lock);
|
|
532
|
+
if (Date.now() - mtimeMs > options.staleMs) {
|
|
533
|
+
await rm(lock, { recursive: true, force: true });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (!isNotFound(error)) throw error;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (Date.now() - started > options.waitMs)
|
|
541
|
+
throw new LockTimeoutError(`could not lock ${dir} within ${options.waitMs} ms`);
|
|
542
|
+
await new Promise((resolve) => setTimeout(resolve, options.retryMs));
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
return await fn();
|
|
546
|
+
} finally {
|
|
547
|
+
await rm(lock, { recursive: true, force: true });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/core/repos/layout.ts
|
|
552
|
+
import { createHash as createHash2 } from "crypto";
|
|
553
|
+
import path4 from "path";
|
|
554
|
+
import { fileURLToPath } from "url";
|
|
555
|
+
var SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
556
|
+
var SCP_LIKE = /^[^@/\s]+@([^:/\s]+):(.+)$/;
|
|
557
|
+
function segmentsOf(repoPath) {
|
|
558
|
+
return repoPath.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
|
|
559
|
+
}
|
|
560
|
+
function localName(file) {
|
|
561
|
+
return path4.basename(file.replace(/[\\/]+$/, "")).replace(/\.git$/, "");
|
|
562
|
+
}
|
|
563
|
+
function parseUrl(url) {
|
|
564
|
+
if (SCHEME.test(url)) {
|
|
565
|
+
const parsed = new URL(url);
|
|
566
|
+
if (parsed.protocol === "file:") return { kind: "local", name: localName(fileURLToPath(parsed)) };
|
|
567
|
+
return { kind: "hosted", host: parsed.hostname, segments: segmentsOf(decodeURIComponent(parsed.pathname)) };
|
|
568
|
+
}
|
|
569
|
+
const scp = url.match(SCP_LIKE);
|
|
570
|
+
if (scp) return { kind: "hosted", host: scp[1], segments: segmentsOf(scp[2]) };
|
|
571
|
+
return { kind: "local", name: localName(url) };
|
|
572
|
+
}
|
|
573
|
+
function safeSegment(value) {
|
|
574
|
+
const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
575
|
+
return /^\.*$/.test(segment) ? `_${segment}` : segment;
|
|
576
|
+
}
|
|
577
|
+
function repoName(url) {
|
|
578
|
+
const parsed = parseUrl(url);
|
|
579
|
+
return parsed.kind === "hosted" ? parsed.segments.join("/") || parsed.host : parsed.name;
|
|
580
|
+
}
|
|
581
|
+
function repoSlug(url) {
|
|
582
|
+
const parsed = parseUrl(url);
|
|
583
|
+
if (parsed.kind === "hosted") return safeSegment([parsed.host, ...parsed.segments].join("_"));
|
|
584
|
+
const hash = createHash2("sha256").update(url).digest("hex").slice(0, 8);
|
|
585
|
+
return safeSegment(`local_${parsed.name}_${hash}`);
|
|
586
|
+
}
|
|
587
|
+
function repoDir(home, url, rev) {
|
|
588
|
+
return path4.join(home, "repos", repoSlug(url), safeSegment(rev));
|
|
589
|
+
}
|
|
590
|
+
function repoLabel(url, rev) {
|
|
591
|
+
return `${repoName(url)}@${rev}`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/core/repos/fetch.ts
|
|
595
|
+
var RepoFetchError = class extends Error {
|
|
596
|
+
};
|
|
597
|
+
var NO_PROMPT = { GIT_TERMINAL_PROMPT: "0" };
|
|
598
|
+
var FETCH_LOCK = { waitMs: 12e4, staleMs: 5 * 6e4, retryMs: 50 };
|
|
599
|
+
async function run(cwd, args) {
|
|
600
|
+
const result = await git(cwd, args, NO_PROMPT);
|
|
601
|
+
if (!result.ok) throw new RepoFetchError(result.stderr.trim() || `git ${args[0]} failed`);
|
|
602
|
+
}
|
|
603
|
+
async function fetchCheckout(url, rev, into) {
|
|
604
|
+
await mkdir2(into, { recursive: true });
|
|
605
|
+
await run(into, ["init", "-q"]);
|
|
606
|
+
await run(into, ["fetch", "-q", "--depth", "1", url, rev]);
|
|
607
|
+
await run(into, ["-c", "advice.detachedHead=false", "checkout", "-q", "FETCH_HEAD"]);
|
|
608
|
+
await rm2(path5.join(into, ".git"), { recursive: true, force: true });
|
|
609
|
+
}
|
|
610
|
+
function cachedRepo(home, url, rev) {
|
|
611
|
+
const dir = repoDir(home, url, rev);
|
|
612
|
+
return existsSync2(dir) ? dir : null;
|
|
613
|
+
}
|
|
614
|
+
async function ensureRepo(home, url, rev) {
|
|
615
|
+
const dir = repoDir(home, url, rev);
|
|
616
|
+
if (existsSync2(dir)) return dir;
|
|
617
|
+
const parent = path5.dirname(dir);
|
|
618
|
+
await withLock(
|
|
619
|
+
parent,
|
|
620
|
+
async () => {
|
|
621
|
+
if (existsSync2(dir)) return;
|
|
622
|
+
const temp = path5.join(parent, `.tmp-${randomUUID()}`);
|
|
623
|
+
try {
|
|
624
|
+
await fetchCheckout(url, rev, temp);
|
|
625
|
+
await rename(temp, dir);
|
|
626
|
+
} finally {
|
|
627
|
+
await rm2(temp, { recursive: true, force: true });
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
FETCH_LOCK
|
|
631
|
+
);
|
|
632
|
+
return dir;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// src/core/repos/provider.ts
|
|
636
|
+
function cachedRepos(home) {
|
|
637
|
+
return {
|
|
638
|
+
async checkout(url, rev) {
|
|
639
|
+
const dir = cachedRepo(home, url, rev);
|
|
640
|
+
if (dir === null) return { ok: false, missing: true, message: "not in the cache" };
|
|
641
|
+
return { ok: true, dir, label: repoLabel(url, rev) };
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
function fetchingRepos(home) {
|
|
646
|
+
return {
|
|
647
|
+
async checkout(url, rev) {
|
|
648
|
+
try {
|
|
649
|
+
return { ok: true, dir: await ensureRepo(home, url, rev), label: repoLabel(url, rev) };
|
|
650
|
+
} catch (error) {
|
|
651
|
+
return { ok: false, missing: false, message: `fetch failed: ${errorMessage(error)}` };
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function fixedRepo(dir, label) {
|
|
657
|
+
return {
|
|
658
|
+
async checkout() {
|
|
659
|
+
return { ok: true, dir, label };
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/core/pipeline.ts
|
|
665
|
+
import { createHash as createHash3 } from "crypto";
|
|
666
|
+
|
|
667
|
+
// src/core/baseline/changes.ts
|
|
668
|
+
import { diffArrays } from "diff";
|
|
669
|
+
function diffLines(before, after) {
|
|
670
|
+
const changed = [];
|
|
671
|
+
const runs = [];
|
|
672
|
+
const mark = (start, end) => {
|
|
673
|
+
const previous = changed.at(-1);
|
|
674
|
+
if (previous && start <= previous[1] + 1) previous[1] = Math.max(previous[1], end);
|
|
675
|
+
else changed.push([start, end]);
|
|
676
|
+
};
|
|
677
|
+
let line = 1;
|
|
678
|
+
let baselineLine = 1;
|
|
679
|
+
for (const part of diffArrays(Array.from(before), Array.from(after))) {
|
|
680
|
+
const count = part.count ?? part.value.length;
|
|
681
|
+
if (part.added) {
|
|
682
|
+
mark(line, line + count - 1);
|
|
683
|
+
line += count;
|
|
684
|
+
} else if (part.removed) {
|
|
685
|
+
if (after.length > 0) {
|
|
686
|
+
const at = Math.min(line, after.length);
|
|
687
|
+
mark(at, at);
|
|
688
|
+
}
|
|
689
|
+
baselineLine += count;
|
|
690
|
+
} else {
|
|
691
|
+
runs.push({ beforeStart: baselineLine, afterStart: line, count });
|
|
692
|
+
line += count;
|
|
693
|
+
baselineLine += count;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return { changed, map: { runs, afterLines: after.length } };
|
|
697
|
+
}
|
|
698
|
+
function identityMap(lines) {
|
|
699
|
+
return { runs: lines === 0 ? [] : [{ beforeStart: 1, afterStart: 1, count: lines }], afterLines: lines };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/core/baseline/hash.ts
|
|
703
|
+
var OFFSET_BASIS = 2166136261;
|
|
704
|
+
var PRIME = 16777619;
|
|
705
|
+
function fnv1a(text) {
|
|
706
|
+
let hash = OFFSET_BASIS;
|
|
707
|
+
for (let i = 0; i < text.length; i++) {
|
|
708
|
+
hash ^= text.charCodeAt(i);
|
|
709
|
+
hash = Math.imul(hash, PRIME);
|
|
710
|
+
}
|
|
711
|
+
return hash >>> 0;
|
|
712
|
+
}
|
|
713
|
+
function hashLines(text) {
|
|
714
|
+
const lines = text.split(/\r?\n/);
|
|
715
|
+
const hashes = new Uint32Array(lines.length);
|
|
716
|
+
lines.forEach((line, index) => {
|
|
717
|
+
hashes[index] = fnv1a(line.trim());
|
|
718
|
+
});
|
|
719
|
+
return hashes;
|
|
720
|
+
}
|
|
721
|
+
function hashOfHashes(lines) {
|
|
722
|
+
let hash = OFFSET_BASIS;
|
|
723
|
+
for (const value of lines) {
|
|
724
|
+
for (let shift = 0; shift < 32; shift += 8) {
|
|
725
|
+
hash ^= value >>> shift & 255;
|
|
726
|
+
hash = Math.imul(hash, PRIME);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return hash >>> 0;
|
|
730
|
+
}
|
|
731
|
+
function snapshotOf(text) {
|
|
732
|
+
const lines = hashLines(text);
|
|
733
|
+
return { fileHash: hashOfHashes(lines), lines };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/core/baseline/baseline.ts
|
|
737
|
+
async function computeChanges(root, files, sources) {
|
|
738
|
+
const changes = { sets: /* @__PURE__ */ new Map(), maps: /* @__PURE__ */ new Map(), fromCommit: /* @__PURE__ */ new Map() };
|
|
739
|
+
for (const file of files) {
|
|
740
|
+
if (!sources.snapshots.has(file) && !sources.fallbackCommit) continue;
|
|
741
|
+
let before = sources.snapshots.get(file) ?? null;
|
|
742
|
+
let commitText = null;
|
|
743
|
+
if (!before && sources.fallbackCommit) {
|
|
744
|
+
commitText = await fileAtCommit(root, sources.fallbackCommit, file);
|
|
745
|
+
before = commitText === null ? null : snapshotOf(commitText);
|
|
746
|
+
}
|
|
747
|
+
if (!before) continue;
|
|
748
|
+
const current = await readSourceFile(root, file);
|
|
749
|
+
if (current === null) continue;
|
|
750
|
+
if (commitText !== null) changes.fromCommit.set(file, commitText);
|
|
751
|
+
const after = snapshotOf(current);
|
|
752
|
+
if (after.fileHash === before.fileHash) {
|
|
753
|
+
changes.sets.set(file, { changedLines: [] });
|
|
754
|
+
changes.maps.set(file, identityMap(after.lines.length));
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const diff = diffLines(before.lines, after.lines);
|
|
758
|
+
changes.sets.set(file, { changedLines: diff.changed });
|
|
759
|
+
changes.maps.set(file, diff.map);
|
|
760
|
+
}
|
|
761
|
+
return changes;
|
|
762
|
+
}
|
|
763
|
+
function isNew(match, changes) {
|
|
764
|
+
const change = changes.get(match.file);
|
|
765
|
+
if (!change) return true;
|
|
766
|
+
return change.changedLines.some(([start, end]) => match.line <= end && match.endLine >= start);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/core/baseline/fingerprint.ts
|
|
770
|
+
function mapRange(map, [start, end]) {
|
|
771
|
+
let first = null;
|
|
772
|
+
let last = 0;
|
|
773
|
+
for (const run2 of map.runs) {
|
|
774
|
+
const from = Math.max(start, run2.beforeStart);
|
|
775
|
+
const to = Math.min(end, run2.beforeStart + run2.count - 1);
|
|
776
|
+
if (from > to) continue;
|
|
777
|
+
if (first === null) first = run2.afterStart + (from - run2.beforeStart);
|
|
778
|
+
last = run2.afterStart + (to - run2.beforeStart);
|
|
779
|
+
}
|
|
780
|
+
return first === null ? null : [first, last];
|
|
781
|
+
}
|
|
782
|
+
function classify(match, scope, rule, changes, fingerprints) {
|
|
783
|
+
const instance = () => isNew(match, changes.sets) ? "new" : "preexisting";
|
|
784
|
+
if (scope === "instance") return instance();
|
|
785
|
+
const recorded = fingerprints.get(match.file)?.get(rule);
|
|
786
|
+
const map = changes.maps.get(match.file);
|
|
787
|
+
if (recorded === void 0 || map === void 0) return instance();
|
|
788
|
+
for (const range of recorded) {
|
|
789
|
+
const mapped = mapRange(map, range);
|
|
790
|
+
if (mapped !== null && match.line <= mapped[1] && match.endLine >= mapped[0]) return "preexisting";
|
|
791
|
+
}
|
|
792
|
+
return "new";
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/core/detection/budget.ts
|
|
796
|
+
function applyFileBudget(kind, selections, maxFiles, recent) {
|
|
797
|
+
const ofKind = selections.filter((selection) => selection.rule.detector?.kind === kind);
|
|
798
|
+
const files = [...new Set(ofKind.flatMap((selection) => selection.files))];
|
|
799
|
+
if (files.length <= maxFiles) return { selections, skipped: [] };
|
|
800
|
+
const rank = new Map(recent.map((file, index) => [file, index]));
|
|
801
|
+
const ordered = [...files].sort((a, b) => (rank.get(a) ?? recent.length) - (rank.get(b) ?? recent.length));
|
|
802
|
+
const keep = new Set(ordered.slice(0, maxFiles));
|
|
803
|
+
return {
|
|
804
|
+
selections: selections.map(
|
|
805
|
+
(selection) => selection.rule.detector?.kind === kind ? { ...selection, files: selection.files.filter((file) => keep.has(file)) } : selection
|
|
806
|
+
).filter((selection) => selection.files.length > 0),
|
|
807
|
+
skipped: ordered.slice(maxFiles)
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
async function applySizeCeiling(selections, maxBytes, bounded, sizeOf) {
|
|
811
|
+
const files = [...new Set(selections.filter((one) => bounded(one.rule.detector.kind)).flatMap((one) => one.files))];
|
|
812
|
+
if (files.length === 0) return { selections, skipped: [] };
|
|
813
|
+
const sizes = await Promise.all(files.map(async (file) => [file, await sizeOf(file)]));
|
|
814
|
+
const over = new Set(sizes.filter(([, size]) => size !== null && size > maxBytes).map(([file]) => file));
|
|
815
|
+
if (over.size === 0) return { selections, skipped: [] };
|
|
816
|
+
return {
|
|
817
|
+
selections: selections.map(
|
|
818
|
+
(selection) => bounded(selection.rule.detector.kind) ? { ...selection, files: selection.files.filter((file) => !over.has(file)) } : selection
|
|
819
|
+
).filter((selection) => selection.files.length > 0),
|
|
820
|
+
skipped: files.filter((file) => over.has(file))
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/core/detection/run.ts
|
|
825
|
+
var TIMED_OUT = /* @__PURE__ */ Symbol("timed out");
|
|
826
|
+
async function runDetection(input) {
|
|
827
|
+
const output = { findings: [], errors: [], timedOut: [] };
|
|
828
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
829
|
+
for (const selection of input.selections) {
|
|
830
|
+
const kind = selection.rule.detector.kind;
|
|
831
|
+
const group = byKind.get(kind);
|
|
832
|
+
if (group === void 0) byKind.set(kind, [selection]);
|
|
833
|
+
else group.push(selection);
|
|
834
|
+
}
|
|
835
|
+
const controller = new AbortController();
|
|
836
|
+
const deadline = new Promise((resolve) => {
|
|
837
|
+
controller.signal.addEventListener("abort", () => resolve(TIMED_OUT), { once: true });
|
|
838
|
+
});
|
|
839
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
840
|
+
const deadlineAt = Date.now() + input.timeoutMs;
|
|
841
|
+
const late = () => Date.now() > deadlineAt;
|
|
842
|
+
const perKind = await Promise.all(
|
|
843
|
+
[...byKind].map(async ([kind, selections]) => {
|
|
844
|
+
const detector = input.registry.get(kind);
|
|
845
|
+
if (!detector) return { status: "error", kind, selections, message: `detector "${kind}" is not registered` };
|
|
846
|
+
try {
|
|
847
|
+
const rules = await Promise.all(
|
|
848
|
+
selections.map(async ({ rule, files }) => ({
|
|
849
|
+
id: rule.id,
|
|
850
|
+
config: rule.detector.config,
|
|
851
|
+
files,
|
|
852
|
+
context: await input.contextFor(rule)
|
|
853
|
+
}))
|
|
854
|
+
);
|
|
855
|
+
const result = await Promise.race([
|
|
856
|
+
detector.run({
|
|
857
|
+
event: input.event,
|
|
858
|
+
rules,
|
|
859
|
+
read: input.read,
|
|
860
|
+
changes: input.changes,
|
|
861
|
+
cache: input.cacheFor(kind),
|
|
862
|
+
settings: input.settings,
|
|
863
|
+
cwd: input.root,
|
|
864
|
+
signal: controller.signal,
|
|
865
|
+
deadlineAt
|
|
866
|
+
}),
|
|
867
|
+
deadline
|
|
868
|
+
]);
|
|
869
|
+
if (result === TIMED_OUT || late()) return { status: "timedOut", kind, selections };
|
|
870
|
+
return { status: "ok", kind, selections, result };
|
|
871
|
+
} catch (error) {
|
|
872
|
+
if (error instanceof DeadlineError || controller.signal.aborted || late()) {
|
|
873
|
+
return { status: "timedOut", kind, selections };
|
|
874
|
+
}
|
|
875
|
+
return { status: "error", kind, selections, message: errorMessage(error) };
|
|
876
|
+
}
|
|
877
|
+
})
|
|
878
|
+
);
|
|
879
|
+
clearTimeout(timer);
|
|
880
|
+
for (const outcome of perKind) {
|
|
881
|
+
const ids = outcome.selections.map((selection) => selection.rule.id);
|
|
882
|
+
if (outcome.status === "timedOut") {
|
|
883
|
+
output.timedOut.push({ kind: outcome.kind, rules: ids });
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (outcome.status === "error") {
|
|
887
|
+
output.errors.push({ kind: outcome.kind, rules: ids, message: outcome.message });
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const result = outcome.result;
|
|
891
|
+
const rulesById = new Map(outcome.selections.map((selection) => [selection.rule.id, selection.rule]));
|
|
892
|
+
const stray = result.findings.find((finding) => !rulesById.has(finding.rule));
|
|
893
|
+
const wholeRun = result.errors.find((error) => error.rule === null);
|
|
894
|
+
if (stray || wholeRun) {
|
|
895
|
+
const message = stray ? `detector reported unknown rule "${stray.rule}"` : wholeRun.message;
|
|
896
|
+
output.errors.push({ kind: outcome.kind, rules: ids, message });
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const failed = /* @__PURE__ */ new Map();
|
|
900
|
+
for (const error of result.errors) failed.set(error.rule, error.message);
|
|
901
|
+
for (const finding of result.findings) {
|
|
902
|
+
const rule = rulesById.get(finding.rule);
|
|
903
|
+
const missing = rule.detector.captures.find((name) => typeof finding.match.captures[name] !== "string");
|
|
904
|
+
if (missing && !failed.has(rule.id)) failed.set(rule.id, `match is missing declared capture "${missing}"`);
|
|
905
|
+
}
|
|
906
|
+
for (const [rule, message] of failed) output.errors.push({ kind: outcome.kind, rules: [rule], message });
|
|
907
|
+
for (const finding of result.findings) {
|
|
908
|
+
if (!failed.has(finding.rule)) output.findings.push({ rule: rulesById.get(finding.rule), match: finding.match });
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return output;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// src/core/baseline/store.ts
|
|
915
|
+
import path7 from "path";
|
|
916
|
+
|
|
917
|
+
// src/core/jsonl.ts
|
|
918
|
+
import { mkdir as mkdir3, open, readFile as readFile3 } from "fs/promises";
|
|
919
|
+
import path6 from "path";
|
|
920
|
+
var CorruptStoreError = class extends Error {
|
|
921
|
+
};
|
|
922
|
+
async function appendRecords(file, records) {
|
|
923
|
+
if (records.length === 0) return;
|
|
924
|
+
await mkdir3(path6.dirname(file), { recursive: true });
|
|
925
|
+
const handle = await open(file, "a+");
|
|
926
|
+
try {
|
|
927
|
+
await dropPartialRecord(handle);
|
|
928
|
+
await handle.appendFile(records.map((record) => `${JSON.stringify(record)}
|
|
929
|
+
`).join(""));
|
|
930
|
+
} finally {
|
|
931
|
+
await handle.close();
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
var TAIL_CHUNK = 64 * 1024;
|
|
935
|
+
async function dropPartialRecord(handle) {
|
|
936
|
+
const { size } = await handle.stat();
|
|
937
|
+
let end = size;
|
|
938
|
+
while (end > 0) {
|
|
939
|
+
const start = Math.max(0, end - TAIL_CHUNK);
|
|
940
|
+
const chunk = Buffer.alloc(end - start);
|
|
941
|
+
await handle.read(chunk, 0, chunk.length, start);
|
|
942
|
+
const newline = chunk.lastIndexOf(10);
|
|
943
|
+
if (newline !== -1) {
|
|
944
|
+
end = start + newline + 1;
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
end = start;
|
|
948
|
+
}
|
|
949
|
+
if (end < size) await handle.truncate(end);
|
|
950
|
+
}
|
|
951
|
+
async function readRecords(file) {
|
|
952
|
+
let text;
|
|
953
|
+
try {
|
|
954
|
+
text = await readFile3(file, "utf8");
|
|
955
|
+
} catch (error) {
|
|
956
|
+
if (isNotFound(error)) return [];
|
|
957
|
+
throw error;
|
|
958
|
+
}
|
|
959
|
+
const lines = text.split("\n");
|
|
960
|
+
const records = [];
|
|
961
|
+
for (let i = 0; i < lines.length; i++) {
|
|
962
|
+
const line = lines[i];
|
|
963
|
+
if (line.trim() === "") continue;
|
|
964
|
+
try {
|
|
965
|
+
records.push(JSON.parse(line));
|
|
966
|
+
} catch {
|
|
967
|
+
if (i === lines.length - 1) break;
|
|
968
|
+
throw new CorruptStoreError(`${file}: unparseable record on line ${i + 1}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return records;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// src/core/baseline/store.ts
|
|
975
|
+
function storeFile(sessionDir2) {
|
|
976
|
+
return path7.join(sessionDir2, "baseline.jsonl");
|
|
977
|
+
}
|
|
978
|
+
function encodeLines(lines) {
|
|
979
|
+
return Buffer.from(lines.buffer, lines.byteOffset, lines.byteLength).toString("base64");
|
|
980
|
+
}
|
|
981
|
+
function decodeLines(encoded) {
|
|
982
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
983
|
+
const aligned = new Uint8Array(bytes.length);
|
|
984
|
+
aligned.set(bytes);
|
|
985
|
+
return new Uint32Array(aligned.buffer);
|
|
986
|
+
}
|
|
987
|
+
function startRecord(commit) {
|
|
988
|
+
return { t: "start", commit };
|
|
989
|
+
}
|
|
990
|
+
function snapshotRecord(file, snapshot) {
|
|
991
|
+
return { t: "snapshot", file, fileHash: snapshot.fileHash, lines: encodeLines(snapshot.lines) };
|
|
992
|
+
}
|
|
993
|
+
function fingerprintRecord(file, rule, ranges) {
|
|
994
|
+
return { t: "fingerprint", file, rule, ranges };
|
|
995
|
+
}
|
|
996
|
+
async function appendBaseline(sessionDir2, records) {
|
|
997
|
+
await appendRecords(storeFile(sessionDir2), records);
|
|
998
|
+
}
|
|
999
|
+
async function readBaseline(sessionDir2) {
|
|
1000
|
+
const state = { started: false, startCommit: null, snapshots: /* @__PURE__ */ new Map(), fingerprints: /* @__PURE__ */ new Map() };
|
|
1001
|
+
for (const record of await readRecords(storeFile(sessionDir2))) {
|
|
1002
|
+
if (record.t === "start" && !state.started) {
|
|
1003
|
+
state.started = true;
|
|
1004
|
+
state.startCommit = record.commit;
|
|
1005
|
+
} else if (record.t === "snapshot" && !state.snapshots.has(record.file)) {
|
|
1006
|
+
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
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return state;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/core/baseline/record.ts
|
|
1017
|
+
async function recordFingerprints(input) {
|
|
1018
|
+
if (!input.rules.some((rule) => rule.scope === "container")) return [];
|
|
1019
|
+
const guards = (kind) => input.registry.get(kind)?.guards === true;
|
|
1020
|
+
let selections = selectDetectorRules(input.rules, input.event, input.files, input.disabled).filter(
|
|
1021
|
+
(selection) => selection.rule.scope === "container" && selection.rule.detector.kind !== "llm" && (input.source === "disk" || guards(selection.rule.detector.kind))
|
|
1022
|
+
);
|
|
1023
|
+
if (input.ceiling) {
|
|
1024
|
+
const applied = await applySizeCeiling(selections, input.ceiling.maxFileBytes, guards, input.ceiling.sizeOf);
|
|
1025
|
+
selections = applied.selections;
|
|
1026
|
+
}
|
|
1027
|
+
if (selections.length === 0) return [];
|
|
1028
|
+
const output = await runDetection({
|
|
1029
|
+
root: input.root,
|
|
1030
|
+
event: input.event,
|
|
1031
|
+
selections,
|
|
1032
|
+
// No change set: the baseline content is being measured whole, and there is nothing it is a
|
|
1033
|
+
// change from. A detector that reads `changes` treats an absent file as entirely new, which is
|
|
1034
|
+
// exactly right here.
|
|
1035
|
+
changes: /* @__PURE__ */ new Map(),
|
|
1036
|
+
read: input.read,
|
|
1037
|
+
registry: input.registry,
|
|
1038
|
+
cacheFor: input.cacheFor,
|
|
1039
|
+
contextFor: input.contextFor,
|
|
1040
|
+
settings: input.settings,
|
|
1041
|
+
timeoutMs: input.timeoutMs
|
|
1042
|
+
});
|
|
1043
|
+
const unmeasured = /* @__PURE__ */ new Set();
|
|
1044
|
+
for (const error of output.errors) for (const rule of error.rules) unmeasured.add(rule);
|
|
1045
|
+
for (const timeout of output.timedOut) for (const rule of timeout.rules) unmeasured.add(rule);
|
|
1046
|
+
const key = (file, rule) => `${file}\0${rule}`;
|
|
1047
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
1048
|
+
for (const { rule, match } of output.findings) {
|
|
1049
|
+
const found = ranges.get(key(match.file, rule.id));
|
|
1050
|
+
if (found === void 0) ranges.set(key(match.file, rule.id), [[match.line, match.endLine]]);
|
|
1051
|
+
else found.push([match.line, match.endLine]);
|
|
1052
|
+
}
|
|
1053
|
+
const records = [];
|
|
1054
|
+
for (const { rule, files } of selections) {
|
|
1055
|
+
if (unmeasured.has(rule.id)) continue;
|
|
1056
|
+
for (const file of files) records.push(fingerprintRecord(file, rule.id, ranges.get(key(file, rule.id)) ?? []));
|
|
1057
|
+
}
|
|
1058
|
+
return records;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/core/delivery/persist.ts
|
|
1062
|
+
import { mkdirSync as mkdirSync2, readdirSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
1063
|
+
import path8 from "path";
|
|
1064
|
+
var KEEP = 3;
|
|
1065
|
+
var UNLIMITED = { maxMatchesPerRule: Number.MAX_SAFE_INTEGER };
|
|
1066
|
+
var safeSegment2 = (value) => value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1067
|
+
function markdown(delivery, now) {
|
|
1068
|
+
const body = renderAgentText({ ...delivery, overflowPath: null }, UNLIMITED);
|
|
1069
|
+
return [
|
|
1070
|
+
`# rulecast delivery \u2014 ${now.toISOString()}`,
|
|
1071
|
+
"",
|
|
1072
|
+
"Too much to fit in one hook message. Everything rulecast found for the files in this session,",
|
|
1073
|
+
"with the doc section each rule cites:",
|
|
1074
|
+
"",
|
|
1075
|
+
body,
|
|
1076
|
+
""
|
|
1077
|
+
].join("\n");
|
|
1078
|
+
}
|
|
1079
|
+
function writeOverflow(stateDir, session, delivery, now = /* @__PURE__ */ new Date()) {
|
|
1080
|
+
const prefix = safeSegment2(session ?? "no-session");
|
|
1081
|
+
const dir = path8.join(stateDir, "deliveries");
|
|
1082
|
+
const file = path8.join(dir, `${prefix}-${now.toISOString().replace(/[:.]/g, "-")}.md`);
|
|
1083
|
+
try {
|
|
1084
|
+
mkdirSync2(dir, { recursive: true });
|
|
1085
|
+
writeFileSync2(file, markdown(delivery, now));
|
|
1086
|
+
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(path8.join(dir, old), { force: true });
|
|
1088
|
+
return file;
|
|
1089
|
+
} catch {
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// src/core/delivery/resolve.ts
|
|
1095
|
+
async function resolveRuleContext(resolver, context) {
|
|
1096
|
+
const references = [];
|
|
1097
|
+
for (const spec of context) {
|
|
1098
|
+
const resolved = await resolver.resolve(spec);
|
|
1099
|
+
if (resolved.found) references.push({ ref: spec.ref, content: resolved.content });
|
|
1100
|
+
}
|
|
1101
|
+
return references;
|
|
1102
|
+
}
|
|
1103
|
+
function createReferenceResolver(root) {
|
|
1104
|
+
const texts = /* @__PURE__ */ new Map();
|
|
1105
|
+
const read = (path11) => {
|
|
1106
|
+
let text = texts.get(path11);
|
|
1107
|
+
if (!text) {
|
|
1108
|
+
text = readSourceFile(root, path11);
|
|
1109
|
+
texts.set(path11, text);
|
|
1110
|
+
}
|
|
1111
|
+
return text;
|
|
1112
|
+
};
|
|
1113
|
+
const contentOf = async (path11, anchor) => {
|
|
1114
|
+
const text = await read(path11);
|
|
1115
|
+
if (text === null) return null;
|
|
1116
|
+
if (anchor === null) return text.trimEnd();
|
|
1117
|
+
const section = sectionRange(text, anchor);
|
|
1118
|
+
return section ? sectionText(text, section) : null;
|
|
1119
|
+
};
|
|
1120
|
+
return {
|
|
1121
|
+
async resolve(spec) {
|
|
1122
|
+
const content = await contentOf(spec.path, spec.anchor);
|
|
1123
|
+
if (content === null) return { spec, found: false };
|
|
1124
|
+
return { spec, found: true, content, hash: fnv1a(content), bytes: Buffer.byteLength(content) };
|
|
1125
|
+
},
|
|
1126
|
+
async currentHash(path11, anchor) {
|
|
1127
|
+
const content = await contentOf(path11, anchor);
|
|
1128
|
+
return content === null ? null : fnv1a(content);
|
|
1129
|
+
},
|
|
1130
|
+
async contains(path11, parent, child) {
|
|
1131
|
+
if (parent === null) return true;
|
|
1132
|
+
if (child === null) return false;
|
|
1133
|
+
const text = await read(path11);
|
|
1134
|
+
return text !== null && sectionContains(text, parent, child);
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// src/core/detection/proposal.ts
|
|
1140
|
+
function propose(current, intent) {
|
|
1141
|
+
if (intent.content !== void 0) {
|
|
1142
|
+
return { content: intent.content, inserts: intent.content === "" ? [] : [[0, intent.content.length]] };
|
|
1143
|
+
}
|
|
1144
|
+
if (intent.edit === void 0 || current === null) return null;
|
|
1145
|
+
const { find, replace, all } = intent.edit;
|
|
1146
|
+
if (find === "") return null;
|
|
1147
|
+
const first = current.indexOf(find);
|
|
1148
|
+
if (first === -1) return null;
|
|
1149
|
+
if (!all && current.indexOf(find, first + find.length) !== -1) return null;
|
|
1150
|
+
let content = "";
|
|
1151
|
+
let rest = current;
|
|
1152
|
+
const inserts = [];
|
|
1153
|
+
for (; ; ) {
|
|
1154
|
+
const at = rest.indexOf(find);
|
|
1155
|
+
if (at === -1) break;
|
|
1156
|
+
const start = content.length + at;
|
|
1157
|
+
content += rest.slice(0, at) + replace;
|
|
1158
|
+
if (replace !== "") inserts.push([start, start + replace.length]);
|
|
1159
|
+
rest = rest.slice(at + find.length);
|
|
1160
|
+
if (!all) break;
|
|
1161
|
+
}
|
|
1162
|
+
return { content: content + rest, inserts };
|
|
1163
|
+
}
|
|
1164
|
+
function isInserted(proposal, match, detector) {
|
|
1165
|
+
if (detector === "path") return true;
|
|
1166
|
+
const starts = lineStarts(proposal.content);
|
|
1167
|
+
const lineStart = starts[match.line - 1];
|
|
1168
|
+
if (lineStart === void 0) return false;
|
|
1169
|
+
const start = lineStart + match.column - 1;
|
|
1170
|
+
const end = start + match.text.length;
|
|
1171
|
+
return proposal.inserts.some(([from, to]) => start < to && end > from);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// src/core/session/decide.ts
|
|
1175
|
+
import path9 from "path";
|
|
1176
|
+
|
|
1177
|
+
// src/core/session/state.ts
|
|
1178
|
+
function preexistingKey(rule, file) {
|
|
1179
|
+
return `${rule} ${file}`;
|
|
1180
|
+
}
|
|
1181
|
+
var refusalKey = preexistingKey;
|
|
1182
|
+
function emptyWork() {
|
|
1183
|
+
return { edited: [], stopBlocks: /* @__PURE__ */ new Map(), refusals: /* @__PURE__ */ new Map(), disabled: /* @__PURE__ */ new Map(), accessed: /* @__PURE__ */ new Map() };
|
|
1184
|
+
}
|
|
1185
|
+
function emptyContext() {
|
|
1186
|
+
return { delivered: [], touched: /* @__PURE__ */ new Set(), preexisting: /* @__PURE__ */ new Set(), warned: /* @__PURE__ */ new Set() };
|
|
1187
|
+
}
|
|
1188
|
+
function foldWork(records) {
|
|
1189
|
+
const state = emptyWork();
|
|
1190
|
+
for (const record of records) {
|
|
1191
|
+
switch (record.t) {
|
|
1192
|
+
case "edited":
|
|
1193
|
+
state.edited = [...state.edited.filter((file) => file !== record.file), record.file];
|
|
1194
|
+
break;
|
|
1195
|
+
case "accessed": {
|
|
1196
|
+
const files = state.accessed.get(record.agent) ?? [];
|
|
1197
|
+
state.accessed.set(record.agent, [...files.filter((file) => file !== record.file), record.file]);
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
case "stopBlock":
|
|
1201
|
+
state.stopBlocks.set(record.agent, (state.stopBlocks.get(record.agent) ?? 0) + 1);
|
|
1202
|
+
break;
|
|
1203
|
+
case "refused": {
|
|
1204
|
+
const key = refusalKey(record.rule, record.file);
|
|
1205
|
+
state.refusals.set(key, (state.refusals.get(key) ?? 0) + 1);
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
case "prompt":
|
|
1209
|
+
state.stopBlocks.delete(record.agent);
|
|
1210
|
+
break;
|
|
1211
|
+
case "disabled":
|
|
1212
|
+
if (!state.disabled.has(record.rule)) state.disabled.set(record.rule, record.reason);
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return state;
|
|
1217
|
+
}
|
|
1218
|
+
function foldContext(records) {
|
|
1219
|
+
let state = emptyContext();
|
|
1220
|
+
for (const record of records) {
|
|
1221
|
+
switch (record.t) {
|
|
1222
|
+
case "reset":
|
|
1223
|
+
state = emptyContext();
|
|
1224
|
+
break;
|
|
1225
|
+
case "delivered":
|
|
1226
|
+
state.delivered.push({ path: record.path, anchor: record.anchor, hash: record.hash });
|
|
1227
|
+
break;
|
|
1228
|
+
case "touched":
|
|
1229
|
+
state.touched.add(record.rule);
|
|
1230
|
+
break;
|
|
1231
|
+
case "preexisting":
|
|
1232
|
+
state.preexisting.add(preexistingKey(record.rule, record.file));
|
|
1233
|
+
break;
|
|
1234
|
+
case "warned":
|
|
1235
|
+
state.warned.add(record.key);
|
|
1236
|
+
break;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return state;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// 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
|
+
var WARNING_SHARE = 0.15;
|
|
1249
|
+
var moreWarnings = (count) => `\u2026and ${count} more (run rulecast validate)`;
|
|
1250
|
+
function locationOf(spec) {
|
|
1251
|
+
return path9.isAbsolute(spec.path) ? { location: spec.path } : {};
|
|
1252
|
+
}
|
|
1253
|
+
function capturesFor(rule, match) {
|
|
1254
|
+
const names = templateBindings(rule.message ?? "");
|
|
1255
|
+
if (names.length === 0) return void 0;
|
|
1256
|
+
const values = {};
|
|
1257
|
+
for (const name of names) {
|
|
1258
|
+
const value = name === "text" ? match.text : match.captures[name];
|
|
1259
|
+
if (value !== void 0) values[name] = value;
|
|
1260
|
+
}
|
|
1261
|
+
return values;
|
|
1262
|
+
}
|
|
1263
|
+
function renderFindings(findings) {
|
|
1264
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1265
|
+
for (const { rule, match } of findings) {
|
|
1266
|
+
const message = renderTemplate(rule.message ?? "", {
|
|
1267
|
+
...match.captures,
|
|
1268
|
+
file: match.file,
|
|
1269
|
+
line: String(match.line),
|
|
1270
|
+
column: String(match.column),
|
|
1271
|
+
text: match.text,
|
|
1272
|
+
rule: rule.id
|
|
1273
|
+
});
|
|
1274
|
+
const key = `${rule.id} ${match.file} ${message}`;
|
|
1275
|
+
const existing = merged.get(key);
|
|
1276
|
+
if (existing) existing.count++;
|
|
1277
|
+
else
|
|
1278
|
+
merged.set(key, {
|
|
1279
|
+
rule: rule.id,
|
|
1280
|
+
severity: rule.severity,
|
|
1281
|
+
status: "new",
|
|
1282
|
+
file: match.file,
|
|
1283
|
+
line: match.line,
|
|
1284
|
+
column: match.column,
|
|
1285
|
+
message,
|
|
1286
|
+
count: 1,
|
|
1287
|
+
captures: capturesFor(rule, match)
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
return [...merged.values()].sort(
|
|
1291
|
+
(a, b) => Number(a.severity === "warning") - Number(b.severity === "warning") || (a.rule < b.rule ? -1 : a.rule > b.rule ? 1 : 0) || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
function referencesInOrder(rules) {
|
|
1295
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1296
|
+
const specs = [];
|
|
1297
|
+
for (const rule of rules) {
|
|
1298
|
+
for (const spec of rule.context) {
|
|
1299
|
+
if (seen.has(spec.ref)) continue;
|
|
1300
|
+
seen.add(spec.ref);
|
|
1301
|
+
specs.push(spec);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
return specs;
|
|
1305
|
+
}
|
|
1306
|
+
async function isCovered(spec, delivered, resolver) {
|
|
1307
|
+
for (const entry of delivered) {
|
|
1308
|
+
if (entry.path !== spec.path) continue;
|
|
1309
|
+
if (!await resolver.contains(spec.path, entry.anchor, spec.anchor)) continue;
|
|
1310
|
+
if (await resolver.currentHash(entry.path, entry.anchor) === entry.hash) return true;
|
|
1311
|
+
}
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
async function decide(input) {
|
|
1315
|
+
const delivery = emptyDelivery();
|
|
1316
|
+
const work = [];
|
|
1317
|
+
const context = [];
|
|
1318
|
+
const fresh = input.findings.filter((finding) => finding.status === "new");
|
|
1319
|
+
delivery.findings = renderFindings(fresh);
|
|
1320
|
+
const rulesById = new Map(fresh.map(({ rule }) => [rule.id, rule]));
|
|
1321
|
+
for (const { rule } of fresh) if (rule.message !== null) delivery.templates[rule.id] = rule.message;
|
|
1322
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
1323
|
+
for (const { rule, match, status } of input.findings) {
|
|
1324
|
+
if (status !== "preexisting") continue;
|
|
1325
|
+
const key = preexistingKey(rule.id, match.file);
|
|
1326
|
+
if (input.context.preexisting.has(key)) continue;
|
|
1327
|
+
const summary = summaries.get(key) ?? { rule: rule.id, file: match.file, count: 0 };
|
|
1328
|
+
summary.count++;
|
|
1329
|
+
summaries.set(key, summary);
|
|
1330
|
+
}
|
|
1331
|
+
delivery.preexistingSummary = [...summaries.values()];
|
|
1332
|
+
const unwarned = input.warnings.filter((warning) => !input.context.warned.has(warning.key));
|
|
1333
|
+
delivery.warnings = unwarned.map((warning) => warning.text);
|
|
1334
|
+
delivery.touches = input.touches.map((rule) => rule.id);
|
|
1335
|
+
for (const rule of input.touches) context.push({ t: "touched", rule: rule.id });
|
|
1336
|
+
const delivered = [...input.context.delivered];
|
|
1337
|
+
if (input.agentRead !== null) {
|
|
1338
|
+
const hash = await input.resolver.currentHash(input.agentRead, null);
|
|
1339
|
+
if (hash !== null) {
|
|
1340
|
+
delivered.push({ path: input.agentRead, anchor: null, hash });
|
|
1341
|
+
context.push({ t: "delivered", path: input.agentRead, anchor: null, hash });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const ruleOrder = [...new Map(fresh.map((finding) => [finding.rule.id, finding.rule])).values(), ...input.touches];
|
|
1345
|
+
const candidates = [];
|
|
1346
|
+
for (const spec of referencesInOrder(ruleOrder)) {
|
|
1347
|
+
const resolved = await input.resolver.resolve(spec);
|
|
1348
|
+
if (!resolved.found) {
|
|
1349
|
+
delivery.references.push({ ref: spec.ref, state: "missing" });
|
|
1350
|
+
} else if (await isCovered(spec, delivered, input.resolver)) {
|
|
1351
|
+
delivery.references.push({ ref: spec.ref, state: "pointer" });
|
|
1352
|
+
} else if (spec.mode === "read") {
|
|
1353
|
+
delivery.references.push({ ref: spec.ref, state: "read", reason: "mode", ...locationOf(spec) });
|
|
1354
|
+
} else if (resolved.bytes > input.maxBytes) {
|
|
1355
|
+
delivery.references.push({ ref: spec.ref, state: "read", reason: "tooLarge", ...locationOf(spec) });
|
|
1356
|
+
} else {
|
|
1357
|
+
candidates.push({ index: delivery.references.length, resolved });
|
|
1358
|
+
delivery.references.push({ ref: spec.ref, state: "full", content: resolved.content });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const limit = input.maxContextChars;
|
|
1362
|
+
const render = { maxMatchesPerRule: input.maxMatchesPerRule };
|
|
1363
|
+
const complete = {
|
|
1364
|
+
...delivery,
|
|
1365
|
+
findings: [...delivery.findings],
|
|
1366
|
+
preexistingSummary: [...delivery.preexistingSummary],
|
|
1367
|
+
references: [...delivery.references],
|
|
1368
|
+
warnings: [...delivery.warnings],
|
|
1369
|
+
omitted: { findings: [], rules: 0, preexisting: 0 }
|
|
1370
|
+
};
|
|
1371
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
1372
|
+
for (const finding of delivery.findings) {
|
|
1373
|
+
const group = byRule.get(finding.rule);
|
|
1374
|
+
if (group === void 0) byRule.set(finding.rule, [finding]);
|
|
1375
|
+
else group.push(finding);
|
|
1376
|
+
}
|
|
1377
|
+
let used = HEADER_OVERHEAD;
|
|
1378
|
+
const fits = (size) => limit === null || used + size <= limit;
|
|
1379
|
+
const kept = /* @__PURE__ */ new Map();
|
|
1380
|
+
const lineCost = (ref) => ref.length + REFERENCE_OVERHEAD;
|
|
1381
|
+
const resolvedRefs = new Set(delivery.references.map((reference) => reference.ref));
|
|
1382
|
+
const charged = /* @__PURE__ */ new Set();
|
|
1383
|
+
const orphaned = /* @__PURE__ */ new Set();
|
|
1384
|
+
const floorWithin = (cap) => {
|
|
1385
|
+
const spent = { chars: 0, kept: /* @__PURE__ */ new Map(), refs: /* @__PURE__ */ new Set(), dropped: 0 };
|
|
1386
|
+
const uncharged = (rule) => [
|
|
1387
|
+
...new Set(rule.context.map((spec) => spec.ref).filter((ref) => resolvedRefs.has(ref) && !spent.refs.has(ref)))
|
|
1388
|
+
];
|
|
1389
|
+
for (const [id, findings] of byRule) {
|
|
1390
|
+
const rule = rulesById.get(id);
|
|
1391
|
+
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);
|
|
1393
|
+
if (used + spent.chars + size > cap) {
|
|
1394
|
+
spent.dropped++;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
spent.chars += size;
|
|
1398
|
+
for (const ref of refs) spent.refs.add(ref);
|
|
1399
|
+
spent.kept.set(id, [findings[0]]);
|
|
1400
|
+
}
|
|
1401
|
+
for (const touch of input.touches) {
|
|
1402
|
+
for (const ref of uncharged(touch)) {
|
|
1403
|
+
spent.chars += lineCost(ref);
|
|
1404
|
+
spent.refs.add(ref);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return spent;
|
|
1408
|
+
};
|
|
1409
|
+
if (limit !== null) {
|
|
1410
|
+
let floor = floorWithin(limit);
|
|
1411
|
+
if (floor.dropped > 0) floor = floorWithin(limit - OVERFLOW_NOTICE);
|
|
1412
|
+
used += floor.chars + (floor.dropped > 0 ? OVERFLOW_NOTICE : 0);
|
|
1413
|
+
delivery.omitted.rules = floor.dropped;
|
|
1414
|
+
for (const [id, findings] of floor.kept) kept.set(id, findings);
|
|
1415
|
+
for (const ref of floor.refs) charged.add(ref);
|
|
1416
|
+
for (const ref of resolvedRefs) if (!charged.has(ref)) orphaned.add(ref);
|
|
1417
|
+
}
|
|
1418
|
+
const warningCost = (text) => text.length + ITEM_OVERHEAD;
|
|
1419
|
+
if (limit === null) {
|
|
1420
|
+
for (const warning of unwarned) context.push({ t: "warned", key: warning.key });
|
|
1421
|
+
} else {
|
|
1422
|
+
const ceiling = Math.max(0, Math.min(Math.floor(limit * WARNING_SHARE), limit - used));
|
|
1423
|
+
let spent = 0;
|
|
1424
|
+
let held = 0;
|
|
1425
|
+
while (held < unwarned.length && spent + warningCost(unwarned[held].text) <= ceiling) {
|
|
1426
|
+
spent += warningCost(unwarned[held].text);
|
|
1427
|
+
held++;
|
|
1428
|
+
}
|
|
1429
|
+
if (held < unwarned.length) {
|
|
1430
|
+
while (held > 0 && spent + warningCost(moreWarnings(unwarned.length - held)) > ceiling) {
|
|
1431
|
+
held--;
|
|
1432
|
+
spent -= warningCost(unwarned[held].text);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
delivery.warnings = unwarned.slice(0, held).map((warning) => warning.text);
|
|
1436
|
+
for (const warning of unwarned.slice(0, held)) context.push({ t: "warned", key: warning.key });
|
|
1437
|
+
const cut = unwarned.length - held;
|
|
1438
|
+
if (cut > 0 && spent + warningCost(moreWarnings(cut)) <= ceiling) {
|
|
1439
|
+
spent += warningCost(moreWarnings(cut));
|
|
1440
|
+
delivery.warnings.push(moreWarnings(cut));
|
|
1441
|
+
}
|
|
1442
|
+
used += spent;
|
|
1443
|
+
}
|
|
1444
|
+
for (const { index, resolved } of candidates) {
|
|
1445
|
+
const size = resolved.content.length;
|
|
1446
|
+
if (orphaned.has(resolved.spec.ref)) continue;
|
|
1447
|
+
if (!fits(size)) {
|
|
1448
|
+
delivery.references[index] = {
|
|
1449
|
+
ref: resolved.spec.ref,
|
|
1450
|
+
state: "read",
|
|
1451
|
+
reason: "budget",
|
|
1452
|
+
...locationOf(resolved.spec)
|
|
1453
|
+
};
|
|
1454
|
+
continue;
|
|
1455
|
+
}
|
|
1456
|
+
used += size;
|
|
1457
|
+
context.push({ t: "delivered", path: resolved.spec.path, anchor: resolved.spec.anchor, hash: resolved.hash });
|
|
1458
|
+
}
|
|
1459
|
+
if (delivery.preexistingSummary.length > 0) used += BACKLOG_HEADING.length + BACKLOG_HINT.length + ITEM_OVERHEAD;
|
|
1460
|
+
const summariesKept = [];
|
|
1461
|
+
for (const summary of delivery.preexistingSummary) {
|
|
1462
|
+
if (!fits(summary.rule.length + summary.file.length + ITEM_OVERHEAD)) {
|
|
1463
|
+
delivery.omitted.preexisting++;
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
used += summary.rule.length + summary.file.length + ITEM_OVERHEAD;
|
|
1467
|
+
summariesKept.push(summary);
|
|
1468
|
+
context.push({ t: "preexisting", rule: summary.rule, file: summary.file });
|
|
1469
|
+
}
|
|
1470
|
+
delivery.preexistingSummary = summariesKept;
|
|
1471
|
+
if (orphaned.size > 0) delivery.references = delivery.references.filter((one) => !orphaned.has(one.ref));
|
|
1472
|
+
if (limit !== null) {
|
|
1473
|
+
for (let added = true; added; ) {
|
|
1474
|
+
added = false;
|
|
1475
|
+
for (const [rule, findings] of byRule) {
|
|
1476
|
+
const shown = kept.get(rule);
|
|
1477
|
+
if (shown === void 0 || shown.length >= Math.min(findings.length, input.maxMatchesPerRule)) continue;
|
|
1478
|
+
const template = delivery.templates[rule];
|
|
1479
|
+
const delta = measureRuleBlock(rule, [...shown, findings[shown.length]], template, render) - measureRuleBlock(rule, shown, template, render);
|
|
1480
|
+
if (!fits(delta)) continue;
|
|
1481
|
+
used += delta;
|
|
1482
|
+
shown.push(findings[shown.length]);
|
|
1483
|
+
added = true;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
for (const [rule, findings] of byRule) {
|
|
1487
|
+
const shown = kept.get(rule) ?? [];
|
|
1488
|
+
if (shown.length === 0 || shown.length === findings.length) continue;
|
|
1489
|
+
const shownFiles = new Set(shown.map((finding) => finding.file));
|
|
1490
|
+
const dropped = findings.slice(shown.length);
|
|
1491
|
+
delivery.omitted.findings.push({
|
|
1492
|
+
rule,
|
|
1493
|
+
count: dropped.length,
|
|
1494
|
+
files: new Set(dropped.map((finding) => finding.file).filter((file) => !shownFiles.has(file))).size
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
delivery.findings = delivery.findings.filter((finding) => kept.get(finding.rule)?.includes(finding) === true);
|
|
1498
|
+
}
|
|
1499
|
+
if (input.stopGate) {
|
|
1500
|
+
const newErrors = fresh.some(({ rule }) => rule.severity === "error");
|
|
1501
|
+
const blocks = input.work.stopBlocks.get(input.agent) ?? 0;
|
|
1502
|
+
if (!newErrors) delivery.stop = "allow";
|
|
1503
|
+
else if (blocks < input.maxBlocks) {
|
|
1504
|
+
delivery.stop = "block";
|
|
1505
|
+
work.push({ t: "stopBlock", agent: input.agent });
|
|
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
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
return { delivery, overflow: delivery.omitted.rules > 0 ? complete : null, work, context };
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// src/core/guard.ts
|
|
1516
|
+
async function guardWrite(input) {
|
|
1517
|
+
const file = input.event.files[0];
|
|
1518
|
+
const intent = input.event.intent;
|
|
1519
|
+
if (file === void 0 || intent === void 0) return emptyDelivery();
|
|
1520
|
+
const rules = input.rules.filter((rule) => rule.refuseWrite && !input.disabled.has(rule.id) && rule.matches(file));
|
|
1521
|
+
if (rules.length === 0) return emptyDelivery();
|
|
1522
|
+
const proposal = propose(await readSourceFile(input.root, file), intent);
|
|
1523
|
+
if (proposal === null) {
|
|
1524
|
+
input.log(`guard: ${file} could not be reconstructed from the tool call; allowing the write`);
|
|
1525
|
+
return emptyDelivery();
|
|
1526
|
+
}
|
|
1527
|
+
if (proposal.inserts.length === 0) return emptyDelivery();
|
|
1528
|
+
if (proposal.content.length > input.config.maxFileBytes) {
|
|
1529
|
+
input.log(`guard: ${file} is over max_file_bytes (${input.config.maxFileBytes}); allowing the write`);
|
|
1530
|
+
return emptyDelivery();
|
|
1531
|
+
}
|
|
1532
|
+
const selections = selectDetectorRules(rules, "edit", [file], input.disabled);
|
|
1533
|
+
if (selections.length === 0) return emptyDelivery();
|
|
1534
|
+
const output = await runDetection({
|
|
1535
|
+
root: input.root,
|
|
1536
|
+
event: "edit",
|
|
1537
|
+
selections,
|
|
1538
|
+
changes: /* @__PURE__ */ new Map(),
|
|
1539
|
+
// 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 },
|
|
1545
|
+
timeoutMs: input.config.timeouts.editDeadlineMs
|
|
1546
|
+
});
|
|
1547
|
+
const evidence = output.findings.filter(({ rule, match }) => isInserted(proposal, match, rule.detector.kind));
|
|
1548
|
+
if (evidence.length === 0) return emptyDelivery();
|
|
1549
|
+
const spent = (rule) => (input.work.refusals.get(refusalKey(rule, file)) ?? 0) >= input.config.refuseGate.maxRefusals;
|
|
1550
|
+
const refusing = evidence.filter(({ rule }) => !spent(rule.id));
|
|
1551
|
+
if (refusing.length === 0) {
|
|
1552
|
+
input.log(`guard: ${file} has spent its refusals; allowing the write`);
|
|
1553
|
+
return emptyDelivery();
|
|
1554
|
+
}
|
|
1555
|
+
const { delivery } = await decide({
|
|
1556
|
+
agent: "main",
|
|
1557
|
+
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
|
+
resolver: input.resolver,
|
|
1567
|
+
maxBytes: input.config.context.maxBytes,
|
|
1568
|
+
maxBlocks: input.config.stopGate.maxBlocks,
|
|
1569
|
+
maxContextChars: input.maxContextChars,
|
|
1570
|
+
maxMatchesPerRule: input.config.maxMatchesPerRule,
|
|
1571
|
+
stopGate: false
|
|
1572
|
+
});
|
|
1573
|
+
await input.record(
|
|
1574
|
+
[...new Set(refusing.map(({ rule }) => rule.id))].map((rule) => ({ t: "refused", rule, file }))
|
|
1575
|
+
);
|
|
1576
|
+
return delivery;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// src/core/session/session.ts
|
|
1580
|
+
import path10 from "path";
|
|
1581
|
+
function safeSegment3(value) {
|
|
1582
|
+
const segment = value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
1583
|
+
return /^\.*$/.test(segment) ? `_${segment}` : segment;
|
|
1584
|
+
}
|
|
1585
|
+
function sessionDir(stateDir, sessionId) {
|
|
1586
|
+
return path10.join(stateDir, "sessions", safeSegment3(sessionId));
|
|
1587
|
+
}
|
|
1588
|
+
var workFile = (dir) => path10.join(dir, "work.jsonl");
|
|
1589
|
+
var contextFile = (dir, agent) => path10.join(dir, `context.${safeSegment3(agent)}.jsonl`);
|
|
1590
|
+
async function appendWork(dir, records) {
|
|
1591
|
+
await appendRecords(workFile(dir), records);
|
|
1592
|
+
}
|
|
1593
|
+
async function appendContext(dir, agent, records) {
|
|
1594
|
+
await appendRecords(contextFile(dir, agent), records);
|
|
1595
|
+
}
|
|
1596
|
+
async function openSession(dir, agent) {
|
|
1597
|
+
const [work, context] = await Promise.all([
|
|
1598
|
+
readRecords(workFile(dir)),
|
|
1599
|
+
readRecords(contextFile(dir, agent))
|
|
1600
|
+
]);
|
|
1601
|
+
return { work: foldWork(work), context: foldContext(context) };
|
|
1602
|
+
}
|
|
1603
|
+
async function commitSession(dir, agent, decideWith) {
|
|
1604
|
+
return withLock(dir, async () => {
|
|
1605
|
+
const decision = await decideWith(await openSession(dir, agent));
|
|
1606
|
+
await appendWork(dir, decision.work);
|
|
1607
|
+
await appendContext(dir, agent, decision.context);
|
|
1608
|
+
return decision;
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// src/core/pipeline.ts
|
|
1613
|
+
var SUMMARY_THRESHOLD = 3;
|
|
1614
|
+
var DEFAULT_HINT = "rulecast validate";
|
|
1615
|
+
function summaryKey(prefix, ids) {
|
|
1616
|
+
const digest = createHash3("sha256").update([...ids].sort().join("\n")).digest("hex").slice(0, 16);
|
|
1617
|
+
return `${prefix}:${digest}`;
|
|
1618
|
+
}
|
|
1619
|
+
function compileWarnings(errors) {
|
|
1620
|
+
if (errors.length <= SUMMARY_THRESHOLD) {
|
|
1621
|
+
return errors.map((diagnostic) => ({
|
|
1622
|
+
key: `diagnostic:${diagnostic.source}:${diagnostic.rule ?? ""}:${diagnostic.message}`,
|
|
1623
|
+
text: `${diagnosticText(diagnostic)} (run ${diagnostic.hint ?? DEFAULT_HINT})`
|
|
1624
|
+
}));
|
|
1625
|
+
}
|
|
1626
|
+
const hints = new Set(errors.map((diagnostic) => diagnostic.hint ?? DEFAULT_HINT));
|
|
1627
|
+
const hint = hints.size === 1 ? [...hints][0] : DEFAULT_HINT;
|
|
1628
|
+
return [
|
|
1629
|
+
{
|
|
1630
|
+
key: summaryKey(
|
|
1631
|
+
"diagnostics",
|
|
1632
|
+
errors.map((diagnostic) => diagnostic.rule ?? diagnostic.source)
|
|
1633
|
+
),
|
|
1634
|
+
text: `${errors.length} rules failed to compile and were skipped \u2014 run ${hint}.
|
|
1635
|
+
First: ${diagnosticText(errors[0])}`
|
|
1636
|
+
}
|
|
1637
|
+
];
|
|
1638
|
+
}
|
|
1639
|
+
function detectorWarnings(errors) {
|
|
1640
|
+
const text = (error) => `${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}. Disabled for this session.`;
|
|
1641
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
1642
|
+
for (const error of errors) {
|
|
1643
|
+
const group = byKind.get(error.kind);
|
|
1644
|
+
if (group === void 0) byKind.set(error.kind, [error]);
|
|
1645
|
+
else group.push(error);
|
|
1646
|
+
}
|
|
1647
|
+
const summarised = [];
|
|
1648
|
+
for (const [kind, group] of byKind) {
|
|
1649
|
+
if (group.length <= SUMMARY_THRESHOLD) {
|
|
1650
|
+
summarised.push(
|
|
1651
|
+
...group.map((error) => ({
|
|
1652
|
+
key: `detector:${kind}:${error.rules.join(",")}:${error.message}`,
|
|
1653
|
+
text: text(error)
|
|
1654
|
+
}))
|
|
1655
|
+
);
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
const ids = group.flatMap((error) => error.rules);
|
|
1659
|
+
summarised.push({
|
|
1660
|
+
key: summaryKey(`detector:${kind}`, ids),
|
|
1661
|
+
text: `${ids.length} ${kind} rules were disabled this session \u2014 see debug.log.
|
|
1662
|
+
First: ${text(group[0])}`
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
return summarised;
|
|
1666
|
+
}
|
|
1667
|
+
async function runPipeline(options) {
|
|
1668
|
+
const { project, stateDir, event, registry } = options;
|
|
1669
|
+
const { root, config } = project;
|
|
1670
|
+
const log = options.log ?? (() => {
|
|
1671
|
+
});
|
|
1672
|
+
const errors = project.diagnostics.filter((diagnostic) => diagnostic.level === "error");
|
|
1673
|
+
const warnings = compileWarnings(errors);
|
|
1674
|
+
let failed = errors.length > 0;
|
|
1675
|
+
const deadlineMissed = [];
|
|
1676
|
+
let session = event.session ? { dir: sessionDir(stateDir, event.session.id), agent: event.session.agentId ?? "main" } : null;
|
|
1677
|
+
const STORE_UNREADABLE = "session state could not be read; delivered without session memory";
|
|
1678
|
+
async function readingStore(read, fallback) {
|
|
1679
|
+
try {
|
|
1680
|
+
return await read();
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
if (!(error instanceof CorruptStoreError)) throw error;
|
|
1683
|
+
log(`session store unreadable: ${error.message}`);
|
|
1684
|
+
session = null;
|
|
1685
|
+
warnings.push({ key: "store", text: STORE_UNREADABLE });
|
|
1686
|
+
return fallback;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
const restoredFiles = options.restoredFiles ?? 0;
|
|
1690
|
+
if (event.kind === "prompt") {
|
|
1691
|
+
if (session) await appendWork(session.dir, [{ t: "prompt", agent: session.agent }]);
|
|
1692
|
+
return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
1693
|
+
}
|
|
1694
|
+
if (event.kind === "reset") {
|
|
1695
|
+
if (session) await appendContext(session.dir, session.agent, [{ t: "reset" }]);
|
|
1696
|
+
if (!session || restoredFiles === 0) return { delivery: emptyDelivery(), failed, deadlineMissed };
|
|
1697
|
+
}
|
|
1698
|
+
let baseline = {
|
|
1699
|
+
started: false,
|
|
1700
|
+
startCommit: null,
|
|
1701
|
+
snapshots: /* @__PURE__ */ new Map(),
|
|
1702
|
+
fingerprints: /* @__PURE__ */ new Map()
|
|
1703
|
+
};
|
|
1704
|
+
if (session && event.kind !== "reset" && event.kind !== "guard") {
|
|
1705
|
+
const dir = session.dir;
|
|
1706
|
+
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
1707
|
+
if (session && !baseline.started) {
|
|
1708
|
+
await appendBaseline(dir, [startRecord(await headCommit(root))]);
|
|
1709
|
+
baseline = await readingStore(() => readBaseline(dir), baseline);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
const empty = () => ({ work: emptyWork(), context: emptyContext() });
|
|
1713
|
+
const view = session ? await readingStore(() => openSession(session.dir, session.agent), empty()) : empty();
|
|
1714
|
+
const disabled = new Set(view.work.disabled.keys());
|
|
1715
|
+
const resolver = createReferenceResolver(root);
|
|
1716
|
+
const workRecords = [];
|
|
1717
|
+
const relevant = (files) => files.filter((file) => project.rules.some((rule) => rule.matches(file)));
|
|
1718
|
+
if (event.kind === "guard") {
|
|
1719
|
+
const open2 = session;
|
|
1720
|
+
const delivery = await guardWrite({
|
|
1721
|
+
root,
|
|
1722
|
+
config,
|
|
1723
|
+
registry,
|
|
1724
|
+
stateDir,
|
|
1725
|
+
event,
|
|
1726
|
+
rules: project.rules,
|
|
1727
|
+
disabled,
|
|
1728
|
+
work: view.work,
|
|
1729
|
+
resolver,
|
|
1730
|
+
maxContextChars: options.maxContextChars,
|
|
1731
|
+
log,
|
|
1732
|
+
record: open2 ? (records) => appendWork(open2.dir, records) : async () => {
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
return { delivery, failed, deadlineMissed };
|
|
1736
|
+
}
|
|
1737
|
+
let touches = [];
|
|
1738
|
+
let findings = [];
|
|
1739
|
+
let agentRead = null;
|
|
1740
|
+
if (event.kind === "touch" || event.kind === "edit") {
|
|
1741
|
+
touches = selectTouchRules(project.rules, event.files, view.context.touched, disabled);
|
|
1742
|
+
const open2 = session;
|
|
1743
|
+
if (open2) {
|
|
1744
|
+
await appendWork(
|
|
1745
|
+
open2.dir,
|
|
1746
|
+
event.files.map((file) => ({ t: "accessed", agent: open2.agent, file }))
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (event.kind === "reset" && session) {
|
|
1751
|
+
const recent = (view.work.accessed.get(session.agent) ?? []).slice(-restoredFiles).reverse();
|
|
1752
|
+
touches = selectTouchRules(project.rules, recent, view.context.touched, disabled);
|
|
1753
|
+
}
|
|
1754
|
+
if (event.kind === "touch") {
|
|
1755
|
+
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
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if (event.kind === "edit" || event.kind === "verify") {
|
|
1789
|
+
let files = relevant(event.files);
|
|
1790
|
+
let snapshots = session ? baseline.snapshots : /* @__PURE__ */ new Map();
|
|
1791
|
+
let fallbackCommit = session ? baseline.startCommit : null;
|
|
1792
|
+
if (event.kind === "edit" && session) {
|
|
1793
|
+
await appendWork(
|
|
1794
|
+
session.dir,
|
|
1795
|
+
files.map((file) => ({ t: "edited", file }))
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
if (event.kind === "verify") {
|
|
1799
|
+
if (event.baseCommit) {
|
|
1800
|
+
fallbackCommit = event.baseCommit;
|
|
1801
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
1802
|
+
}
|
|
1803
|
+
if (event.files.length === 0 && session) files = relevant(view.work.edited);
|
|
1804
|
+
}
|
|
1805
|
+
const baselineChanges = await computeChanges(root, files, { snapshots, fallbackCommit });
|
|
1806
|
+
const changes = baselineChanges.sets;
|
|
1807
|
+
if (event.kind === "verify" && session && !event.baseCommit) {
|
|
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
|
+
}
|
|
1835
|
+
const skip = options.skipDetectorKinds ?? /* @__PURE__ */ new Set();
|
|
1836
|
+
let selections = selectDetectorRules(project.rules, event.kind, files, disabled).filter(
|
|
1837
|
+
(selection) => !skip.has(selection.rule.detector.kind) && (options.onlyRules?.has(selection.rule.id) ?? true)
|
|
1838
|
+
);
|
|
1839
|
+
let overBudget = [];
|
|
1840
|
+
if (event.kind === "verify") {
|
|
1841
|
+
const budgeted = applyFileBudget("llm", selections, config.llm.maxFilesPerVerify, [...view.work.edited].reverse());
|
|
1842
|
+
selections = budgeted.selections;
|
|
1843
|
+
overBudget = budgeted.skipped;
|
|
1844
|
+
}
|
|
1845
|
+
if (event.kind === "edit") {
|
|
1846
|
+
const ceiling = await applySizeCeiling(
|
|
1847
|
+
selections,
|
|
1848
|
+
config.maxFileBytes,
|
|
1849
|
+
(kind) => registry.get(kind)?.guards === true,
|
|
1850
|
+
(file) => fileBytes(root, file)
|
|
1851
|
+
);
|
|
1852
|
+
selections = ceiling.selections;
|
|
1853
|
+
if (ceiling.skipped.length > 0) {
|
|
1854
|
+
log(`over max_file_bytes (${config.maxFileBytes}), not checked on this edit: ${ceiling.skipped.join(", ")}`);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
const output = await runDetection({
|
|
1858
|
+
root,
|
|
1859
|
+
event: event.kind,
|
|
1860
|
+
selections,
|
|
1861
|
+
changes,
|
|
1862
|
+
read: (file) => readSourceFile(root, file),
|
|
1863
|
+
registry,
|
|
1864
|
+
cacheFor: (kind) => diskCache(detectorCacheDir(stateDir, kind)),
|
|
1865
|
+
contextFor: (rule) => resolveRuleContext(resolver, rule.context),
|
|
1866
|
+
settings: { llm: config.llm },
|
|
1867
|
+
timeoutMs: event.kind === "edit" ? config.timeouts.editDeadlineMs : config.timeouts.verifyMs
|
|
1868
|
+
});
|
|
1869
|
+
findings = output.findings.map(({ rule, match }) => ({
|
|
1870
|
+
rule,
|
|
1871
|
+
match,
|
|
1872
|
+
status: classify(match, rule.scope, rule.id, baselineChanges, baseline.fingerprints)
|
|
1873
|
+
}));
|
|
1874
|
+
for (const error of output.errors) {
|
|
1875
|
+
failed = true;
|
|
1876
|
+
log(`${error.kind} detector failed for ${error.rules.join(", ")}: ${error.message}`);
|
|
1877
|
+
for (const rule of error.rules) workRecords.push({ t: "disabled", rule, reason: error.message });
|
|
1878
|
+
}
|
|
1879
|
+
warnings.push(...detectorWarnings(output.errors));
|
|
1880
|
+
if (overBudget.length > 0) {
|
|
1881
|
+
warnings.push({
|
|
1882
|
+
key: `llm-budget:${overBudget.join(",")}`,
|
|
1883
|
+
text: `llm rules checked ${config.llm.maxFilesPerVerify} files; ${overBudget.length} were not checked: ${overBudget.join(", ")}. Raise llm.max_files_per_verify, or run rulecast run --files on them.`
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
for (const timeout of output.timedOut) {
|
|
1887
|
+
if (event.kind === "edit") {
|
|
1888
|
+
log(`edit deadline passed for ${timeout.kind}: ${timeout.rules.join(", ")}`);
|
|
1889
|
+
deadlineMissed.push(timeout.kind);
|
|
1890
|
+
} else {
|
|
1891
|
+
failed = true;
|
|
1892
|
+
warnings.push({
|
|
1893
|
+
key: `timeout:${timeout.kind}:${timeout.rules.join(",")}`,
|
|
1894
|
+
// Naming the lever matters: a field trial measured a single haiku call on a 135-line file
|
|
1895
|
+
// 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 === "llm" ? " (an llm rule on a large file can need 120000 or more)" : ""}.`
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
const inputFor = (state) => ({
|
|
1902
|
+
agent: session?.agent ?? "main",
|
|
1903
|
+
findings,
|
|
1904
|
+
touches: touches.filter((rule) => !state.context.touched.has(rule.id)),
|
|
1905
|
+
agentRead,
|
|
1906
|
+
warnings,
|
|
1907
|
+
work: state.work,
|
|
1908
|
+
context: state.context,
|
|
1909
|
+
resolver,
|
|
1910
|
+
maxBytes: config.context.maxBytes,
|
|
1911
|
+
maxBlocks: config.stopGate.maxBlocks,
|
|
1912
|
+
maxContextChars: options.maxContextChars,
|
|
1913
|
+
maxMatchesPerRule: config.maxMatchesPerRule,
|
|
1914
|
+
stopGate: options.stopGate === true && event.kind === "verify" && session !== null
|
|
1915
|
+
});
|
|
1916
|
+
const persisted = (decision) => {
|
|
1917
|
+
if (decision.overflow !== null) {
|
|
1918
|
+
decision.delivery.overflowPath = writeOverflow(stateDir, event.session?.id ?? null, decision.overflow);
|
|
1919
|
+
}
|
|
1920
|
+
return decision.delivery;
|
|
1921
|
+
};
|
|
1922
|
+
if (!session) return { delivery: persisted(await decide(inputFor(view))), failed, deadlineMissed };
|
|
1923
|
+
await appendWork(session.dir, workRecords);
|
|
1924
|
+
try {
|
|
1925
|
+
const decision = await commitSession(session.dir, session.agent, (state) => decide(inputFor(state)));
|
|
1926
|
+
return { delivery: persisted(decision), failed, deadlineMissed };
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
if (error instanceof CorruptStoreError) {
|
|
1929
|
+
log(`session store unreadable: ${error.message}`);
|
|
1930
|
+
warnings.push({ key: "store", text: STORE_UNREADABLE });
|
|
1931
|
+
} else if (error instanceof LockTimeoutError) {
|
|
1932
|
+
warnings.push({ key: "lock", text: "session state was locked; delivered without session memory" });
|
|
1933
|
+
} else throw error;
|
|
1934
|
+
const decision = await decide({ ...inputFor({ work: emptyWork(), context: emptyContext() }), stopGate: false });
|
|
1935
|
+
return { delivery: persisted(decision), failed, deadlineMissed };
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
export {
|
|
1940
|
+
defaultConfig,
|
|
1941
|
+
CONFIG_FILE,
|
|
1942
|
+
MANIFEST_FILE,
|
|
1943
|
+
readConfigData,
|
|
1944
|
+
parseConfig,
|
|
1945
|
+
readManifest,
|
|
1946
|
+
git,
|
|
1947
|
+
mergeBase,
|
|
1948
|
+
changedFilesSince,
|
|
1949
|
+
allFiles,
|
|
1950
|
+
stagedFiles,
|
|
1951
|
+
changedFilesBetween,
|
|
1952
|
+
LockTimeoutError,
|
|
1953
|
+
withLock,
|
|
1954
|
+
repoLabel,
|
|
1955
|
+
RepoFetchError,
|
|
1956
|
+
NO_PROMPT,
|
|
1957
|
+
fetchCheckout,
|
|
1958
|
+
cachedRepo,
|
|
1959
|
+
ensureRepo,
|
|
1960
|
+
cacheHome,
|
|
1961
|
+
projectStateDir,
|
|
1962
|
+
ensureProjectState,
|
|
1963
|
+
debugLogger,
|
|
1964
|
+
diagnosticText,
|
|
1965
|
+
compile,
|
|
1966
|
+
compileManifest,
|
|
1967
|
+
rulesOfKind,
|
|
1968
|
+
checkableKinds,
|
|
1969
|
+
checkDetectors,
|
|
1970
|
+
runDetection,
|
|
1971
|
+
cachedRepos,
|
|
1972
|
+
fetchingRepos,
|
|
1973
|
+
fixedRepo,
|
|
1974
|
+
resolveRuleContext,
|
|
1975
|
+
createReferenceResolver,
|
|
1976
|
+
runPipeline
|
|
1977
|
+
};
|