@syv-ai/rulecast 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 +145 -0
- package/dist/chunk-K37BHCEK.js +10 -0
- package/dist/chunk-POEWJTZ2.js +3780 -0
- package/dist/clack-YPV4ZOQK.js +25 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1664 -0
- package/dist/index.d.ts +840 -0
- package/dist/index.js +327 -0
- package/package.json +69 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ADAPTERS,
|
|
3
|
+
CONFIG_FILE,
|
|
4
|
+
LlmUnavailableError,
|
|
5
|
+
MANIFEST_FILE,
|
|
6
|
+
MODEL_ALIASES,
|
|
7
|
+
VERSION,
|
|
8
|
+
adapterByName,
|
|
9
|
+
builtinDetectors,
|
|
10
|
+
cacheHome,
|
|
11
|
+
cachedRepos,
|
|
12
|
+
checkDetectors,
|
|
13
|
+
checkableKinds,
|
|
14
|
+
claudeCodeAdapter,
|
|
15
|
+
compile,
|
|
16
|
+
compileManifest,
|
|
17
|
+
createRegistry,
|
|
18
|
+
defaultDetectorSettings,
|
|
19
|
+
emptyDelivery,
|
|
20
|
+
fetchingRepos,
|
|
21
|
+
fixedRepo,
|
|
22
|
+
memoryCache,
|
|
23
|
+
perRule,
|
|
24
|
+
projectStateDir,
|
|
25
|
+
providerByName,
|
|
26
|
+
readSourceFile,
|
|
27
|
+
renderAgentText,
|
|
28
|
+
resolveModel,
|
|
29
|
+
runPipeline
|
|
30
|
+
} from "./chunk-POEWJTZ2.js";
|
|
31
|
+
|
|
32
|
+
// src/testing/adapter-contract.ts
|
|
33
|
+
import assert from "assert/strict";
|
|
34
|
+
var FORMAT_OPTIONS = { maxMatchesPerRule: 3 };
|
|
35
|
+
var NONSENSE = [null, void 0, 0, "", "not json", [], {}, { hook_event_name: "NoSuchEvent" }];
|
|
36
|
+
function adapterContract(adapter, fixture) {
|
|
37
|
+
const cases = [
|
|
38
|
+
{
|
|
39
|
+
name: "describes itself",
|
|
40
|
+
async run() {
|
|
41
|
+
assert.ok(adapter.name.length > 0, "name must not be empty");
|
|
42
|
+
assert.ok(adapter.label.length > 0, "label must not be empty");
|
|
43
|
+
assert.ok(
|
|
44
|
+
adapter.maxContextChars === null || adapter.maxContextChars > 0,
|
|
45
|
+
"maxContextChars must be null or positive"
|
|
46
|
+
);
|
|
47
|
+
assert.ok(Number.isInteger(adapter.restoredFiles) && adapter.restoredFiles >= 0, "restoredFiles must be >= 0");
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "returns null for input it does not handle, and never throws",
|
|
52
|
+
async run() {
|
|
53
|
+
for (const input of NONSENSE) {
|
|
54
|
+
const parsed = adapter.parse(input);
|
|
55
|
+
assert.equal(parsed, null, `parse(${JSON.stringify(input)}) must be null`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "parses each recorded payload to its event",
|
|
61
|
+
async run() {
|
|
62
|
+
assert.ok(fixture.payloads.length > 0, "an adapter fixture needs at least one recorded payload");
|
|
63
|
+
for (const { name, input, expected } of fixture.payloads) {
|
|
64
|
+
assert.deepEqual(adapter.parse(input), expected, `payload ${name}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "parsing is pure: the same payload parses the same twice",
|
|
70
|
+
async run() {
|
|
71
|
+
for (const { name, input } of fixture.payloads) {
|
|
72
|
+
assert.deepEqual(adapter.parse(input), adapter.parse(input), `payload ${name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "every parsed event is well formed",
|
|
78
|
+
async run() {
|
|
79
|
+
for (const { name, input } of fixture.payloads) {
|
|
80
|
+
const event = adapter.parse(input)?.event;
|
|
81
|
+
if (!event) continue;
|
|
82
|
+
assert.ok(event.cwd.length > 0, `${name}: event.cwd must not be empty`);
|
|
83
|
+
for (const file of event.files) assert.equal(typeof file, "string", `${name}: files must be strings`);
|
|
84
|
+
if (event.kind === "prompt" || event.kind === "reset") {
|
|
85
|
+
assert.deepEqual(event.files, [], `${name}: ${event.kind} carries no files`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "formats each delivery deterministically, with an exit code of 0 or 1",
|
|
92
|
+
async run() {
|
|
93
|
+
for (const { name, delivery, event } of fixture.deliveries) {
|
|
94
|
+
const first = adapter.format(delivery, event, FORMAT_OPTIONS);
|
|
95
|
+
const second = adapter.format(delivery, event, FORMAT_OPTIONS);
|
|
96
|
+
assert.deepEqual(first, second, `${name}: formatting must be deterministic`);
|
|
97
|
+
assert.equal(typeof first.stdout, "string", `${name}: stdout must be a string`);
|
|
98
|
+
assert.ok([0, 1].includes(first.exitCode), `${name}: exit code must be 0 or 1, got ${first.exitCode}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "an empty delivery exits 0",
|
|
104
|
+
async run() {
|
|
105
|
+
const event = fixture.deliveries[0]?.event ?? { kind: "edit", files: [], cwd: "/project" };
|
|
106
|
+
const result = adapter.format(emptyDelivery(), event, FORMAT_OPTIONS);
|
|
107
|
+
assert.equal(result.exitCode, 0, "nothing to report must not be a failure");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
];
|
|
111
|
+
const install = adapter.install;
|
|
112
|
+
if (install) {
|
|
113
|
+
cases.push(
|
|
114
|
+
{
|
|
115
|
+
name: "install declares markers, scopes and a command",
|
|
116
|
+
async run() {
|
|
117
|
+
assert.ok(install.markers.length > 0, "markers must not be empty");
|
|
118
|
+
assert.ok(install.scopes.length > 0, "scopes must not be empty");
|
|
119
|
+
for (const { file } of install.scopes) assert.ok(file.length > 0, "a scope needs a settings file");
|
|
120
|
+
assert.notEqual(install.command(true), install.command(false), "a local install runs a different command");
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "merge is idempotent and remove undoes it",
|
|
125
|
+
async run() {
|
|
126
|
+
const command = install.command(false);
|
|
127
|
+
for (const settings of fixture.settings ?? [{}]) {
|
|
128
|
+
const original = structuredClone(settings);
|
|
129
|
+
const once = install.merge(structuredClone(original), command, 5e3);
|
|
130
|
+
assert.ok(once.added.length > 0, "merging into fresh settings must add hooks");
|
|
131
|
+
const twice = install.merge(structuredClone(once.settings), command, 5e3);
|
|
132
|
+
assert.deepEqual(twice.settings, once.settings, "merging twice must change nothing");
|
|
133
|
+
assert.deepEqual(twice.added, [], "merging twice must add nothing");
|
|
134
|
+
const removed = install.remove(structuredClone(once.settings));
|
|
135
|
+
assert.deepEqual(removed.settings, original, "remove must return the settings merge was given");
|
|
136
|
+
assert.deepEqual([...removed.removed].sort(), [...once.added].sort(), "remove must name what merge added");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return cases;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/testing/detector-contract.ts
|
|
146
|
+
import assert2 from "assert/strict";
|
|
147
|
+
|
|
148
|
+
// src/testing/contract.ts
|
|
149
|
+
import { mkdir, mkdtemp, writeFile } from "fs/promises";
|
|
150
|
+
import { tmpdir } from "os";
|
|
151
|
+
import path from "path";
|
|
152
|
+
async function contractProject(files) {
|
|
153
|
+
const root = await mkdtemp(path.join(tmpdir(), "rulecast-contract-"));
|
|
154
|
+
for (const [file, content] of Object.entries(files)) {
|
|
155
|
+
const full = path.join(root, file);
|
|
156
|
+
await mkdir(path.dirname(full), { recursive: true });
|
|
157
|
+
await writeFile(full, content);
|
|
158
|
+
}
|
|
159
|
+
return root;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/testing/detector-contract.ts
|
|
163
|
+
var EVENTS = ["edit", "verify"];
|
|
164
|
+
async function configOf(detector, value, where) {
|
|
165
|
+
const parsed = await detector.schema.safeParseAsync(value);
|
|
166
|
+
assert2.ok(parsed.success, `${where} config must pass the detector's own schema`);
|
|
167
|
+
return parsed.data;
|
|
168
|
+
}
|
|
169
|
+
function run(detector, rules, cwd, signal) {
|
|
170
|
+
return detector.run({
|
|
171
|
+
event: "edit",
|
|
172
|
+
// biome-ignore lint/suspicious/noExplicitAny: the registry erases each detector's config type.
|
|
173
|
+
rules,
|
|
174
|
+
changes: /* @__PURE__ */ new Map(),
|
|
175
|
+
read: (file) => readSourceFile(cwd, file),
|
|
176
|
+
cache: memoryCache(),
|
|
177
|
+
settings: defaultDetectorSettings(),
|
|
178
|
+
cwd,
|
|
179
|
+
signal
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function assertResultShape(result, ids) {
|
|
183
|
+
assert2.ok(Array.isArray(result.findings), "result.findings must be an array");
|
|
184
|
+
assert2.ok(Array.isArray(result.errors), "result.errors must be an array");
|
|
185
|
+
for (const finding of result.findings) {
|
|
186
|
+
assert2.ok(ids.includes(finding.rule), `finding names rule "${finding.rule}", which was not in the run`);
|
|
187
|
+
const { match } = finding;
|
|
188
|
+
assert2.equal(typeof match.file, "string", "match.file must be a string");
|
|
189
|
+
for (const key of ["line", "endLine", "column"]) {
|
|
190
|
+
assert2.ok(Number.isInteger(match[key]) && match[key] >= 1, `match.${key} must be a 1-based integer`);
|
|
191
|
+
}
|
|
192
|
+
assert2.ok(match.endLine >= match.line, "match.endLine must not precede match.line");
|
|
193
|
+
assert2.equal(typeof match.text, "string", "match.text must be a string");
|
|
194
|
+
}
|
|
195
|
+
for (const error of result.errors) {
|
|
196
|
+
assert2.ok(error.rule === null || ids.includes(error.rule), `error names rule "${error.rule}", not in the run`);
|
|
197
|
+
assert2.equal(typeof error.message, "string", "error.message must be a string");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function detectorContract(detector, fixture) {
|
|
201
|
+
const project = async () => {
|
|
202
|
+
const root = await contractProject(fixture.files);
|
|
203
|
+
await fixture.prepare?.(root);
|
|
204
|
+
return root;
|
|
205
|
+
};
|
|
206
|
+
const rule = (id, config, files) => ({ id, config, files, context: [] });
|
|
207
|
+
const open = () => new AbortController().signal;
|
|
208
|
+
const cases = [
|
|
209
|
+
{
|
|
210
|
+
name: "declares a kind, captures and events",
|
|
211
|
+
async run() {
|
|
212
|
+
assert2.ok(detector.kind.length > 0, "kind must not be empty");
|
|
213
|
+
const config = await configOf(detector, fixture.config, "the fixture's");
|
|
214
|
+
const captures = detector.captures(config);
|
|
215
|
+
assert2.ok(Array.isArray(captures), "captures(config) must return an array");
|
|
216
|
+
for (const name of captures) assert2.equal(typeof name, "string", "a capture name must be a string");
|
|
217
|
+
const events = detector.events(config);
|
|
218
|
+
assert2.ok(events.length > 0, "events(config) must name at least one event");
|
|
219
|
+
for (const event of events) assert2.ok(EVENTS.includes(event), `unknown event "${event}"`);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "no rules is an empty result",
|
|
224
|
+
async run() {
|
|
225
|
+
const result = await run(detector, [], await project(), open());
|
|
226
|
+
assert2.deepEqual(result, { findings: [], errors: [] });
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "every match carries exactly the declared captures, as strings",
|
|
231
|
+
async run() {
|
|
232
|
+
const config = await configOf(detector, fixture.config, "the fixture's");
|
|
233
|
+
const declared = [...detector.captures(config)].sort();
|
|
234
|
+
const result = await run(detector, [rule("a", config, fixture.matching)], await project(), open());
|
|
235
|
+
assert2.deepEqual(result.errors, [], "the fixture's matching config must not error");
|
|
236
|
+
assert2.ok(result.findings.length > 0, "the fixture's matching config must produce at least one finding");
|
|
237
|
+
assertResultShape(result, ["a"]);
|
|
238
|
+
for (const finding of result.findings) {
|
|
239
|
+
const present = Object.keys(finding.match.captures).sort();
|
|
240
|
+
assert2.deepEqual(present, declared, "captures must be exactly the declared names");
|
|
241
|
+
for (const value of Object.values(finding.match.captures)) {
|
|
242
|
+
assert2.equal(typeof value, "string", "every capture must be a string");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: "batching attributes the same work to every rule that asked for it",
|
|
249
|
+
async run() {
|
|
250
|
+
const config = await configOf(detector, fixture.config, "the fixture's");
|
|
251
|
+
const rules = [rule("a", config, fixture.matching), rule("b", config, fixture.matching)];
|
|
252
|
+
const result = await run(detector, rules, await project(), open());
|
|
253
|
+
assert2.deepEqual(result.errors, [], "two identical rules must not error");
|
|
254
|
+
assertResultShape(result, ["a", "b"]);
|
|
255
|
+
const forRule = (id) => result.findings.filter((finding) => finding.rule === id);
|
|
256
|
+
assert2.ok(forRule("a").length > 0, "rule a must get findings");
|
|
257
|
+
assert2.equal(forRule("b").length, forRule("a").length, "identical rules must get the same findings");
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "an aborted run produces nothing",
|
|
262
|
+
async run() {
|
|
263
|
+
const config = await configOf(detector, fixture.config, "the fixture's");
|
|
264
|
+
const controller = new AbortController();
|
|
265
|
+
controller.abort();
|
|
266
|
+
const rules = [rule("a", config, fixture.matching)];
|
|
267
|
+
const result = await run(detector, rules, await project(), controller.signal).catch(() => null);
|
|
268
|
+
if (result !== null) assert2.deepEqual(result.findings, [], "an aborted run must not report findings");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
];
|
|
272
|
+
if (fixture.failing) {
|
|
273
|
+
const failing = fixture.failing;
|
|
274
|
+
cases.push({
|
|
275
|
+
name: "a rule that fails does not take the others with it",
|
|
276
|
+
async run() {
|
|
277
|
+
const good = await configOf(detector, fixture.config, "the fixture's");
|
|
278
|
+
const bad = await configOf(detector, failing.config, "the fixture's failing");
|
|
279
|
+
const rules = [rule("bad", bad, failing.matching ?? fixture.matching), rule("good", good, fixture.matching)];
|
|
280
|
+
const result = await run(detector, rules, await project(), open());
|
|
281
|
+
assertResultShape(result, ["bad", "good"]);
|
|
282
|
+
assert2.ok(
|
|
283
|
+
result.errors.some((error) => error.rule === "bad"),
|
|
284
|
+
"the failing rule must produce an error naming it"
|
|
285
|
+
);
|
|
286
|
+
assert2.ok(
|
|
287
|
+
!result.errors.some((error) => error.rule === null),
|
|
288
|
+
"one rule's failure must not be reported as a whole-run error"
|
|
289
|
+
);
|
|
290
|
+
assert2.ok(
|
|
291
|
+
result.findings.some((finding) => finding.rule === "good"),
|
|
292
|
+
"the healthy rule must still report its findings"
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return cases;
|
|
298
|
+
}
|
|
299
|
+
export {
|
|
300
|
+
ADAPTERS,
|
|
301
|
+
CONFIG_FILE,
|
|
302
|
+
LlmUnavailableError,
|
|
303
|
+
MANIFEST_FILE,
|
|
304
|
+
MODEL_ALIASES,
|
|
305
|
+
VERSION,
|
|
306
|
+
adapterByName,
|
|
307
|
+
adapterContract,
|
|
308
|
+
builtinDetectors,
|
|
309
|
+
cacheHome,
|
|
310
|
+
cachedRepos,
|
|
311
|
+
checkDetectors,
|
|
312
|
+
checkableKinds,
|
|
313
|
+
claudeCodeAdapter,
|
|
314
|
+
compile,
|
|
315
|
+
compileManifest,
|
|
316
|
+
createRegistry,
|
|
317
|
+
detectorContract,
|
|
318
|
+
emptyDelivery,
|
|
319
|
+
fetchingRepos,
|
|
320
|
+
fixedRepo,
|
|
321
|
+
perRule,
|
|
322
|
+
projectStateDir,
|
|
323
|
+
providerByName,
|
|
324
|
+
renderAgentText,
|
|
325
|
+
resolveModel,
|
|
326
|
+
runPipeline
|
|
327
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@syv-ai/rulecast",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deliver project conventions to coding agents at the moment they matter.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"coding-agent",
|
|
8
|
+
"coding-agents",
|
|
9
|
+
"conventions",
|
|
10
|
+
"lint",
|
|
11
|
+
"hooks",
|
|
12
|
+
"ast-grep",
|
|
13
|
+
"agents"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/syv-ai/rulecast#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/syv-ai/rulecast.git",
|
|
20
|
+
"directory": "packages/rulecast"
|
|
21
|
+
},
|
|
22
|
+
"bugs": "https://github.com/syv-ai/rulecast/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20.12"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"rulecast": "./dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@ast-grep/lang-python": "^0.0.6",
|
|
44
|
+
"@ast-grep/napi": "^0.45.3",
|
|
45
|
+
"@clack/prompts": "^1.8.1",
|
|
46
|
+
"diff": "^8.0.4",
|
|
47
|
+
"yaml": "^2.9.1",
|
|
48
|
+
"zod": "^3.25.76"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^20.19.43",
|
|
52
|
+
"oxlint": "^1.83.0",
|
|
53
|
+
"tsup": "^8.5.1",
|
|
54
|
+
"tsx": "^4.23.13",
|
|
55
|
+
"typescript": "^5.9.3",
|
|
56
|
+
"vitest": "^3.2.7"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"binary": "tsx scripts/build-binary.ts",
|
|
60
|
+
"build": "tsup src/index.ts src/cli.ts --format esm --dts --clean",
|
|
61
|
+
"manifest": "tsx scripts/generate-manifest.ts",
|
|
62
|
+
"perf": "tsx scripts/perf.ts",
|
|
63
|
+
"readme": "tsx scripts/readme.ts",
|
|
64
|
+
"release-notes": "tsx scripts/release-notes.ts",
|
|
65
|
+
"sync-version": "tsx scripts/sync-version.ts",
|
|
66
|
+
"test": "vitest run",
|
|
67
|
+
"typecheck": "tsc --noEmit"
|
|
68
|
+
}
|
|
69
|
+
}
|