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.
@@ -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 };