rulebookguard 0.5.1 → 0.7.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 +149 -81
- package/dist/{anchors-BFJCm1NU.mjs → anchors-C2j9c5MI.mjs} +1 -1
- package/dist/cli.mjs +99 -36
- package/dist/index.d.mts +43 -8
- package/dist/index.mjs +3 -3
- package/dist/lint-DfibQDW3.mjs +1042 -0
- package/dist/{site-BiiOPVEe.mjs → site-DmY5YVte.mjs} +5 -5
- package/package.json +3 -3
- package/dist/lint-C1yUeLOq.mjs +0 -755
package/dist/lint-C1yUeLOq.mjs
DELETED
|
@@ -1,755 +0,0 @@
|
|
|
1
|
-
import { access, lstat, readFile, readdir, realpath } from "node:fs/promises";
|
|
2
|
-
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
3
|
-
import { YAMLParseError, parse } from "yaml";
|
|
4
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import picomatch from "picomatch";
|
|
6
|
-
import { execFile } from "node:child_process";
|
|
7
|
-
import { promisify } from "node:util";
|
|
8
|
-
// src/format/errors.ts
|
|
9
|
-
function formatIssue(issue) {
|
|
10
|
-
return issue.field === void 0 ? `${issue.file}: ${issue.message}` : `${issue.file}: field "${issue.field}" ${issue.message}`;
|
|
11
|
-
}
|
|
12
|
-
var FormatError = class extends Error {
|
|
13
|
-
issues;
|
|
14
|
-
constructor(issues) {
|
|
15
|
-
super(issues.map(formatIssue).join("\n"));
|
|
16
|
-
this.name = "FormatError";
|
|
17
|
-
this.issues = issues;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
// src/format/read.ts
|
|
21
|
-
async function readSource(path) {
|
|
22
|
-
try {
|
|
23
|
-
return await readFile(path, "utf8");
|
|
24
|
-
} catch (error) {
|
|
25
|
-
throw new FormatError([{
|
|
26
|
-
file: path,
|
|
27
|
-
message: `cannot read file (${error.code ?? "unknown error"})`
|
|
28
|
-
}]);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
// schemas/v1/config.schema.json
|
|
32
|
-
var config_schema_default = {
|
|
33
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
34
|
-
$id: "urn:rulebookguard:schema:v1:config",
|
|
35
|
-
title: "rulebookguard.config.yaml",
|
|
36
|
-
type: "object",
|
|
37
|
-
properties: {
|
|
38
|
-
"schema": { "const": 1 },
|
|
39
|
-
"rulesDir": {
|
|
40
|
-
"type": "string",
|
|
41
|
-
"minLength": 1,
|
|
42
|
-
"default": "docs/rules"
|
|
43
|
-
},
|
|
44
|
-
"testGlobs": {
|
|
45
|
-
"type": "array",
|
|
46
|
-
"items": {
|
|
47
|
-
"type": "string",
|
|
48
|
-
"minLength": 1
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
"contextBudget": {
|
|
52
|
-
"type": "integer",
|
|
53
|
-
"minimum": 1,
|
|
54
|
-
"default": 30
|
|
55
|
-
},
|
|
56
|
-
"language": { "enum": ["en", "es"] }
|
|
57
|
-
},
|
|
58
|
-
required: ["schema", "testGlobs"],
|
|
59
|
-
additionalProperties: false
|
|
60
|
-
};
|
|
61
|
-
// schemas/v1/context.schema.json
|
|
62
|
-
var context_schema_default = {
|
|
63
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
64
|
-
$id: "urn:rulebookguard:schema:v1:context",
|
|
65
|
-
title: "_context.md frontmatter",
|
|
66
|
-
type: "object",
|
|
67
|
-
properties: {
|
|
68
|
-
"context": {
|
|
69
|
-
"type": "string",
|
|
70
|
-
"minLength": 1
|
|
71
|
-
},
|
|
72
|
-
"prefix": {
|
|
73
|
-
"type": "string",
|
|
74
|
-
"pattern": "^[A-Z][A-Z0-9]*$"
|
|
75
|
-
},
|
|
76
|
-
"summary": {
|
|
77
|
-
"type": "string",
|
|
78
|
-
"minLength": 1,
|
|
79
|
-
"pattern": "^[^\\r\\n]*$"
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
required: [
|
|
83
|
-
"context",
|
|
84
|
-
"prefix",
|
|
85
|
-
"summary"
|
|
86
|
-
],
|
|
87
|
-
additionalProperties: false
|
|
88
|
-
};
|
|
89
|
-
// schemas/v1/rule.schema.json
|
|
90
|
-
var rule_schema_default = {
|
|
91
|
-
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
92
|
-
$id: "urn:rulebookguard:schema:v1:rule",
|
|
93
|
-
title: "Rule file frontmatter",
|
|
94
|
-
type: "object",
|
|
95
|
-
$defs: { "ruleId": {
|
|
96
|
-
"type": "string",
|
|
97
|
-
"pattern": "^R-[A-Z][A-Z0-9]*-[0-9]{3}$"
|
|
98
|
-
} },
|
|
99
|
-
properties: {
|
|
100
|
-
"id": { "$ref": "#/$defs/ruleId" },
|
|
101
|
-
"context": {
|
|
102
|
-
"type": "string",
|
|
103
|
-
"minLength": 1
|
|
104
|
-
},
|
|
105
|
-
"entities": {
|
|
106
|
-
"type": "array",
|
|
107
|
-
"items": {
|
|
108
|
-
"type": "string",
|
|
109
|
-
"minLength": 1
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
"status": { "enum": [
|
|
113
|
-
"proposed",
|
|
114
|
-
"active",
|
|
115
|
-
"deprecated"
|
|
116
|
-
] },
|
|
117
|
-
"supersedes": { "$ref": "#/$defs/ruleId" },
|
|
118
|
-
"anchors": {
|
|
119
|
-
"type": "array",
|
|
120
|
-
"items": {
|
|
121
|
-
"type": "string",
|
|
122
|
-
"minLength": 1
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
"summary": {
|
|
126
|
-
"type": "string",
|
|
127
|
-
"minLength": 1,
|
|
128
|
-
"pattern": "^[^\\r\\n]*$"
|
|
129
|
-
}
|
|
130
|
-
},
|
|
131
|
-
required: [
|
|
132
|
-
"id",
|
|
133
|
-
"context",
|
|
134
|
-
"entities",
|
|
135
|
-
"status",
|
|
136
|
-
"anchors",
|
|
137
|
-
"summary"
|
|
138
|
-
],
|
|
139
|
-
additionalProperties: false
|
|
140
|
-
};
|
|
141
|
-
// src/format/validate.ts
|
|
142
|
-
const ajv = new Ajv2020({
|
|
143
|
-
allErrors: true,
|
|
144
|
-
useDefaults: true
|
|
145
|
-
});
|
|
146
|
-
const validators = {
|
|
147
|
-
config: ajv.compile(config_schema_default),
|
|
148
|
-
context: ajv.compile(context_schema_default),
|
|
149
|
-
rule: ajv.compile(rule_schema_default)
|
|
150
|
-
};
|
|
151
|
-
function validate(validator, data, file) {
|
|
152
|
-
if (validator(data)) return data;
|
|
153
|
-
throw new FormatError((validator.errors ?? []).map((error) => toIssue(error, file)));
|
|
154
|
-
}
|
|
155
|
-
function toIssue(error, file) {
|
|
156
|
-
const base = toFieldPath(error.instancePath);
|
|
157
|
-
const params = error.params;
|
|
158
|
-
switch (error.keyword) {
|
|
159
|
-
case "required": return {
|
|
160
|
-
file,
|
|
161
|
-
field: join$1(base, String(params.missingProperty)),
|
|
162
|
-
message: "is required"
|
|
163
|
-
};
|
|
164
|
-
case "additionalProperties": return {
|
|
165
|
-
file,
|
|
166
|
-
field: join$1(base, String(params.additionalProperty)),
|
|
167
|
-
message: "is not allowed"
|
|
168
|
-
};
|
|
169
|
-
case "enum": return withField(file, base, `must be one of: ${params.allowedValues.join(", ")}`);
|
|
170
|
-
case "const": return withField(file, base, `must be equal to ${JSON.stringify(params.allowedValue)}`);
|
|
171
|
-
case "pattern":
|
|
172
|
-
if (params.pattern === "^[^\\r\\n]*$") return withField(file, base, "must be a single line");
|
|
173
|
-
return withField(file, base, error.message ?? "is invalid");
|
|
174
|
-
default: return withField(file, base, error.message ?? "is invalid");
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
function withField(file, field, message) {
|
|
178
|
-
return field === "" ? {
|
|
179
|
-
file,
|
|
180
|
-
message
|
|
181
|
-
} : {
|
|
182
|
-
file,
|
|
183
|
-
field,
|
|
184
|
-
message
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
function join$1(base, key) {
|
|
188
|
-
return base === "" ? key : `${base}.${key}`;
|
|
189
|
-
}
|
|
190
|
-
function toFieldPath(instancePath) {
|
|
191
|
-
return instancePath.split("/").slice(1).map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")).reduce((path, segment) => /^\d+$/.test(segment) ? `${path}[${segment}]` : join$1(path, segment), "");
|
|
192
|
-
}
|
|
193
|
-
// src/format/yaml.ts
|
|
194
|
-
function parseYaml(source, file) {
|
|
195
|
-
try {
|
|
196
|
-
return parse(source);
|
|
197
|
-
} catch (error) {
|
|
198
|
-
if (error instanceof YAMLParseError) throw new FormatError([{
|
|
199
|
-
file,
|
|
200
|
-
message: `invalid YAML: ${singleLine(error)}`
|
|
201
|
-
}]);
|
|
202
|
-
throw error;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
function singleLine(error) {
|
|
206
|
-
const first = (error.message.split("\n")[0] ?? "").replace(/:\s*$/, "");
|
|
207
|
-
const position = error.linePos?.[0];
|
|
208
|
-
if (position === void 0 || / at line \d+, column \d+$/.test(first)) return first;
|
|
209
|
-
return `${first} at line ${position.line}, column ${position.col}`;
|
|
210
|
-
}
|
|
211
|
-
// src/format/config.ts
|
|
212
|
-
const CONFIG_FILE_NAME = "rulebookguard.config.yaml";
|
|
213
|
-
function parseConfig(source, file) {
|
|
214
|
-
return validate(validators.config, parseYaml(source, file), file);
|
|
215
|
-
}
|
|
216
|
-
async function loadConfig(path) {
|
|
217
|
-
return parseConfig(await readSource(path), path);
|
|
218
|
-
}
|
|
219
|
-
// src/format/frontmatter.ts
|
|
220
|
-
const OPENING = /^---\r?\n/;
|
|
221
|
-
const CLOSING = /^---[ \t]*(?:\r?\n|$)/m;
|
|
222
|
-
function splitFrontmatter(source, file) {
|
|
223
|
-
const opening = OPENING.exec(source);
|
|
224
|
-
if (opening === null) throw new FormatError([{
|
|
225
|
-
file,
|
|
226
|
-
message: "missing YAML frontmatter delimited by \"---\" lines"
|
|
227
|
-
}]);
|
|
228
|
-
const rest = source.slice(opening[0].length);
|
|
229
|
-
const closing = CLOSING.exec(rest);
|
|
230
|
-
if (closing === null) throw new FormatError([{
|
|
231
|
-
file,
|
|
232
|
-
message: "frontmatter is not closed by a \"---\" line"
|
|
233
|
-
}]);
|
|
234
|
-
const yaml = rest.slice(0, closing.index);
|
|
235
|
-
const body = rest.slice(closing.index + closing[0].length).replace(/^(?:\r?\n)+/, "");
|
|
236
|
-
return {
|
|
237
|
-
data: parseYaml(yaml, file),
|
|
238
|
-
body
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
// src/format/context.ts
|
|
242
|
-
const CONTEXT_FILE_NAME = "_context.md";
|
|
243
|
-
function parseContext(source, file) {
|
|
244
|
-
const { data, body } = splitFrontmatter(source, file);
|
|
245
|
-
return {
|
|
246
|
-
file,
|
|
247
|
-
...validate(validators.context, data, file),
|
|
248
|
-
body
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
async function loadContext(path) {
|
|
252
|
-
return parseContext(await readSource(path), path);
|
|
253
|
-
}
|
|
254
|
-
// src/format/rule.ts
|
|
255
|
-
const RULE_ID = /^R-[A-Z][A-Z0-9]*-[0-9]{3}$/;
|
|
256
|
-
function isRuleId(value) {
|
|
257
|
-
return RULE_ID.test(value);
|
|
258
|
-
}
|
|
259
|
-
function parseRule(source, file) {
|
|
260
|
-
const { data, body } = splitFrontmatter(source, file);
|
|
261
|
-
return {
|
|
262
|
-
file,
|
|
263
|
-
...validate(validators.rule, data, file),
|
|
264
|
-
body
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
async function loadRule(path) {
|
|
268
|
-
return parseRule(await readSource(path), path);
|
|
269
|
-
}
|
|
270
|
-
// src/format/tag.ts
|
|
271
|
-
const RULE_TAG_PATTERN = /(?<![\w@.-])@rule\s+(R-[A-Z][A-Z0-9]*-[0-9]{3})(?![\w-])/g;
|
|
272
|
-
function findRuleTags(source) {
|
|
273
|
-
const tags = [];
|
|
274
|
-
source.split(/\r?\n/).forEach((text, index) => {
|
|
275
|
-
for (const match of text.matchAll(RULE_TAG_PATTERN)) tags.push({
|
|
276
|
-
id: match[1],
|
|
277
|
-
line: index + 1
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
return tags;
|
|
281
|
-
}
|
|
282
|
-
// src/catalog/git.ts
|
|
283
|
-
const execFileAsync = promisify(execFile);
|
|
284
|
-
const SAFE_CONFIG = ["-c", "core.fsmonitor=false"];
|
|
285
|
-
async function runGit(cwd, args) {
|
|
286
|
-
const { stdout } = await execFileAsync("git", [...SAFE_CONFIG, ...args], {
|
|
287
|
-
cwd,
|
|
288
|
-
maxBuffer: 268435456
|
|
289
|
-
});
|
|
290
|
-
return stdout;
|
|
291
|
-
}
|
|
292
|
-
async function gitPaths(cwd, command, args) {
|
|
293
|
-
return (await runGit(cwd, [
|
|
294
|
-
command,
|
|
295
|
-
"-z",
|
|
296
|
-
...args
|
|
297
|
-
])).split("\0").filter((path) => path !== "");
|
|
298
|
-
}
|
|
299
|
-
async function isInsideWorkTree(cwd) {
|
|
300
|
-
try {
|
|
301
|
-
return (await runGit(cwd, ["rev-parse", "--is-inside-work-tree"])).trim() === "true";
|
|
302
|
-
} catch {
|
|
303
|
-
return false;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
async function isCommitRef(cwd, ref) {
|
|
307
|
-
if (ref.startsWith("-")) return false;
|
|
308
|
-
try {
|
|
309
|
-
await runGit(cwd, [
|
|
310
|
-
"rev-parse",
|
|
311
|
-
"--verify",
|
|
312
|
-
"--quiet",
|
|
313
|
-
`${ref}^{commit}`
|
|
314
|
-
]);
|
|
315
|
-
return true;
|
|
316
|
-
} catch {
|
|
317
|
-
return false;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
async function changedSince(cwd, ref) {
|
|
321
|
-
const changed = await gitPaths(cwd, "diff", [
|
|
322
|
-
"--name-only",
|
|
323
|
-
"--relative",
|
|
324
|
-
ref,
|
|
325
|
-
"--"
|
|
326
|
-
]);
|
|
327
|
-
const untracked = await gitPaths(cwd, "ls-files", ["--others", "--exclude-standard"]);
|
|
328
|
-
return [.../* @__PURE__ */ new Set([...changed, ...untracked])].sort();
|
|
329
|
-
}
|
|
330
|
-
function gitFailureReason(error) {
|
|
331
|
-
const { code, stderr, message } = error;
|
|
332
|
-
if (code === "ENOENT") return "git was not found";
|
|
333
|
-
return `${typeof stderr === "string" ? stderr : ""}\n${String(message ?? error)}`.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("warning:"))[0] ?? "git failed";
|
|
334
|
-
}
|
|
335
|
-
function isNotARepository(error) {
|
|
336
|
-
const { stderr } = error;
|
|
337
|
-
return typeof stderr === "string" && stderr.includes("not a git repository");
|
|
338
|
-
}
|
|
339
|
-
// src/catalog/files.ts
|
|
340
|
-
const SKIPPED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules"]);
|
|
341
|
-
const GITLINK_MODE = "160000";
|
|
342
|
-
function toRepoPath(path) {
|
|
343
|
-
return path.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
344
|
-
}
|
|
345
|
-
function createGlobMatcher(globs) {
|
|
346
|
-
if (globs.length === 0) return () => false;
|
|
347
|
-
const isMatch = picomatch(globs);
|
|
348
|
-
return (path) => isMatch(toRepoPath(path));
|
|
349
|
-
}
|
|
350
|
-
async function listRepoFiles(root, onGitFallback) {
|
|
351
|
-
return (await listGitFiles(root, {
|
|
352
|
-
realRoot: await realpath(root),
|
|
353
|
-
prefix: "",
|
|
354
|
-
onGitFallback
|
|
355
|
-
}) ?? await walkFiles(root)).sort();
|
|
356
|
-
}
|
|
357
|
-
async function expandGlobs(root, globs, onGitFallback) {
|
|
358
|
-
const isMatch = createGlobMatcher(globs);
|
|
359
|
-
return (await listRepoFiles(root, onGitFallback)).filter(isMatch);
|
|
360
|
-
}
|
|
361
|
-
async function listGitFiles(root, scope) {
|
|
362
|
-
let listing;
|
|
363
|
-
try {
|
|
364
|
-
listing = await listGitIndex(root);
|
|
365
|
-
} catch (error) {
|
|
366
|
-
if (isNotARepository(error)) return;
|
|
367
|
-
if (scope.prefix !== "") throw error;
|
|
368
|
-
scope.onGitFallback?.(gitFailureReason(error));
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
if (listing === void 0) return;
|
|
372
|
-
const { files, gitlinks } = listing;
|
|
373
|
-
for (const gitlink of gitlinks) {
|
|
374
|
-
const nested = await listSubmoduleFiles(root, gitlink, scope);
|
|
375
|
-
files.push(...nested.map((file) => `${gitlink}/${file}`));
|
|
376
|
-
}
|
|
377
|
-
return files;
|
|
378
|
-
}
|
|
379
|
-
async function listGitIndex(root) {
|
|
380
|
-
const staged = (await gitPaths(root, "ls-files", ["--stage"])).map(parseStageEntry);
|
|
381
|
-
const others = await gitPaths(root, "ls-files", ["--others", "--exclude-standard"]);
|
|
382
|
-
const deleted = new Set(await gitPaths(root, "ls-files", ["--deleted"]));
|
|
383
|
-
const gitlinks = new Set(staged.filter(({ mode }) => mode === GITLINK_MODE).map(({ path }) => path));
|
|
384
|
-
const files = [.../* @__PURE__ */ new Set([...staged.map(({ path }) => path), ...others])].filter((file) => !deleted.has(file) && !gitlinks.has(file));
|
|
385
|
-
if (files.length === 0 && gitlinks.size === 0) return;
|
|
386
|
-
return {
|
|
387
|
-
files,
|
|
388
|
-
gitlinks: [...gitlinks]
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
async function listSubmoduleFiles(root, gitlink, scope) {
|
|
392
|
-
const dir = join(root, gitlink);
|
|
393
|
-
const path = `${scope.prefix}${gitlink}`;
|
|
394
|
-
try {
|
|
395
|
-
if (!await isContainedEntry(dir, scope.realRoot)) return [];
|
|
396
|
-
if (!await exists$1(join(dir, ".git"))) return await walkFiles(dir);
|
|
397
|
-
return await listGitFiles(dir, {
|
|
398
|
-
...scope,
|
|
399
|
-
prefix: `${path}/`
|
|
400
|
-
}) ?? await walkFiles(dir);
|
|
401
|
-
} catch (error) {
|
|
402
|
-
scope.onGitFallback?.(gitFailureReason(error), path);
|
|
403
|
-
return [];
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
async function isContainedEntry(path, realRoot) {
|
|
407
|
-
try {
|
|
408
|
-
if ((await lstat(path)).isSymbolicLink()) return false;
|
|
409
|
-
} catch (error) {
|
|
410
|
-
if (error.code === "ENOENT") return false;
|
|
411
|
-
throw error;
|
|
412
|
-
}
|
|
413
|
-
return resolvesInside(path, realRoot);
|
|
414
|
-
}
|
|
415
|
-
async function resolvesInside(path, realRoot) {
|
|
416
|
-
let target;
|
|
417
|
-
try {
|
|
418
|
-
target = await realpath(path);
|
|
419
|
-
} catch (error) {
|
|
420
|
-
if (error.code === "ENOENT") return false;
|
|
421
|
-
throw error;
|
|
422
|
-
}
|
|
423
|
-
const fromRoot = relative(realRoot, target);
|
|
424
|
-
return fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`) && !isAbsolute(fromRoot);
|
|
425
|
-
}
|
|
426
|
-
function parseStageEntry(entry) {
|
|
427
|
-
const tab = entry.indexOf(" ");
|
|
428
|
-
return {
|
|
429
|
-
mode: entry.slice(0, entry.indexOf(" ")),
|
|
430
|
-
path: entry.slice(tab + 1)
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
async function exists$1(path) {
|
|
434
|
-
try {
|
|
435
|
-
await access(path);
|
|
436
|
-
return true;
|
|
437
|
-
} catch {
|
|
438
|
-
return false;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
async function walkFiles(root) {
|
|
442
|
-
const files = [];
|
|
443
|
-
await walk(root, "", files);
|
|
444
|
-
return files;
|
|
445
|
-
}
|
|
446
|
-
async function walk(root, prefix, files) {
|
|
447
|
-
const entries = await readdir(join(root, prefix), { withFileTypes: true });
|
|
448
|
-
for (const entry of entries) {
|
|
449
|
-
const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
450
|
-
if (entry.isDirectory()) {
|
|
451
|
-
if (!SKIPPED_DIRECTORIES.has(entry.name)) await walk(root, path, files);
|
|
452
|
-
} else if (entry.isFile()) files.push(path);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
// src/catalog/load.ts
|
|
456
|
-
async function findRulebookRoot(start) {
|
|
457
|
-
let directory = resolve(start);
|
|
458
|
-
for (;;) {
|
|
459
|
-
if (await exists(join(directory, "rulebookguard.config.yaml"))) return directory;
|
|
460
|
-
const parent = dirname(directory);
|
|
461
|
-
if (parent === directory) return;
|
|
462
|
-
directory = parent;
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
async function loadCatalog(root) {
|
|
466
|
-
const config = await loadConfig(join(root, CONFIG_FILE_NAME));
|
|
467
|
-
const catalog = {
|
|
468
|
-
root,
|
|
469
|
-
config,
|
|
470
|
-
contexts: [],
|
|
471
|
-
rules: [],
|
|
472
|
-
invalidRuleIds: [],
|
|
473
|
-
issues: []
|
|
474
|
-
};
|
|
475
|
-
const rulesDir = toRepoPath(config.rulesDir).replace(/\/+$/, "");
|
|
476
|
-
const directories = await listEntries(join(root, rulesDir));
|
|
477
|
-
if (directories === void 0) {
|
|
478
|
-
catalog.issues.push({
|
|
479
|
-
code: "missing-rules-dir",
|
|
480
|
-
file: rulesDir,
|
|
481
|
-
message: "directory does not exist"
|
|
482
|
-
});
|
|
483
|
-
return catalog;
|
|
484
|
-
}
|
|
485
|
-
for (const entry of directories.filter((item) => item.isDirectory())) await loadContextDirectory(catalog, `${rulesDir}/${entry.name}`, entry.name);
|
|
486
|
-
catalog.issues.push(...findDuplicateIds(catalog.rules));
|
|
487
|
-
return catalog;
|
|
488
|
-
}
|
|
489
|
-
async function loadContextDirectory(catalog, dir, name) {
|
|
490
|
-
const files = (await listEntries(join(catalog.root, dir)) ?? []).filter((entry) => entry.isFile() && entry.name.endsWith(".md")).map((entry) => entry.name);
|
|
491
|
-
let context;
|
|
492
|
-
if (files.includes("_context.md")) {
|
|
493
|
-
context = await load(catalog, `${dir}/${CONTEXT_FILE_NAME}`, loadContext);
|
|
494
|
-
if (context !== void 0) {
|
|
495
|
-
catalog.contexts.push(context);
|
|
496
|
-
checkContextName(catalog, context, name);
|
|
497
|
-
}
|
|
498
|
-
} else catalog.issues.push({
|
|
499
|
-
code: "missing-context-file",
|
|
500
|
-
file: dir,
|
|
501
|
-
message: "has no _context.md"
|
|
502
|
-
});
|
|
503
|
-
for (const file of files.filter((item) => item !== CONTEXT_FILE_NAME)) {
|
|
504
|
-
const rule = await load(catalog, `${dir}/${file}`, loadRule);
|
|
505
|
-
const baseName = file.slice(0, -3);
|
|
506
|
-
if (rule === void 0 && isRuleId(baseName)) catalog.invalidRuleIds.push(baseName);
|
|
507
|
-
if (rule !== void 0) {
|
|
508
|
-
catalog.rules.push(rule);
|
|
509
|
-
checkRule(catalog, rule, name, baseName, context);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
async function load(catalog, file, loader) {
|
|
514
|
-
try {
|
|
515
|
-
return {
|
|
516
|
-
...await loader(join(catalog.root, file)),
|
|
517
|
-
file
|
|
518
|
-
};
|
|
519
|
-
} catch (error) {
|
|
520
|
-
if (!(error instanceof FormatError)) throw error;
|
|
521
|
-
for (const issue of error.issues) catalog.issues.push({
|
|
522
|
-
code: "schema",
|
|
523
|
-
...issue,
|
|
524
|
-
file
|
|
525
|
-
});
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
function checkContextName(catalog, doc, name) {
|
|
530
|
-
if (doc.context !== name) catalog.issues.push({
|
|
531
|
-
code: "context-mismatch",
|
|
532
|
-
file: doc.file,
|
|
533
|
-
field: "context",
|
|
534
|
-
message: `must match its directory name "${name}"`
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
function checkRule(catalog, rule, directory, baseName, context) {
|
|
538
|
-
if (rule.id !== baseName) catalog.issues.push({
|
|
539
|
-
code: "file-name-mismatch",
|
|
540
|
-
file: rule.file,
|
|
541
|
-
field: "id",
|
|
542
|
-
message: `must match the file name "${baseName}"`
|
|
543
|
-
});
|
|
544
|
-
if (context !== void 0 && !rule.id.startsWith(`R-${context.prefix}-`)) catalog.issues.push({
|
|
545
|
-
code: "id-prefix-mismatch",
|
|
546
|
-
file: rule.file,
|
|
547
|
-
field: "id",
|
|
548
|
-
message: `must use the context prefix "${context.prefix}"`
|
|
549
|
-
});
|
|
550
|
-
checkContextName(catalog, rule, directory);
|
|
551
|
-
}
|
|
552
|
-
function findDuplicateIds(rules) {
|
|
553
|
-
const firstFile = /* @__PURE__ */ new Map();
|
|
554
|
-
const issues = [];
|
|
555
|
-
for (const rule of rules) {
|
|
556
|
-
const first = firstFile.get(rule.id);
|
|
557
|
-
if (first === void 0) firstFile.set(rule.id, rule.file);
|
|
558
|
-
else issues.push({
|
|
559
|
-
code: "duplicate-id",
|
|
560
|
-
file: rule.file,
|
|
561
|
-
field: "id",
|
|
562
|
-
message: `duplicates the id of ${first}`
|
|
563
|
-
});
|
|
564
|
-
}
|
|
565
|
-
return issues;
|
|
566
|
-
}
|
|
567
|
-
async function listEntries(path) {
|
|
568
|
-
try {
|
|
569
|
-
return (await readdir(path, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
570
|
-
} catch (error) {
|
|
571
|
-
if (error.code === "ENOENT") return;
|
|
572
|
-
throw error;
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
async function exists(path) {
|
|
576
|
-
try {
|
|
577
|
-
await access(path);
|
|
578
|
-
return true;
|
|
579
|
-
} catch {
|
|
580
|
-
return false;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
function relativeToRoot(root, path) {
|
|
584
|
-
return toRepoPath(relative(root, path));
|
|
585
|
-
}
|
|
586
|
-
// src/catalog/tags.ts
|
|
587
|
-
async function scanTestTags(root, testGlobs, onGitFallback) {
|
|
588
|
-
const index = /* @__PURE__ */ new Map();
|
|
589
|
-
const realRoot = await realpath(root);
|
|
590
|
-
for (const file of await expandGlobs(root, testGlobs, onGitFallback)) {
|
|
591
|
-
const path = join(root, file);
|
|
592
|
-
if (!await resolvesInside(path, realRoot)) continue;
|
|
593
|
-
const source = await readFile(path, "utf8");
|
|
594
|
-
for (const { id, line } of findRuleTags(source)) {
|
|
595
|
-
const locations = index.get(id) ?? [];
|
|
596
|
-
locations.push({
|
|
597
|
-
file,
|
|
598
|
-
line
|
|
599
|
-
});
|
|
600
|
-
index.set(id, locations);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
return new Map([...index].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
604
|
-
}
|
|
605
|
-
var CliError = class extends Error {
|
|
606
|
-
exitCode;
|
|
607
|
-
constructor(message, exitCode = 1) {
|
|
608
|
-
super(message);
|
|
609
|
-
this.name = "CliError";
|
|
610
|
-
this.exitCode = exitCode;
|
|
611
|
-
}
|
|
612
|
-
};
|
|
613
|
-
async function requireRulebookRoot(io) {
|
|
614
|
-
const root = await findRulebookRoot(io.cwd);
|
|
615
|
-
if (root === void 0) throw new CliError(`${CONFIG_FILE_NAME} not found in ${io.cwd} or any parent directory.`);
|
|
616
|
-
return root;
|
|
617
|
-
}
|
|
618
|
-
async function openCatalog(io) {
|
|
619
|
-
const catalog = await loadCatalog(await requireRulebookRoot(io));
|
|
620
|
-
const count = catalog.issues.length;
|
|
621
|
-
if (count > 0) io.stderr(`warning: the catalog has ${count} ${count === 1 ? "issue" : "issues"}; run "rulebookguard lint" for details.\n`);
|
|
622
|
-
return catalog;
|
|
623
|
-
}
|
|
624
|
-
function findRule(catalog, id) {
|
|
625
|
-
const rule = catalog.rules.find((item) => item.id === id);
|
|
626
|
-
if (rule === void 0) throw new CliError(`Unknown rule id "${id}". Run "rulebookguard list" to list them.`);
|
|
627
|
-
return rule;
|
|
628
|
-
}
|
|
629
|
-
function writeJson(io, value) {
|
|
630
|
-
io.stdout(`${JSON.stringify(value, null, 2)}\n`);
|
|
631
|
-
}
|
|
632
|
-
function writeLines(io, lines) {
|
|
633
|
-
io.stdout(lines.map((line) => `${line}\n`).join(""));
|
|
634
|
-
}
|
|
635
|
-
function gitFallbackWarning(io) {
|
|
636
|
-
let warned = false;
|
|
637
|
-
const skipped = /* @__PURE__ */ new Set();
|
|
638
|
-
return (reason, submodule) => {
|
|
639
|
-
if (submodule !== void 0) {
|
|
640
|
-
if (!skipped.has(submodule)) {
|
|
641
|
-
skipped.add(submodule);
|
|
642
|
-
io.stderr(`warning: could not list files of submodule "${submodule}" (${reason}); skipping it.\n`);
|
|
643
|
-
}
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
if (!warned) {
|
|
647
|
-
warned = true;
|
|
648
|
-
io.stderr(`warning: git could not list files (${reason}); walking the directory instead.\n`);
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
}
|
|
652
|
-
// src/commands/lint.ts
|
|
653
|
-
async function lintCommand(io, options) {
|
|
654
|
-
const root = await requireRulebookRoot(io);
|
|
655
|
-
let catalog;
|
|
656
|
-
try {
|
|
657
|
-
catalog = await loadCatalog(root);
|
|
658
|
-
} catch (error) {
|
|
659
|
-
if (!(error instanceof FormatError)) throw error;
|
|
660
|
-
return report(io, error.issues.map((issue) => issueFinding("schema", {
|
|
661
|
-
...issue,
|
|
662
|
-
file: relativeToRoot(root, issue.file)
|
|
663
|
-
})), options.json);
|
|
664
|
-
}
|
|
665
|
-
const changed = options.base === void 0 ? void 0 : await changedFiles(catalog.root, options.base);
|
|
666
|
-
const warn = gitFallbackWarning(io);
|
|
667
|
-
const tags = await scanTestTags(catalog.root, catalog.config.testGlobs, warn);
|
|
668
|
-
const findings = await lintCatalog(catalog, tags, warn);
|
|
669
|
-
if (options.base !== void 0 && changed !== void 0) findings.push(...pendingImplementation(catalog, tags, options.base, changed));
|
|
670
|
-
return report(io, findings, options.json);
|
|
671
|
-
}
|
|
672
|
-
function report(io, findings, json) {
|
|
673
|
-
const sorted = sortFindings(findings);
|
|
674
|
-
const errors = sorted.filter((finding) => finding.severity === "error").length;
|
|
675
|
-
const warnings = sorted.length - errors;
|
|
676
|
-
if (json) writeJson(io, {
|
|
677
|
-
findings: sorted,
|
|
678
|
-
errors,
|
|
679
|
-
warnings
|
|
680
|
-
});
|
|
681
|
-
else if (sorted.length === 0) writeLines(io, ["No issues found."]);
|
|
682
|
-
else writeLines(io, [...sorted.map(formatFinding), `${plural(errors, "error")}, ${plural(warnings, "warning")}`]);
|
|
683
|
-
return errors > 0 ? 1 : 0;
|
|
684
|
-
}
|
|
685
|
-
function issueFinding(code, { file, field, message }) {
|
|
686
|
-
return error(code, file, field === void 0 ? message : `field "${field}" ${message}`);
|
|
687
|
-
}
|
|
688
|
-
async function lintCatalog(catalog, tags, onGitFallback) {
|
|
689
|
-
const tagIndex = tags ?? await scanTestTags(catalog.root, catalog.config.testGlobs, onGitFallback);
|
|
690
|
-
const findings = catalog.issues.map((issue) => issueFinding(issue.code, issue));
|
|
691
|
-
const files = await listRepoFiles(catalog.root, onGitFallback);
|
|
692
|
-
const status = new Map(catalog.rules.map((rule) => [rule.id, rule.status]));
|
|
693
|
-
const invalid = new Set(catalog.invalidRuleIds);
|
|
694
|
-
for (const rule of catalog.rules) {
|
|
695
|
-
if (rule.status === "active" && !tagIndex.has(rule.id)) findings.push(error("untested-rule", rule.file, `active rule ${rule.id} has no test tagging it`));
|
|
696
|
-
for (const anchor of rule.anchors) if (!files.some(createGlobMatcher([anchor]))) findings.push(error("unmatched-anchor", rule.file, `anchor "${anchor}" matches no file`));
|
|
697
|
-
}
|
|
698
|
-
for (const [id, locations] of tagIndex) {
|
|
699
|
-
const ruleStatus = status.get(id);
|
|
700
|
-
if (ruleStatus !== void 0 && ruleStatus !== "deprecated" || invalid.has(id)) continue;
|
|
701
|
-
for (const { file, line } of locations) findings.push(ruleStatus === void 0 ? {
|
|
702
|
-
...error("unknown-rule-tag", file, `tags ${id}, which does not exist`),
|
|
703
|
-
line
|
|
704
|
-
} : {
|
|
705
|
-
...error("deprecated-rule-tag", file, `tags ${id}, which is deprecated`),
|
|
706
|
-
line
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
const budget = catalog.config.contextBudget;
|
|
710
|
-
for (const context of catalog.contexts) {
|
|
711
|
-
const count = catalog.rules.filter((rule) => rule.context === context.context).length;
|
|
712
|
-
if (count > budget) findings.push({
|
|
713
|
-
severity: "warning",
|
|
714
|
-
code: "context-over-budget",
|
|
715
|
-
file: context.file,
|
|
716
|
-
message: `context "${context.context}" has ${count} rules, more than the contextBudget of ${budget}`
|
|
717
|
-
});
|
|
718
|
-
}
|
|
719
|
-
return findings;
|
|
720
|
-
}
|
|
721
|
-
function pendingImplementation(catalog, tags, base, changed) {
|
|
722
|
-
const changedSet = new Set(changed);
|
|
723
|
-
return catalog.rules.filter((rule) => rule.status === "active" && changedSet.has(rule.file)).filter((rule) => (tags.get(rule.id) ?? []).length > 0).filter((rule) => !(tags.get(rule.id) ?? []).some(({ file }) => changedSet.has(file))).map((rule) => error("pending-implementation", rule.file, `rule ${rule.id} changed since ${base} but none of the tests tagging it changed`));
|
|
724
|
-
}
|
|
725
|
-
async function changedFiles(root, base) {
|
|
726
|
-
if (!await isInsideWorkTree(root)) throw new CliError(`lint --base needs a git work tree, but ${root} is not inside one.`);
|
|
727
|
-
if (!await isCommitRef(root, base)) throw new CliError(`Unknown git ref "${base}".`);
|
|
728
|
-
try {
|
|
729
|
-
return await changedSince(root, base);
|
|
730
|
-
} catch (error) {
|
|
731
|
-
throw new CliError(`Cannot list the files changed since "${base}": ${gitFailureReason(error)}`);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
function formatFinding(finding) {
|
|
735
|
-
const location = finding.line === void 0 ? finding.file : `${finding.file}:${finding.line}`;
|
|
736
|
-
return `${finding.severity} ${finding.code} ${location} ${finding.message}`;
|
|
737
|
-
}
|
|
738
|
-
function error(code, file, message) {
|
|
739
|
-
return {
|
|
740
|
-
severity: "error",
|
|
741
|
-
code,
|
|
742
|
-
file,
|
|
743
|
-
message
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
function sortFindings(findings) {
|
|
747
|
-
return [...findings].sort((a, b) => compare(a.file, b.file) || (a.line ?? 0) - (b.line ?? 0) || compare(a.code, b.code));
|
|
748
|
-
}
|
|
749
|
-
function plural(count, noun) {
|
|
750
|
-
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
751
|
-
}
|
|
752
|
-
function compare(a, b) {
|
|
753
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
754
|
-
}
|
|
755
|
-
export { loadConfig as A, loadRule as C, parseContext as D, loadContext as E, FormatError as M, formatIssue as N, splitFrontmatter as O, isRuleId as S, CONTEXT_FILE_NAME as T, listRepoFiles as _, CliError as a, RULE_TAG_PATTERN as b, openCatalog as c, scanTestTags as d, findRulebookRoot as f, expandGlobs as g, createGlobMatcher as h, pendingImplementation as i, parseConfig as j, CONFIG_FILE_NAME as k, writeJson as l, relativeToRoot as m, lintCatalog as n, findRule as o, loadCatalog as p, lintCommand as r, gitFallbackWarning as s, formatFinding as t, writeLines as u, toRepoPath as v, parseRule as w, findRuleTags as x, runGit as y };
|