dsh-rules 0.1.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/LICENSE +21 -0
- package/README.md +188 -0
- package/README.zh.md +188 -0
- package/cordis.patch.yml +20 -0
- package/lib/fs.js +195 -0
- package/lib/index.js +448 -0
- package/lib/rules.js +336 -0
- package/package.json +53 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-rules: glob-activated rule prompts for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* A host-plane Cordis plugin that discovers rule files (project
|
|
5
|
+
* `<root>/.dsh/rules/*.md`, user `<dshHome>/rules/*.md`, and optionally
|
|
6
|
+
* `# Path:` sections of CLAUDE.md / AGENTS.md), tracks the files each agent
|
|
7
|
+
* reads or edits through `fs/observed`, and injects the currently active rule
|
|
8
|
+
* set as a superseding user message at every `agent/pre-step`, Claude Code
|
|
9
|
+
* rules.md style.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-rules
|
|
12
|
+
*/
|
|
13
|
+
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
14
|
+
import { dshHomeDisplay, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
15
|
+
import z from "@deepseek-ai/schemastery";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
EMPTY_RULES_TEXT,
|
|
19
|
+
compileMatcher,
|
|
20
|
+
mergeRuleSources,
|
|
21
|
+
parseClaudePathSections,
|
|
22
|
+
parseRuleFile,
|
|
23
|
+
renderRules
|
|
24
|
+
} from "./rules.js";
|
|
25
|
+
import {
|
|
26
|
+
findProjectRoot,
|
|
27
|
+
listRuleDirEntries,
|
|
28
|
+
posixRelative,
|
|
29
|
+
readRuleText,
|
|
30
|
+
statRuleFile
|
|
31
|
+
} from "./fs.js";
|
|
32
|
+
|
|
33
|
+
/** Stable Cordis provider name. */
|
|
34
|
+
const name = "dsh-rules";
|
|
35
|
+
/** Precedence ranks: lower wins. */
|
|
36
|
+
const PROJECT_RANK = 100;
|
|
37
|
+
const USER_RANK = 200;
|
|
38
|
+
const CLAUDE_SECTION_RANK = 300;
|
|
39
|
+
|
|
40
|
+
const DEFAULT_PROJECT_ROOT_MARKERS = [".git"];
|
|
41
|
+
const DEFAULT_RULE_DIR_NAMES = [".dsh/rules"];
|
|
42
|
+
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ["AGENTS.md", "CLAUDE.md"];
|
|
43
|
+
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ["AGENTS.local.md", "CLAUDE.local.md"];
|
|
44
|
+
const DEFAULT_MAX_BYTES = 32768;
|
|
45
|
+
const DEFAULT_MAX_SOURCE_BYTES = 1048576;
|
|
46
|
+
const DEFAULT_MAX_TOUCHED_PATHS = 512;
|
|
47
|
+
const RESERVED_PATH_SEGMENTS = new Set(["", ".", ".."]);
|
|
48
|
+
const USER_GLOBAL_INSTRUCTION_FILE = "AGENTS.md";
|
|
49
|
+
|
|
50
|
+
const Config = z.object({
|
|
51
|
+
dshHome: z.string(),
|
|
52
|
+
projectRootMarkers: z.array(z.string()).default([...DEFAULT_PROJECT_ROOT_MARKERS]),
|
|
53
|
+
ruleDirNames: z.array(z.string()).default([...DEFAULT_RULE_DIR_NAMES]),
|
|
54
|
+
includeUserRules: z.boolean().default(true),
|
|
55
|
+
includeClaudeSections: z.boolean().default(false),
|
|
56
|
+
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
|
|
57
|
+
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
|
|
58
|
+
maxBytes: z.number().default(DEFAULT_MAX_BYTES),
|
|
59
|
+
maxSourceBytes: z.number().default(DEFAULT_MAX_SOURCE_BYTES),
|
|
60
|
+
maxTouchedPaths: z.number().default(DEFAULT_MAX_TOUCHED_PATHS)
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register the rules pipeline: touch tracking, per-session state, and
|
|
65
|
+
* pre-step injection.
|
|
66
|
+
* @param ctx - Cordis context (host plane).
|
|
67
|
+
* @param config - plugin configuration.
|
|
68
|
+
*/
|
|
69
|
+
function apply(ctx, config = {}) {
|
|
70
|
+
const resolved = resolveConfig(config);
|
|
71
|
+
const state = new RulesState(ctx, resolved);
|
|
72
|
+
ctx.on("fs/observed", (target, observation, actor) => {
|
|
73
|
+
if (observation === void 0 || observation.kind === "absent") return;
|
|
74
|
+
const agent = actor?.agent;
|
|
75
|
+
if (agent === void 0) return;
|
|
76
|
+
const displayPath = target?.displayPath;
|
|
77
|
+
if (typeof displayPath !== "string" || displayPath.length === 0) return;
|
|
78
|
+
state.touch(agent, displayPath).catch((error) => {
|
|
79
|
+
ctx.logger.warn(`dsh-rules: failed to record touched path: ${errorMessage(error)}`);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
ctx.on("agent/disposed", ({ agent }) => {
|
|
83
|
+
state.disposeSession(agent);
|
|
84
|
+
});
|
|
85
|
+
ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
|
|
86
|
+
const decision = await next();
|
|
87
|
+
if (decision.kind !== "enter" || agent === void 0) return decision;
|
|
88
|
+
try {
|
|
89
|
+
const desired = await state.compose(agent, signal);
|
|
90
|
+
if (desired === void 0 || decision.messages.some((message) => sameRulesMessage(message, desired))) return decision;
|
|
91
|
+
const lastClaimedIndex = decision.messages.findLastIndex((message) => messages.includes(message));
|
|
92
|
+
return {
|
|
93
|
+
kind: "enter",
|
|
94
|
+
messages: decision.messages.toSpliced(lastClaimedIndex + 1, 0, desired)
|
|
95
|
+
};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (signal?.aborted === true) throw error;
|
|
98
|
+
ctx.logger.warn(`dsh-rules: rule injection failed: ${errorMessage(error)}`);
|
|
99
|
+
return decision;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Normalize plugin configuration and harness home. */
|
|
105
|
+
function resolveConfig(config) {
|
|
106
|
+
return {
|
|
107
|
+
dshHome: resolveDshHome(config.dshHome),
|
|
108
|
+
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
|
|
109
|
+
ruleDirNames: cleanPathSegments(config.ruleDirNames, DEFAULT_RULE_DIR_NAMES),
|
|
110
|
+
includeUserRules: config.includeUserRules ?? true,
|
|
111
|
+
includeClaudeSections: config.includeClaudeSections ?? false,
|
|
112
|
+
instructionFileCandidates: cleanPathSegments(config.instructionFileCandidates, DEFAULT_INSTRUCTION_FILE_CANDIDATES),
|
|
113
|
+
localInstructionFileCandidates: cleanPathSegments(config.localInstructionFileCandidates, DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES),
|
|
114
|
+
maxBytes: config.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
115
|
+
maxSourceBytes: config.maxSourceBytes ?? DEFAULT_MAX_SOURCE_BYTES,
|
|
116
|
+
maxTouchedPaths: config.maxTouchedPaths ?? DEFAULT_MAX_TOUCHED_PATHS
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cleanPathSegments(candidates, fallback) {
|
|
121
|
+
return (candidates ?? [...fallback]).filter((candidate) => !RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Per-process rules state: versioned rule caches and per-session touch sets. */
|
|
125
|
+
var RulesState = class {
|
|
126
|
+
ctx;
|
|
127
|
+
resolved;
|
|
128
|
+
fileSystem;
|
|
129
|
+
/** sessionId -> insertion-ordered relative path map (FIFO eviction). */
|
|
130
|
+
touched = /* @__PURE__ */ new Map();
|
|
131
|
+
/** sessionId -> last injected snapshot text (`null` = none yet injected). */
|
|
132
|
+
lastInjected = /* @__PURE__ */ new Map();
|
|
133
|
+
/** sessionId -> cached project root promise. */
|
|
134
|
+
projectRoots = /* @__PURE__ */ new Map();
|
|
135
|
+
/** absolute rule-source path -> version-cached raw text. */
|
|
136
|
+
fileCache = /* @__PURE__ */ new Map();
|
|
137
|
+
/** absolute paths already warned about this process. */
|
|
138
|
+
warned = /* @__PURE__ */ new Set();
|
|
139
|
+
constructor(ctx, resolved) {
|
|
140
|
+
this.ctx = ctx;
|
|
141
|
+
this.resolved = resolved;
|
|
142
|
+
this.fileSystem = ctx.get("fs");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Record one observed file for an agent's session, relative to its project
|
|
146
|
+
* root. Files outside the project root never activate rules.
|
|
147
|
+
* @param agent - the agent whose session observed the file.
|
|
148
|
+
* @param displayPath - host display path of the observed target.
|
|
149
|
+
*/
|
|
150
|
+
async touch(agent, displayPath) {
|
|
151
|
+
const sessionId = agent.session.id;
|
|
152
|
+
const projectRoot = await this.projectRootFor(agent);
|
|
153
|
+
const relativePath = posixRelative(projectRoot, displayPath);
|
|
154
|
+
if (relativePath === void 0) return;
|
|
155
|
+
let set = this.touched.get(sessionId);
|
|
156
|
+
if (set === void 0) {
|
|
157
|
+
set = /* @__PURE__ */ new Map();
|
|
158
|
+
this.touched.set(sessionId, set);
|
|
159
|
+
}
|
|
160
|
+
set.delete(relativePath);
|
|
161
|
+
set.set(relativePath, true);
|
|
162
|
+
while (set.size > this.resolved.maxTouchedPaths) {
|
|
163
|
+
const oldest = set.keys().next().value;
|
|
164
|
+
set.delete(oldest);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Resolve and cache the project root for an agent's session cwd.
|
|
169
|
+
* @param agent - the agent owning the session.
|
|
170
|
+
* @returns the absolute project root.
|
|
171
|
+
*/
|
|
172
|
+
projectRootFor(agent) {
|
|
173
|
+
const sessionId = agent.session.id;
|
|
174
|
+
let cached = this.projectRoots.get(sessionId);
|
|
175
|
+
if (cached === void 0) {
|
|
176
|
+
const cwd = agent.session.header.cwd ?? process.cwd();
|
|
177
|
+
cached = findProjectRoot(cwd, this.resolved.projectRootMarkers, this.fileSystem, void 0).catch((error) => {
|
|
178
|
+
this.projectRoots.delete(sessionId);
|
|
179
|
+
throw error;
|
|
180
|
+
});
|
|
181
|
+
this.projectRoots.set(sessionId, cached);
|
|
182
|
+
}
|
|
183
|
+
return cached;
|
|
184
|
+
}
|
|
185
|
+
/** Drop every per-session state entry owned by a disposed agent. */
|
|
186
|
+
disposeSession(agent) {
|
|
187
|
+
const sessionId = agent.session.id;
|
|
188
|
+
this.touched.delete(sessionId);
|
|
189
|
+
this.lastInjected.delete(sessionId);
|
|
190
|
+
this.projectRoots.delete(sessionId);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Compute the desired rules snapshot message for one agent's next step.
|
|
194
|
+
* Returns `undefined` when nothing should be injected (rules unchanged or
|
|
195
|
+
* nothing active and nothing previously injected).
|
|
196
|
+
* @param agent - the agent about to step.
|
|
197
|
+
* @param signal - turn cancellation.
|
|
198
|
+
* @returns the message to inject, or `undefined`.
|
|
199
|
+
*/
|
|
200
|
+
async compose(agent, signal) {
|
|
201
|
+
const { maxBytes } = this.resolved;
|
|
202
|
+
if (!(maxBytes > 0) || !Number.isFinite(maxBytes)) return void 0;
|
|
203
|
+
const session = agent.session;
|
|
204
|
+
const sessionId = session.id;
|
|
205
|
+
if (!this.lastInjected.has(sessionId)) this.seedFromSession(session);
|
|
206
|
+
signal?.throwIfAborted();
|
|
207
|
+
const cwd = session.header.cwd ?? process.cwd();
|
|
208
|
+
const projectRoot = await this.projectRootFor(agent);
|
|
209
|
+
signal?.throwIfAborted();
|
|
210
|
+
const rules = await this.loadRules(projectRoot, cwd, signal);
|
|
211
|
+
signal?.throwIfAborted();
|
|
212
|
+
const touched = this.touched.get(sessionId);
|
|
213
|
+
const active = [];
|
|
214
|
+
const matchedFiles = /* @__PURE__ */ new Set();
|
|
215
|
+
for (const rule of rules) {
|
|
216
|
+
if (rule.globs.length === 0) {
|
|
217
|
+
active.push(rule);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let matched = false;
|
|
221
|
+
if (touched !== void 0) for (const relativePath of touched.keys()) {
|
|
222
|
+
if (rule.matcher(relativePath)) {
|
|
223
|
+
matched = true;
|
|
224
|
+
matchedFiles.add(relativePath);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (matched) active.push(rule);
|
|
228
|
+
}
|
|
229
|
+
const desiredText = active.length === 0
|
|
230
|
+
? null
|
|
231
|
+
: renderRules(active, {
|
|
232
|
+
maxBytes,
|
|
233
|
+
matchedFiles: [...matchedFiles].sort()
|
|
234
|
+
}).text;
|
|
235
|
+
const previous = this.lastInjected.get(sessionId);
|
|
236
|
+
if (desiredText === null) {
|
|
237
|
+
if (previous === void 0 || previous === null || previous === EMPTY_RULES_TEXT) return void 0;
|
|
238
|
+
this.lastInjected.set(sessionId, EMPTY_RULES_TEXT);
|
|
239
|
+
return rulesMessage(EMPTY_RULES_TEXT);
|
|
240
|
+
}
|
|
241
|
+
if (previous === desiredText) return void 0;
|
|
242
|
+
this.lastInjected.set(sessionId, desiredText);
|
|
243
|
+
return rulesMessage(desiredText);
|
|
244
|
+
}
|
|
245
|
+
/** Restore the last injected snapshot (and matched files) from a resumed session log. */
|
|
246
|
+
seedFromSession(session) {
|
|
247
|
+
const events = session.events;
|
|
248
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
249
|
+
const event = events[index];
|
|
250
|
+
if (event?.type !== "user/message") continue;
|
|
251
|
+
const source = event.data?.source;
|
|
252
|
+
if (source?.kind !== "plugin" || source.plugin !== name || source.form !== "rules") continue;
|
|
253
|
+
const [block] = event.data.content;
|
|
254
|
+
const text = event.data.content.length === 1 && block?.type === "text" ? block.text : void 0;
|
|
255
|
+
if (text === void 0) break;
|
|
256
|
+
this.lastInjected.set(session.id, text);
|
|
257
|
+
this.seedTouchedFromText(session.id, text);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
this.lastInjected.set(session.id, null);
|
|
261
|
+
}
|
|
262
|
+
/** Recover previously matched relative paths from a rendered snapshot intro. */
|
|
263
|
+
seedTouchedFromText(sessionId, text) {
|
|
264
|
+
const match = /\(matched files: ([^)]*)\)/.exec(text);
|
|
265
|
+
if (match === null) return;
|
|
266
|
+
const paths = match[1].split(", ").map((item) => unescapeAttr(item.trim())).filter((item) => item.length > 0);
|
|
267
|
+
if (paths.length === 0) return;
|
|
268
|
+
let set = this.touched.get(sessionId);
|
|
269
|
+
if (set === void 0) {
|
|
270
|
+
set = /* @__PURE__ */ new Map();
|
|
271
|
+
this.touched.set(sessionId, set);
|
|
272
|
+
}
|
|
273
|
+
for (const path of paths) set.set(path, true);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Discover and parse every rule source for one workspace, version-cached.
|
|
277
|
+
* @param projectRoot - absolute project root.
|
|
278
|
+
* @param cwd - absolute session working directory.
|
|
279
|
+
* @param signal - cancellation for filesystem work.
|
|
280
|
+
* @returns the merged, precedence-ordered rule catalog.
|
|
281
|
+
*/
|
|
282
|
+
async loadRules(projectRoot, cwd, signal) {
|
|
283
|
+
const groups = [];
|
|
284
|
+
const projectRules = [];
|
|
285
|
+
for (const dirName of this.resolved.ruleDirNames) {
|
|
286
|
+
const entries = await listRuleDirEntries(join(projectRoot, dirName), this.fileSystem, signal);
|
|
287
|
+
if (entries === void 0 || entries === null) continue;
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (entry.type !== "file" || !entry.name.endsWith(".md")) continue;
|
|
290
|
+
const rule = await this.loadFlatRule(join(projectRoot, dirName, entry.name), `${dirName}/${entry.name}`, entry.name.slice(0, -3), PROJECT_RANK, signal);
|
|
291
|
+
if (rule !== void 0) projectRules.push(rule);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
groups.push({ rank: PROJECT_RANK, rules: projectRules });
|
|
295
|
+
const userRules = [];
|
|
296
|
+
if (this.resolved.includeUserRules) {
|
|
297
|
+
const userDir = join(this.resolved.dshHome, "rules");
|
|
298
|
+
const entries = await listRuleDirEntries(userDir, this.fileSystem, signal);
|
|
299
|
+
if (entries !== void 0 && entries !== null) {
|
|
300
|
+
const displayRoot = `${dshHomeDisplay(this.resolved.dshHome)}/rules`;
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
if (entry.type !== "file" || !entry.name.endsWith(".md")) continue;
|
|
303
|
+
const rule = await this.loadFlatRule(join(userDir, entry.name), `${displayRoot}/${entry.name}`, entry.name.slice(0, -3), USER_RANK, signal);
|
|
304
|
+
if (rule !== void 0) userRules.push(rule);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
groups.push({ rank: USER_RANK, rules: userRules });
|
|
309
|
+
const claudeRules = [];
|
|
310
|
+
if (this.resolved.includeClaudeSections) {
|
|
311
|
+
const candidates = [
|
|
312
|
+
join(this.resolved.dshHome, USER_GLOBAL_INSTRUCTION_FILE),
|
|
313
|
+
...ancestorChain(projectRoot, cwd).flatMap((dir) => [
|
|
314
|
+
...this.resolved.instructionFileCandidates,
|
|
315
|
+
...this.resolved.localInstructionFileCandidates
|
|
316
|
+
].map((candidate) => join(dir, candidate)))
|
|
317
|
+
];
|
|
318
|
+
for (const filePath of candidates) {
|
|
319
|
+
const sections = await this.loadClaudeSections(filePath, projectRoot, signal);
|
|
320
|
+
if (sections !== void 0) claudeRules.push(...sections);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
groups.push({ rank: CLAUDE_SECTION_RANK, rules: claudeRules });
|
|
324
|
+
const merged = mergeRuleSources(groups);
|
|
325
|
+
for (const dropped of merged.dropped) this.warnOnce(`rule "${dropped.name}" (${dropped.source}) ignored: a higher-priority rule with the same name exists`);
|
|
326
|
+
return merged.rules;
|
|
327
|
+
}
|
|
328
|
+
/** Load and parse one flat rule file through the version cache. */
|
|
329
|
+
async loadFlatRule(filePath, source, fallbackName, rank, signal) {
|
|
330
|
+
const raw = await this.loadCachedText(filePath, signal);
|
|
331
|
+
if (raw === void 0) return void 0;
|
|
332
|
+
const rule = parseRuleFile(raw, fallbackName);
|
|
333
|
+
if (rule === void 0) {
|
|
334
|
+
this.warnOnce(`rule file ${filePath} ignored: missing or invalid frontmatter (requires a markdown body and, when present, a valid \`path\` field)`);
|
|
335
|
+
return void 0;
|
|
336
|
+
}
|
|
337
|
+
let matcher = null;
|
|
338
|
+
if (rule.globs.length > 0) {
|
|
339
|
+
const compiled = compileMatcher(rule.globs);
|
|
340
|
+
if (!compiled.valid) {
|
|
341
|
+
this.warnOnce(`rule file ${filePath} ignored: invalid glob pattern: ${errorMessage(compiled.error)}`);
|
|
342
|
+
return void 0;
|
|
343
|
+
}
|
|
344
|
+
matcher = compiled.match;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
name: rule.name,
|
|
348
|
+
rank,
|
|
349
|
+
source,
|
|
350
|
+
globs: rule.globs,
|
|
351
|
+
matcher,
|
|
352
|
+
content: rule.content
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/** Load and parse `# Path:` sections from one instruction file. */
|
|
356
|
+
async loadClaudeSections(filePath, projectRoot, signal) {
|
|
357
|
+
const raw = await this.loadCachedText(filePath, signal);
|
|
358
|
+
if (raw === void 0) return void 0;
|
|
359
|
+
const display = posixRelative(projectRoot, filePath) ?? filePath;
|
|
360
|
+
return parseClaudePathSections(raw).flatMap((section, index) => {
|
|
361
|
+
const compiled = compileMatcher(section.globs);
|
|
362
|
+
if (!compiled.valid) {
|
|
363
|
+
this.warnOnce(`rule section ${display}#${index} ignored: invalid glob pattern: ${errorMessage(compiled.error)}`);
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
return [{
|
|
367
|
+
name: `${display}:${index}`,
|
|
368
|
+
rank: CLAUDE_SECTION_RANK,
|
|
369
|
+
source: display,
|
|
370
|
+
globs: section.globs,
|
|
371
|
+
matcher: compiled.match,
|
|
372
|
+
content: section.content
|
|
373
|
+
}];
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
/** Read a rule source through the version cache, re-parsing only on change. */
|
|
377
|
+
async loadCachedText(filePath, signal) {
|
|
378
|
+
const probe = await statRuleFile(filePath, this.fileSystem, signal);
|
|
379
|
+
if (probe.kind === "absent") {
|
|
380
|
+
this.fileCache.delete(filePath);
|
|
381
|
+
return void 0;
|
|
382
|
+
}
|
|
383
|
+
const cached = this.fileCache.get(filePath);
|
|
384
|
+
if (probe.kind === "unavailable") return cached?.raw;
|
|
385
|
+
if (cached !== void 0 && cached.version === probe.version) return cached.raw;
|
|
386
|
+
if (probe.size !== void 0 && probe.size > this.resolved.maxSourceBytes) {
|
|
387
|
+
this.warnOnce(`rule source ${filePath} ignored: exceeds maxSourceBytes (${probe.size} > ${this.resolved.maxSourceBytes})`);
|
|
388
|
+
return void 0;
|
|
389
|
+
}
|
|
390
|
+
const raw = await readRuleText(filePath, this.fileSystem, signal, this.resolved.maxSourceBytes);
|
|
391
|
+
if (raw === void 0) return void 0;
|
|
392
|
+
this.fileCache.set(filePath, { version: probe.version, raw });
|
|
393
|
+
return raw;
|
|
394
|
+
}
|
|
395
|
+
warnOnce(message) {
|
|
396
|
+
if (this.warned.has(message)) return;
|
|
397
|
+
this.warned.add(message);
|
|
398
|
+
this.ctx.logger.warn(`dsh-rules: ${message}`);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** Build the user-role message carrying one rules snapshot. */
|
|
403
|
+
function rulesMessage(text) {
|
|
404
|
+
return createUserMessage({
|
|
405
|
+
content: [{ type: "text", text }],
|
|
406
|
+
source: { kind: "plugin", plugin: name, form: "rules" }
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Whether one message is our rules snapshot with the same text as another. */
|
|
411
|
+
function sameRulesMessage(message, desired) {
|
|
412
|
+
if (message.source?.kind !== "plugin" || message.source?.plugin !== name) return false;
|
|
413
|
+
const [block] = message.content;
|
|
414
|
+
const text = message.content.length === 1 && block?.type === "text" ? block.text : void 0;
|
|
415
|
+
const [desiredBlock] = desired.content;
|
|
416
|
+
const desiredText = desired.content.length === 1 && desiredBlock?.type === "text" ? desiredBlock.text : void 0;
|
|
417
|
+
return text !== void 0 && text === desiredText;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Directories from the project root down to (and including) cwd. */
|
|
421
|
+
function ancestorChain(projectRoot, cwd) {
|
|
422
|
+
const dirs = [];
|
|
423
|
+
let current = resolve(cwd);
|
|
424
|
+
const root = resolve(projectRoot);
|
|
425
|
+
while (true) {
|
|
426
|
+
dirs.push(current);
|
|
427
|
+
if (current === root) break;
|
|
428
|
+
const parent = dirname(current);
|
|
429
|
+
if (parent === current) break;
|
|
430
|
+
current = parent;
|
|
431
|
+
}
|
|
432
|
+
return dirs;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Reverse of escapeAttr for recovered snapshot text. */
|
|
436
|
+
function unescapeAttr(value) {
|
|
437
|
+
return value.replaceAll(""", "\"").replaceAll("<", "<").replaceAll("&", "&");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function errorMessage(error) {
|
|
441
|
+
try {
|
|
442
|
+
return String(error);
|
|
443
|
+
} catch {
|
|
444
|
+
return "[unrenderable thrown value]";
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export { Config, apply, name };
|