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/rules.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rule-model logic for the dsh-rules plugin.
|
|
3
|
+
*
|
|
4
|
+
* This module owns everything that can be tested without a harness runtime:
|
|
5
|
+
* frontmatter parsing, `# Path:` section parsing, glob compilation and
|
|
6
|
+
* matching, precedence merging, and budget-bounded deterministic rendering.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-rules/rules
|
|
9
|
+
*/
|
|
10
|
+
import picomatch from "picomatch";
|
|
11
|
+
import { parse as parseYaml } from "yaml";
|
|
12
|
+
|
|
13
|
+
/** Frame that marks the model-facing rules snapshot. */
|
|
14
|
+
const RULES_OPEN = "<rules>";
|
|
15
|
+
const RULES_CLOSE = "</rules>";
|
|
16
|
+
/** Intro asserting that one rules snapshot supersedes earlier ones. */
|
|
17
|
+
const RULES_INTRO =
|
|
18
|
+
"Active rules for files read or edited in this session";
|
|
19
|
+
/** Text used to clear a previously injected rules snapshot. */
|
|
20
|
+
export const EMPTY_RULES_TEXT = [
|
|
21
|
+
RULES_OPEN,
|
|
22
|
+
"This rules snapshot supersedes earlier rules snapshots. No rules are currently active.",
|
|
23
|
+
RULES_CLOSE
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
/** Headings that open a Claude-Code-style scoped rule section. */
|
|
27
|
+
const PATH_HEADING = /^#+\s*path:\s*(.+)$/i;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Return whether a candidate rule name is acceptable: non-empty and free of
|
|
31
|
+
* path separators, so it can never be confused with a file path.
|
|
32
|
+
* @param name - candidate rule name.
|
|
33
|
+
* @returns whether the name may be used to identify a rule.
|
|
34
|
+
*/
|
|
35
|
+
export function isValidRuleName(name) {
|
|
36
|
+
return typeof name === "string" && name.length > 0 && !/[\\/]/.test(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse one flat rule file (frontmatter + markdown body).
|
|
41
|
+
*
|
|
42
|
+
* Frontmatter fields:
|
|
43
|
+
* - `path`: string or list of glob strings, relative to the project root,
|
|
44
|
+
* using `/` separators. A `!` prefix marks an exclusion pattern. Absent or
|
|
45
|
+
* empty means the rule is always active for the workspace.
|
|
46
|
+
* - `name`: optional display/identity override; defaults to the file's
|
|
47
|
+
* basename without the `.md` suffix (supplied by the caller).
|
|
48
|
+
*
|
|
49
|
+
* @param raw - exact UTF-8 file text.
|
|
50
|
+
* @param fallbackName - name used when frontmatter omits `name`.
|
|
51
|
+
* @returns the parsed rule, or `undefined` when the file is not a valid rule.
|
|
52
|
+
*/
|
|
53
|
+
export function parseRuleFile(raw, fallbackName) {
|
|
54
|
+
const parsed = parseFrontmatter(raw);
|
|
55
|
+
if (parsed === void 0) return void 0;
|
|
56
|
+
const { data, body } = parsed;
|
|
57
|
+
const name = optionalString(data, "name") ?? fallbackName;
|
|
58
|
+
if (!isValidRuleName(name)) return void 0;
|
|
59
|
+
const globs = normalizeGlobField(data.path);
|
|
60
|
+
if (globs === void 0) return void 0;
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
globs,
|
|
64
|
+
content: body.trim()
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse Claude-Code-style `# Path: <glob…>` scoped sections out of a markdown
|
|
70
|
+
* instruction file (CLAUDE.md / AGENTS.md). Content before the first heading
|
|
71
|
+
* is the file's preamble and is intentionally ignored: the baseline
|
|
72
|
+
* instructions pipeline owns it, and this plugin only handles scoped rules.
|
|
73
|
+
* @param raw - exact UTF-8 file text.
|
|
74
|
+
* @returns parsed sections, each with normalized globs and trimmed body.
|
|
75
|
+
*/
|
|
76
|
+
export function parseClaudePathSections(raw) {
|
|
77
|
+
const sections = [];
|
|
78
|
+
let current = void 0;
|
|
79
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
80
|
+
const match = PATH_HEADING.exec(line);
|
|
81
|
+
if (match !== null) {
|
|
82
|
+
if (current !== void 0) sections.push(current);
|
|
83
|
+
const globs = splitGlobList(match[1]);
|
|
84
|
+
if (globs.length === 0) {
|
|
85
|
+
current = void 0;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
current = { globs, lines: [] };
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (current !== void 0) current.lines.push(line);
|
|
92
|
+
}
|
|
93
|
+
if (current !== void 0) sections.push(current);
|
|
94
|
+
return sections.map((section) => ({
|
|
95
|
+
globs: section.globs,
|
|
96
|
+
content: section.lines.join("\n").trim()
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compile a rule's glob patterns into a matcher. Patterns are matched against
|
|
102
|
+
* project-root-relative POSIX paths. `!`-prefixed patterns exclude; an
|
|
103
|
+
* inclusion list of only negations matches everything except the exclusions.
|
|
104
|
+
* @param patterns - raw glob patterns.
|
|
105
|
+
* @returns a compiled matcher, or an invalid result when a pattern is malformed.
|
|
106
|
+
*/
|
|
107
|
+
export function compileMatcher(patterns) {
|
|
108
|
+
const normalized = patterns.map((pattern) => pattern.trim()).filter((pattern) => pattern.length > 0);
|
|
109
|
+
const include = normalized.filter((pattern) => !pattern.startsWith("!"));
|
|
110
|
+
const exclude = normalized.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
|
|
111
|
+
let includeMatcher = null;
|
|
112
|
+
let excludeMatcher = null;
|
|
113
|
+
try {
|
|
114
|
+
if (include.length > 0) includeMatcher = picomatch(include, { dot: true });
|
|
115
|
+
if (exclude.length > 0) excludeMatcher = picomatch(exclude, { dot: true });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return { valid: false, error };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
valid: true,
|
|
121
|
+
/**
|
|
122
|
+
* Test one project-root-relative POSIX path against the rule.
|
|
123
|
+
* @param path - relative path with `/` separators.
|
|
124
|
+
* @returns whether the rule applies to the path.
|
|
125
|
+
*/
|
|
126
|
+
match(path) {
|
|
127
|
+
if (excludeMatcher !== null && excludeMatcher(path)) return false;
|
|
128
|
+
if (includeMatcher === null) return true;
|
|
129
|
+
return includeMatcher(path);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Merge rules from ordered source groups into one deterministic catalog.
|
|
136
|
+
* Groups carry a `rank` (lower wins) and a sorted-by-name `rules` list.
|
|
137
|
+
* Duplicate names keep the lowest-rank entry; ties keep the first in group
|
|
138
|
+
* order. The result is sorted by (rank, name).
|
|
139
|
+
* @param groups - source groups, each `{ rank, rules }`.
|
|
140
|
+
* @returns the merged catalog plus the dropped duplicates.
|
|
141
|
+
*/
|
|
142
|
+
export function mergeRuleSources(groups) {
|
|
143
|
+
const byName = /* @__PURE__ */ new Map();
|
|
144
|
+
const dropped = [];
|
|
145
|
+
for (const group of groups) {
|
|
146
|
+
for (const rule of [...group.rules].sort(compareByName)) {
|
|
147
|
+
const previous = byName.get(rule.name);
|
|
148
|
+
if (previous === void 0) {
|
|
149
|
+
byName.set(rule.name, rule);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (rule.rank < previous.rank) {
|
|
153
|
+
byName.set(rule.name, rule);
|
|
154
|
+
dropped.push(previous);
|
|
155
|
+
} else dropped.push(rule);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
rules: [...byName.values()].sort(compareByRankThenName),
|
|
160
|
+
dropped
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Render the active rule set into one deterministic, budget-bounded snapshot.
|
|
166
|
+
* Rules are ordered by (rank, name); when the framed text exceeds `maxBytes`,
|
|
167
|
+
* lowest-priority rules are omitted first, then the last remaining rule's
|
|
168
|
+
* content is truncated. Rule bodies are escaped so they cannot close the
|
|
169
|
+
* framing tags.
|
|
170
|
+
* @param active - active rules, each `{ name, source, content, rank }`.
|
|
171
|
+
* @param options - render budget and the matched-file list for the intro.
|
|
172
|
+
* @returns the rendered snapshot text and budget diagnostics.
|
|
173
|
+
*/
|
|
174
|
+
export function renderRules(active, options) {
|
|
175
|
+
const maxBytes = options.maxBytes;
|
|
176
|
+
const sorted = [...active].sort(compareByRankThenName);
|
|
177
|
+
const full = buildFrame(sorted, options.matchedFiles ?? []);
|
|
178
|
+
if (byteLength(full) <= maxBytes) return { text: full, omitted: [], truncated: [] };
|
|
179
|
+
for (let keep = sorted.length - 1; keep >= 1; keep -= 1) {
|
|
180
|
+
const kept = sorted.slice(0, keep);
|
|
181
|
+
const omitted = sorted.slice(keep).map(ruleIdentity);
|
|
182
|
+
const candidate = buildFrame(kept, options.matchedFiles ?? []);
|
|
183
|
+
if (byteLength(candidate) <= maxBytes) return { text: candidate, omitted, truncated: [] };
|
|
184
|
+
}
|
|
185
|
+
const [first] = sorted;
|
|
186
|
+
if (first === void 0) return { text: truncateUtf8(full, maxBytes), omitted: [], truncated: [] };
|
|
187
|
+
const truncatedRule = truncateRuleToFit(first, maxBytes);
|
|
188
|
+
const framed = buildFrame([truncatedRule.rule], options.matchedFiles ?? []);
|
|
189
|
+
return {
|
|
190
|
+
text: byteLength(framed) <= maxBytes ? framed : truncateUtf8(framed, maxBytes),
|
|
191
|
+
omitted: sorted.slice(1).map(ruleIdentity),
|
|
192
|
+
truncated: [truncatedRule.report]
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Split a `# Path:` heading value into normalized glob patterns. */
|
|
197
|
+
function splitGlobList(value) {
|
|
198
|
+
return value.split(/[\s,]+/).map((item) => item.trim()).filter((item) => item.length > 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Normalize a frontmatter `path` field.
|
|
203
|
+
* @param value - raw field value.
|
|
204
|
+
* @returns normalized glob list, or `undefined` when the field is malformed.
|
|
205
|
+
*/
|
|
206
|
+
export function normalizeGlobField(value) {
|
|
207
|
+
if (value === void 0) return [];
|
|
208
|
+
if (typeof value === "string") {
|
|
209
|
+
const trimmed = value.trim();
|
|
210
|
+
return trimmed.length === 0 ? [] : [trimmed];
|
|
211
|
+
}
|
|
212
|
+
if (!Array.isArray(value)) return void 0;
|
|
213
|
+
const globs = [];
|
|
214
|
+
for (const item of value) {
|
|
215
|
+
if (typeof item !== "string") return void 0;
|
|
216
|
+
const trimmed = item.trim();
|
|
217
|
+
if (trimmed.length > 0) globs.push(trimmed);
|
|
218
|
+
}
|
|
219
|
+
return globs;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Parse a `---`-delimited YAML frontmatter block. */
|
|
223
|
+
function parseFrontmatter(raw) {
|
|
224
|
+
const firstLineEnd = raw.indexOf("\n");
|
|
225
|
+
if (firstLineEnd < 0) return void 0;
|
|
226
|
+
if (raw.slice(0, firstLineEnd).replace(/\r$/, "") !== "---") return void 0;
|
|
227
|
+
const start = firstLineEnd + 1;
|
|
228
|
+
const closing = findClosingFrontmatter(raw, start);
|
|
229
|
+
if (closing === void 0) return void 0;
|
|
230
|
+
let data;
|
|
231
|
+
try {
|
|
232
|
+
data = parseYaml(raw.slice(start, closing.start)) ?? {};
|
|
233
|
+
} catch {
|
|
234
|
+
return void 0;
|
|
235
|
+
}
|
|
236
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) return void 0;
|
|
237
|
+
return { data, body: raw.slice(closing.bodyStart) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function findClosingFrontmatter(raw, start) {
|
|
241
|
+
let lineStart = start;
|
|
242
|
+
while (lineStart <= raw.length) {
|
|
243
|
+
const nextNewline = raw.indexOf("\n", lineStart);
|
|
244
|
+
const lineEnd = nextNewline < 0 ? raw.length : nextNewline;
|
|
245
|
+
if (raw.slice(lineStart, lineEnd).replace(/\r$/, "") === "---") {
|
|
246
|
+
return {
|
|
247
|
+
start: lineStart,
|
|
248
|
+
bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (nextNewline < 0) return void 0;
|
|
252
|
+
lineStart = nextNewline + 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function optionalString(data, key) {
|
|
257
|
+
const value = data[key];
|
|
258
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function buildFrame(rules, matchedFiles) {
|
|
262
|
+
const parts = [
|
|
263
|
+
RULES_OPEN,
|
|
264
|
+
matchedFiles.length > 0
|
|
265
|
+
? `${RULES_INTRO} (matched files: ${matchedFiles.map(escapeAttr).join(", ")})`
|
|
266
|
+
: `${RULES_INTRO} (scoped rules activate as you read or edit matching files)`,
|
|
267
|
+
""
|
|
268
|
+
];
|
|
269
|
+
for (const rule of rules) {
|
|
270
|
+
parts.push(
|
|
271
|
+
`<rule name="${escapeAttr(rule.name)}" source="${escapeAttr(rule.source)}">`,
|
|
272
|
+
escapeRuleText(rule.content),
|
|
273
|
+
"</rule>"
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
parts.push(RULES_CLOSE);
|
|
277
|
+
return parts.join("\n");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Escape rule body text so it cannot close the framing tags. */
|
|
281
|
+
export function escapeRuleText(value) {
|
|
282
|
+
return value.replaceAll("&", "&").replaceAll("<", "<");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Escape an attribute value embedded in framing markup. */
|
|
286
|
+
export function escapeAttr(value) {
|
|
287
|
+
return value.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ruleIdentity(rule) {
|
|
291
|
+
return { name: rule.name, source: rule.source };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function compareByName(left, right) {
|
|
295
|
+
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function compareByRankThenName(left, right) {
|
|
299
|
+
return left.rank - right.rank || compareByName(left, right);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function byteLength(value) {
|
|
303
|
+
return Buffer.byteLength(value, "utf8");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function truncateUtf8(value, maxBytes) {
|
|
307
|
+
const bytes = Buffer.from(value, "utf8");
|
|
308
|
+
if (bytes.length <= maxBytes) return value;
|
|
309
|
+
return bytes.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD/g, "");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Binary-search the largest content prefix whose framed rendering fits. */
|
|
313
|
+
function truncateRuleToFit(rule, maxBytes) {
|
|
314
|
+
const originalBytes = byteLength(rule.content);
|
|
315
|
+
let low = 0;
|
|
316
|
+
let high = originalBytes;
|
|
317
|
+
let best = { ...rule, content: "" };
|
|
318
|
+
while (low <= high) {
|
|
319
|
+
const mid = Math.floor((low + high) / 2);
|
|
320
|
+
const candidate = { ...rule, content: truncateUtf8(rule.content, mid) };
|
|
321
|
+
const framed = buildFrame([candidate], []);
|
|
322
|
+
if (byteLength(framed) <= maxBytes) {
|
|
323
|
+
best = candidate;
|
|
324
|
+
low = mid + 1;
|
|
325
|
+
} else high = mid - 1;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
rule: best,
|
|
329
|
+
report: {
|
|
330
|
+
name: rule.name,
|
|
331
|
+
source: rule.source,
|
|
332
|
+
originalBytes,
|
|
333
|
+
includedBytes: byteLength(best.content)
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-rules",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH rules plugin: activate rule prompts / markdown documents by glob-matching the files an agent reads or edits (Claude Code rules.md style).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/types/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./rules": {
|
|
14
|
+
"types": "./lib/types/rules.d.ts",
|
|
15
|
+
"default": "./lib/rules.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib/**/*.js",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"README.md",
|
|
23
|
+
"README.zh.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"dsh": {
|
|
27
|
+
"bundle": {
|
|
28
|
+
"patch": "./cordis.patch.yml"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
33
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
|
|
34
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
35
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
36
|
+
"picomatch": "^4.0.2",
|
|
37
|
+
"yaml": "^2.9.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
41
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6",
|
|
42
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
43
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
44
|
+
"picomatch": "^4.0.2",
|
|
45
|
+
"yaml": "^2.9.0"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "node --test"
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|