madr-lint 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/dist/cli.js ADDED
@@ -0,0 +1,1500 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
7
+
8
+ // src/cli.ts
9
+ import { readFileSync as readFileSync4 } from "fs";
10
+ import { relative as relative2, resolve as resolve3, sep as sep2 } from "path";
11
+ import { defineCommand, runMain } from "citty";
12
+
13
+ // src/core/cache.ts
14
+ import { createHash } from "crypto";
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
16
+ import { dirname, join } from "path";
17
+ function computeContentHash(content) {
18
+ return createHash("sha1").update(content, "utf8").digest("hex");
19
+ }
20
+ function computeConfigHash(config) {
21
+ return createHash("sha1").update(stableStringify(config), "utf8").digest("hex");
22
+ }
23
+ function stableStringify(value) {
24
+ if (value === null || typeof value !== "object") {
25
+ return JSON.stringify(value);
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return "[" + value.map(stableStringify).join(",") + "]";
29
+ }
30
+ const entries = Object.entries(value).toSorted(
31
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
32
+ );
33
+ return "{" + entries.map(([k, v]) => JSON.stringify(k) + ":" + stableStringify(v)).join(",") + "}";
34
+ }
35
+ function manifestPath(cacheDir) {
36
+ return join(cacheDir, "manifest.json");
37
+ }
38
+ function loadManifest(path) {
39
+ if (!existsSync(path)) return null;
40
+ try {
41
+ const data = JSON.parse(readFileSync(path, "utf8"));
42
+ if (typeof data.version !== "string" || typeof data.configHash !== "string" || typeof data.files !== "object" || data.files === null) {
43
+ return null;
44
+ }
45
+ return data;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ function saveManifest(path, manifest) {
51
+ mkdirSync(dirname(path), { recursive: true });
52
+ writeFileSync(path, JSON.stringify(manifest), "utf8");
53
+ }
54
+
55
+ // src/core/config.ts
56
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
57
+ import { resolve } from "path";
58
+ import { createJiti } from "jiti";
59
+
60
+ // src/configs/recommended.ts
61
+ var recommended = {
62
+ "madr/date-iso8601": "error",
63
+ "madr/filename-format": "error",
64
+ "madr/no-broken-links": "error",
65
+ "madr/no-duplicate-numbering": "error",
66
+ // Convention-only — opt in if your team treats ADR numbering as a
67
+ // contiguous sequence. See docs/rules/no-numbering-gap.md.
68
+ "madr/no-numbering-gap": "off",
69
+ "madr/required-sections": "error",
70
+ "madr/status-enum": "error",
71
+ "madr/supersedes-bidirectional": "error"
72
+ };
73
+
74
+ // src/core/config.ts
75
+ var CONFIG_FILES = [
76
+ ".madrlintrc.json",
77
+ ".madrlintrc.ts",
78
+ ".madrlintrc.mts",
79
+ ".madrlintrc.js",
80
+ ".madrlintrc.mjs",
81
+ ".madrlintrc.cjs",
82
+ "madr-lint.config.ts",
83
+ "madr-lint.config.mts",
84
+ "madr-lint.config.js",
85
+ "madr-lint.config.mjs",
86
+ "madr-lint.config.cjs"
87
+ ];
88
+ function loadConfig(cwd) {
89
+ for (const name of CONFIG_FILES) {
90
+ const path = resolve(cwd, name);
91
+ if (!existsSync2(path)) continue;
92
+ if (name.endsWith(".json")) {
93
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
94
+ return resolveExtends(parsed);
95
+ }
96
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
97
+ const loaded = jiti(path);
98
+ const config = typeof loaded === "object" && loaded !== null && "default" in loaded ? loaded.default : loaded;
99
+ return resolveExtends(config);
100
+ }
101
+ return resolveExtends({});
102
+ }
103
+ function resolveExtends(config) {
104
+ const baseRules = config.extends?.includes(
105
+ "madr-lint:recommended"
106
+ ) ? { ...recommended } : {};
107
+ return {
108
+ adrDir: config.adrDir ?? "docs/adr",
109
+ rules: { ...baseRules, ...config.rules },
110
+ ignorePatterns: config.ignorePatterns ?? [],
111
+ madrVersion: config.madrVersion ?? "auto",
112
+ cache: config.cache ?? true,
113
+ cacheLocation: config.cacheLocation ?? ".madr-lint/cache"
114
+ };
115
+ }
116
+
117
+ // src/core/discover.ts
118
+ import { existsSync as existsSync3, readdirSync, statSync } from "fs";
119
+ import { join as join2 } from "path";
120
+ function findAdrFiles(target) {
121
+ if (!existsSync3(target)) return [];
122
+ const stat = statSync(target);
123
+ if (stat.isFile()) {
124
+ return target.endsWith(".md") ? [target] : [];
125
+ }
126
+ if (!stat.isDirectory()) return [];
127
+ const entries = readdirSync(target, { recursive: true, withFileTypes: true });
128
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => join2(e.parentPath, e.name)).toSorted();
129
+ }
130
+
131
+ // src/core/ignore.ts
132
+ import { basename } from "path";
133
+ import picomatch from "picomatch";
134
+ function shouldIgnore(relativePath, patterns) {
135
+ if (patterns.length === 0) return false;
136
+ const base = basename(relativePath);
137
+ for (const pattern of patterns) {
138
+ if (matchesPattern(relativePath, base, pattern)) return true;
139
+ }
140
+ return false;
141
+ }
142
+ function matchesPattern(relativePath, base, pattern) {
143
+ if (relativePath === pattern) return true;
144
+ const matcher = picomatch(pattern, { dot: true });
145
+ if (matcher(relativePath)) return true;
146
+ if (matcher(base)) return true;
147
+ if (pattern.includes("/") && !pattern.startsWith("**/")) {
148
+ const suffixMatcher = picomatch(`**/${pattern}`, { dot: true });
149
+ if (suffixMatcher(relativePath)) return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ // src/core/lint.ts
155
+ import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
156
+ import { isAbsolute, relative, resolve as resolve2, sep } from "path";
157
+
158
+ // src/core/runner.ts
159
+ import Ajv from "ajv";
160
+
161
+ // src/core/parser.ts
162
+ import { fromMarkdown } from "mdast-util-from-markdown";
163
+ import { toString } from "mdast-util-to-string";
164
+ import grayMatter from "gray-matter";
165
+ function parseFile(content) {
166
+ const matter = grayMatter(content);
167
+ const data = matter.data;
168
+ const frontmatter = Object.keys(data).length > 0 ? data : null;
169
+ const ast = fromMarkdown(matter.content);
170
+ const listMetadata = extractListMetadata(ast);
171
+ let metadata = null;
172
+ if (frontmatter && listMetadata) {
173
+ const frontmatterDefined = {};
174
+ for (const [k, v] of Object.entries(frontmatter)) {
175
+ if (v !== null && v !== void 0) frontmatterDefined[k] = v;
176
+ }
177
+ metadata = { ...listMetadata, ...frontmatterDefined };
178
+ } else if (frontmatter) {
179
+ metadata = frontmatter;
180
+ } else if (listMetadata) {
181
+ metadata = listMetadata;
182
+ }
183
+ return { frontmatter, listMetadata, metadata, ast, body: matter.content };
184
+ }
185
+ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
186
+ "status",
187
+ "date",
188
+ "deciders",
189
+ "decision-makers",
190
+ "consulted",
191
+ "informed"
192
+ ]);
193
+ function extractListMetadata(ast) {
194
+ let firstList = null;
195
+ for (const child of ast.children) {
196
+ if (child.type === "heading") {
197
+ if (child.depth >= 2) break;
198
+ continue;
199
+ }
200
+ if (child.type === "html") continue;
201
+ if (child.type === "list") {
202
+ firstList = child;
203
+ break;
204
+ }
205
+ break;
206
+ }
207
+ if (!firstList) return null;
208
+ const result = {};
209
+ for (const item of firstList.children) {
210
+ const pair = extractListItemKV(item);
211
+ if (pair && !(pair.key in result)) {
212
+ result[pair.key] = pair.value;
213
+ }
214
+ }
215
+ const hasRecognizedKey = Object.keys(result).some(
216
+ (k) => RECOGNIZED_METADATA_KEYS.has(k)
217
+ );
218
+ return hasRecognizedKey ? result : null;
219
+ }
220
+ var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
221
+ var SEPARATOR_PATTERN = /^\s*:\s*/;
222
+ function normalizeKey(rawKey) {
223
+ return rawKey.trim().toLowerCase().replace(/\s+/g, "-");
224
+ }
225
+ function extractListItemKV(item) {
226
+ const para = item.children[0];
227
+ if (para?.type !== "paragraph") return null;
228
+ const paragraph = para;
229
+ const first = paragraph.children[0];
230
+ if (!first) return null;
231
+ if (first.type === "strong") {
232
+ const keyNode = first.children[0];
233
+ if (keyNode?.type !== "text" || !KEY_PATTERN.test(keyNode.value)) {
234
+ return null;
235
+ }
236
+ const sep3 = paragraph.children[1];
237
+ if (sep3?.type !== "text" || !SEPARATOR_PATTERN.test(sep3.value)) return null;
238
+ const valueHead = sep3.value.replace(SEPARATOR_PATTERN, "");
239
+ const valueTail = paragraph.children.slice(2).map((n) => toString(n)).join("");
240
+ const value = (valueHead + valueTail).trim();
241
+ if (value === "") return null;
242
+ return { key: normalizeKey(keyNode.value), value };
243
+ }
244
+ if (first.type === "text") {
245
+ const colonIdx = first.value.indexOf(":");
246
+ if (colonIdx === -1) return null;
247
+ const rawKey = first.value.slice(0, colonIdx);
248
+ if (!KEY_PATTERN.test(rawKey)) return null;
249
+ const valueHead = first.value.slice(colonIdx + 1).replace(/^\s+/, "");
250
+ const valueTail = paragraph.children.slice(1).map((n) => toString(n)).join("");
251
+ const value = (valueHead + valueTail).trim();
252
+ if (value === "") return null;
253
+ return { key: normalizeKey(rawKey), value };
254
+ }
255
+ return null;
256
+ }
257
+
258
+ // src/core/runner.ts
259
+ var ajv = new Ajv({ strict: true, allErrors: true });
260
+ var validatorCache = /* @__PURE__ */ new WeakMap();
261
+ function getValidator(schema) {
262
+ let validator = validatorCache.get(schema);
263
+ if (!validator) {
264
+ validator = ajv.compile(schema);
265
+ validatorCache.set(schema, validator);
266
+ }
267
+ return validator;
268
+ }
269
+ var INTERNAL_ERROR_RULE_NAME = "core/internal-error";
270
+ var RuleOptionsError = class extends Error {
271
+ constructor(ruleName, ajvErrors) {
272
+ super(`Invalid options for rule ${ruleName}: ${ajvErrors}`);
273
+ this.ruleName = ruleName;
274
+ this.ajvErrors = ajvErrors;
275
+ this.name = "RuleOptionsError";
276
+ }
277
+ ruleName;
278
+ ajvErrors;
279
+ };
280
+ function runRulesOnFile(rules, file, runtime = {}) {
281
+ const diagnostics = [];
282
+ const severity = runtime.severity ?? "error";
283
+ let parsed = null;
284
+ const ensureParsed = () => {
285
+ parsed ??= parseFile(file.content);
286
+ return parsed;
287
+ };
288
+ const enterMap = {};
289
+ const exitMap = {};
290
+ let hasASTRule = false;
291
+ for (const rule9 of rules) {
292
+ const perRuleOptions = runtime.optionsByRule?.[rule9.meta.name] ?? runtime.options;
293
+ const mergedOptions = {
294
+ ...rule9.meta.defaultOptions,
295
+ ...perRuleOptions
296
+ };
297
+ if (rule9.meta.schema && !runtime.skipValidation) {
298
+ const validator = getValidator(rule9.meta.schema);
299
+ if (!validator(mergedOptions)) {
300
+ throw new RuleOptionsError(rule9.meta.name, ajv.errorsText(validator.errors));
301
+ }
302
+ }
303
+ const context = {
304
+ file,
305
+ get frontmatter() {
306
+ return ensureParsed().frontmatter;
307
+ },
308
+ get metadata() {
309
+ return ensureParsed().metadata;
310
+ },
311
+ options: mergedOptions,
312
+ report(d) {
313
+ diagnostics.push({
314
+ ruleName: rule9.meta.name,
315
+ severity,
316
+ path: file.path,
317
+ ...d
318
+ });
319
+ }
320
+ };
321
+ let listeners;
322
+ try {
323
+ listeners = rule9.create(context);
324
+ } catch (err) {
325
+ if (err instanceof RuleOptionsError) throw err;
326
+ diagnostics.push(
327
+ internalErrorDiagnostic(rule9.meta.name, "create", err, file.path)
328
+ );
329
+ continue;
330
+ }
331
+ if (listeners) {
332
+ hasASTRule = true;
333
+ collectListeners(rule9, listeners, enterMap, exitMap, diagnostics, file);
334
+ }
335
+ }
336
+ if (hasASTRule) {
337
+ walk(ensureParsed().ast, enterMap, exitMap);
338
+ }
339
+ return diagnostics;
340
+ }
341
+ function internalErrorDiagnostic(ruleName, operation, err, path) {
342
+ return {
343
+ ruleName: INTERNAL_ERROR_RULE_NAME,
344
+ messageId: "ruleThrew",
345
+ severity: "error",
346
+ path,
347
+ data: {
348
+ rule: ruleName,
349
+ operation,
350
+ error: err instanceof Error ? err.message : String(err)
351
+ }
352
+ };
353
+ }
354
+ function collectListeners(rule9, listeners, enterMap, exitMap, diagnostics, file) {
355
+ const wrap = (op) => (handler) => (node) => {
356
+ try {
357
+ handler(node);
358
+ } catch (err) {
359
+ diagnostics.push(
360
+ internalErrorDiagnostic(rule9.meta.name, op, err, file.path)
361
+ );
362
+ }
363
+ };
364
+ if (listeners.enter) {
365
+ const wrapEnter = wrap("enter");
366
+ for (const [type, handler] of Object.entries(listeners.enter)) {
367
+ if (handler) (enterMap[type] ??= []).push(wrapEnter(handler));
368
+ }
369
+ }
370
+ if (listeners.exit) {
371
+ const wrapExit = wrap("exit");
372
+ for (const [type, handler] of Object.entries(listeners.exit)) {
373
+ if (handler) (exitMap[type] ??= []).push(wrapExit(handler));
374
+ }
375
+ }
376
+ }
377
+ function walk(node, enterMap, exitMap) {
378
+ const enterHandlers = enterMap[node.type];
379
+ if (enterHandlers) {
380
+ for (const handler of enterHandlers) handler(node);
381
+ }
382
+ if ("children" in node && Array.isArray(node.children)) {
383
+ for (const child of node.children) {
384
+ walk(child, enterMap, exitMap);
385
+ }
386
+ }
387
+ const exitHandlers = exitMap[node.type];
388
+ if (exitHandlers) {
389
+ for (const handler of exitHandlers) handler(node);
390
+ }
391
+ }
392
+ function buildProjectFile(file) {
393
+ const parsed = parseFile(file.content);
394
+ return {
395
+ path: file.path,
396
+ content: file.content,
397
+ frontmatter: parsed.frontmatter,
398
+ metadata: parsed.metadata,
399
+ ast: parsed.ast
400
+ };
401
+ }
402
+ function runRulesOnProject(rules, files, runtime = {}) {
403
+ const diagnostics = [];
404
+ const severity = runtime.severity ?? "error";
405
+ for (const rule9 of rules) {
406
+ const perRuleOptions = runtime.optionsByRule?.[rule9.meta.name] ?? runtime.options;
407
+ const mergedOptions = {
408
+ ...rule9.meta.defaultOptions,
409
+ ...perRuleOptions
410
+ };
411
+ if (rule9.meta.schema && !runtime.skipValidation) {
412
+ const validator = getValidator(rule9.meta.schema);
413
+ if (!validator(mergedOptions)) {
414
+ throw new RuleOptionsError(
415
+ rule9.meta.name,
416
+ ajv.errorsText(validator.errors)
417
+ );
418
+ }
419
+ }
420
+ const context = {
421
+ files,
422
+ options: mergedOptions,
423
+ fileExists: runtime.fileExists,
424
+ report(d) {
425
+ diagnostics.push({
426
+ ruleName: rule9.meta.name,
427
+ severity,
428
+ ...d
429
+ });
430
+ }
431
+ };
432
+ try {
433
+ rule9.check(context);
434
+ } catch (err) {
435
+ diagnostics.push(
436
+ internalErrorDiagnostic(rule9.meta.name, "check", err, "<project>")
437
+ );
438
+ }
439
+ }
440
+ return diagnostics;
441
+ }
442
+
443
+ // src/core/types.ts
444
+ function isProjectRule(rule9) {
445
+ return "check" in rule9;
446
+ }
447
+
448
+ // src/core/lint.ts
449
+ function toPosix(p) {
450
+ if (sep === "/") return p;
451
+ return p.split(sep).join("/");
452
+ }
453
+ function lintFiles(opts) {
454
+ const allDiagnostics = [];
455
+ const fileEntries = opts.files.map((absolutePath) => ({
456
+ relativePath: toPosix(relative(opts.cwd, absolutePath)),
457
+ content: readFileSync3(absolutePath, "utf8")
458
+ }));
459
+ const perFileRules = [];
460
+ const projectRules = [];
461
+ for (const rule9 of opts.rules) {
462
+ if (isProjectRule(rule9)) projectRules.push(rule9);
463
+ else perFileRules.push(rule9);
464
+ }
465
+ const optionsByRule = extractOptionsByRule(opts.ruleSeverity);
466
+ let manifest = null;
467
+ let filesFromCache = 0;
468
+ if (opts.cache) {
469
+ const path = manifestPath(opts.cache.dir);
470
+ const loaded = loadManifest(path);
471
+ if (loaded && loaded.version === opts.cache.pkgVersion && loaded.configHash === opts.cache.configHash) {
472
+ manifest = loaded;
473
+ } else {
474
+ manifest = {
475
+ version: opts.cache.pkgVersion,
476
+ configHash: opts.cache.configHash,
477
+ files: {}
478
+ };
479
+ }
480
+ }
481
+ for (const file of fileEntries) {
482
+ if (manifest) {
483
+ const contentHash = computeContentHash(file.content);
484
+ const entry = manifest.files[file.relativePath];
485
+ if (entry && entry.contentHash === contentHash) {
486
+ allDiagnostics.push(...entry.perFileDiagnostics);
487
+ filesFromCache++;
488
+ continue;
489
+ }
490
+ const fileDiagnostics = runPerFileRulesForFile(
491
+ perFileRules,
492
+ opts.ruleSeverity,
493
+ optionsByRule,
494
+ file
495
+ );
496
+ allDiagnostics.push(...fileDiagnostics);
497
+ const next = {
498
+ contentHash,
499
+ perFileDiagnostics: fileDiagnostics
500
+ };
501
+ manifest.files[file.relativePath] = next;
502
+ } else {
503
+ allDiagnostics.push(
504
+ ...runPerFileRulesForFile(
505
+ perFileRules,
506
+ opts.ruleSeverity,
507
+ optionsByRule,
508
+ file
509
+ )
510
+ );
511
+ }
512
+ }
513
+ const enabledProjectRules = projectRules.filter(
514
+ (rule9) => resolveSeverity(opts.ruleSeverity[rule9.meta.name]) !== "off"
515
+ );
516
+ if (enabledProjectRules.length > 0) {
517
+ const projectFiles = fileEntries.map(
518
+ (f) => buildProjectFile({ path: f.relativePath, content: f.content })
519
+ );
520
+ const projectRoot = resolve2(opts.cwd);
521
+ const existsCache = /* @__PURE__ */ new Map();
522
+ const fileExists = (resolvedPath) => {
523
+ const cached = existsCache.get(resolvedPath);
524
+ if (cached !== void 0) return cached;
525
+ const abs = resolve2(projectRoot, resolvedPath);
526
+ const rel = relative(projectRoot, abs);
527
+ const escapesRoot = rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
528
+ let result;
529
+ if (escapesRoot) {
530
+ result = false;
531
+ } else {
532
+ try {
533
+ result = statSync2(abs, { throwIfNoEntry: false })?.isFile() ?? false;
534
+ } catch {
535
+ result = false;
536
+ }
537
+ }
538
+ existsCache.set(resolvedPath, result);
539
+ return result;
540
+ };
541
+ const errorRules = [];
542
+ const warnRules = [];
543
+ for (const rule9 of enabledProjectRules) {
544
+ const sev = resolveSeverity(opts.ruleSeverity[rule9.meta.name]);
545
+ if (sev === "error") errorRules.push(rule9);
546
+ else if (sev === "warn") warnRules.push(rule9);
547
+ }
548
+ if (errorRules.length > 0) {
549
+ allDiagnostics.push(
550
+ ...runRulesOnProject(errorRules, projectFiles, {
551
+ severity: "error",
552
+ fileExists,
553
+ optionsByRule
554
+ })
555
+ );
556
+ }
557
+ if (warnRules.length > 0) {
558
+ allDiagnostics.push(
559
+ ...runRulesOnProject(warnRules, projectFiles, {
560
+ severity: "warn",
561
+ fileExists,
562
+ optionsByRule
563
+ })
564
+ );
565
+ }
566
+ }
567
+ if (manifest && opts.cache) {
568
+ saveManifest(manifestPath(opts.cache.dir), manifest);
569
+ }
570
+ return {
571
+ filesChecked: opts.files.length,
572
+ filesFromCache,
573
+ diagnostics: allDiagnostics
574
+ };
575
+ }
576
+ function runPerFileRulesForFile(perFileRules, ruleSeverity, optionsByRule, file) {
577
+ const fileContext = {
578
+ content: file.content,
579
+ path: file.relativePath
580
+ };
581
+ const errorRules = [];
582
+ const warnRules = [];
583
+ for (const rule9 of perFileRules) {
584
+ const sev = resolveSeverity(ruleSeverity[rule9.meta.name]);
585
+ if (sev === "off") continue;
586
+ if (sev === "error") errorRules.push(rule9);
587
+ else warnRules.push(rule9);
588
+ }
589
+ const diagnostics = [];
590
+ if (errorRules.length > 0) {
591
+ diagnostics.push(
592
+ ...runRulesOnFile(errorRules, fileContext, {
593
+ severity: "error",
594
+ optionsByRule
595
+ })
596
+ );
597
+ }
598
+ if (warnRules.length > 0) {
599
+ diagnostics.push(
600
+ ...runRulesOnFile(warnRules, fileContext, {
601
+ severity: "warn",
602
+ optionsByRule
603
+ })
604
+ );
605
+ }
606
+ return diagnostics;
607
+ }
608
+ function resolveSeverity(config) {
609
+ if (config === void 0) return "off";
610
+ if (typeof config === "string") return config;
611
+ return config[0];
612
+ }
613
+ function extractOptionsByRule(ruleSeverity) {
614
+ const map = {};
615
+ for (const [name, config] of Object.entries(ruleSeverity)) {
616
+ if (Array.isArray(config) && config[1]) map[name] = config[1];
617
+ }
618
+ return map;
619
+ }
620
+
621
+ // src/core/reporter.ts
622
+ import pc from "picocolors";
623
+ var textReporter = {
624
+ format(diagnostics, rulesByName) {
625
+ if (diagnostics.length === 0) {
626
+ return pc.green("\u2713 All clear.");
627
+ }
628
+ const byFile = groupByFile(diagnostics);
629
+ const lines = [];
630
+ for (const [file, fileDiagnostics] of byFile) {
631
+ lines.push(pc.underline(file));
632
+ for (const d of fileDiagnostics) {
633
+ const sev = d.severity === "error" ? pc.red(" error ") : pc.yellow(" warn ");
634
+ const ruleId = pc.dim(d.ruleName.padEnd(28));
635
+ const message = renderMessage(d, rulesByName);
636
+ lines.push(`${sev}${ruleId} ${message}`);
637
+ }
638
+ lines.push("");
639
+ }
640
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
641
+ const warnings = diagnostics.filter((d) => d.severity === "warn").length;
642
+ lines.push(pc.bold(formatSummary(errors, warnings)));
643
+ return lines.join("\n");
644
+ }
645
+ };
646
+ function groupByFile(diagnostics) {
647
+ const map = /* @__PURE__ */ new Map();
648
+ for (const d of diagnostics) {
649
+ if (!map.has(d.path)) map.set(d.path, []);
650
+ map.get(d.path)?.push(d);
651
+ }
652
+ return map;
653
+ }
654
+ function renderMessage(d, rulesByName) {
655
+ const rule9 = rulesByName.get(d.ruleName);
656
+ const template = rule9?.meta.messages[d.messageId] ?? d.messageId;
657
+ return interpolate(template, d.data ?? {});
658
+ }
659
+ function interpolate(template, data) {
660
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
661
+ const v = data[key];
662
+ return v !== void 0 ? String(v) : `{{${key}}}`;
663
+ });
664
+ }
665
+ function formatSummary(errors, warnings) {
666
+ const parts = [];
667
+ if (errors > 0) parts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
668
+ if (warnings > 0) parts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
669
+ return parts.join(", ");
670
+ }
671
+ var jsonReporter = {
672
+ format(diagnostics, rulesByName) {
673
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
674
+ const warnings = diagnostics.filter((d) => d.severity === "warn").length;
675
+ const payload = {
676
+ version: 1,
677
+ summary: {
678
+ total: diagnostics.length,
679
+ errors,
680
+ warnings
681
+ },
682
+ results: diagnostics.map((d) => ({
683
+ path: d.path,
684
+ ruleName: d.ruleName,
685
+ messageId: d.messageId,
686
+ severity: d.severity,
687
+ message: renderMessage(d, rulesByName),
688
+ data: d.data ?? {}
689
+ }))
690
+ };
691
+ return JSON.stringify(payload, null, 2);
692
+ }
693
+ };
694
+ var sarifReporter = {
695
+ format(diagnostics, rulesByName) {
696
+ const ruleNames = [...new Set(diagnostics.map((d) => d.ruleName))];
697
+ const ruleIndex = new Map(ruleNames.map((name, i) => [name, i]));
698
+ const sarifRules = ruleNames.map((name) => {
699
+ const rule9 = rulesByName.get(name);
700
+ return {
701
+ id: name,
702
+ name: name.replace(/[^A-Za-z0-9]/g, "_"),
703
+ shortDescription: { text: rule9?.meta.docs.description ?? name },
704
+ helpUri: rule9?.meta.docs.url
705
+ };
706
+ });
707
+ const sarifResults = diagnostics.map((d) => ({
708
+ ruleId: d.ruleName,
709
+ ruleIndex: ruleIndex.get(d.ruleName) ?? 0,
710
+ level: d.severity === "error" ? "error" : "warning",
711
+ message: { text: renderMessage(d, rulesByName) },
712
+ locations: [
713
+ {
714
+ physicalLocation: {
715
+ artifactLocation: { uri: d.path, uriBaseId: "%SRCROOT%" },
716
+ region: { startLine: 1 }
717
+ }
718
+ }
719
+ ]
720
+ }));
721
+ const sarif = {
722
+ $schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
723
+ version: "2.1.0",
724
+ runs: [
725
+ {
726
+ tool: {
727
+ driver: {
728
+ name: "madr-lint",
729
+ informationUri: "https://github.com/knktkc/madr-lint",
730
+ rules: sarifRules
731
+ }
732
+ },
733
+ results: sarifResults
734
+ }
735
+ ]
736
+ };
737
+ return JSON.stringify(sarif, null, 2);
738
+ }
739
+ };
740
+ var reporters = {
741
+ text: textReporter,
742
+ json: jsonReporter,
743
+ sarif: sarifReporter
744
+ };
745
+
746
+ // src/rules/index.ts
747
+ var rules_exports = {};
748
+ __export(rules_exports, {
749
+ dateIso8601: () => date_iso8601_default,
750
+ filenameFormat: () => filename_format_default,
751
+ noBrokenLinks: () => no_broken_links_default,
752
+ noDuplicateNumbering: () => no_duplicate_numbering_default,
753
+ noNumberingGap: () => no_numbering_gap_default,
754
+ requiredSections: () => required_sections_default,
755
+ statusEnum: () => status_enum_default,
756
+ supersedesBidirectional: () => supersedes_bidirectional_default
757
+ });
758
+
759
+ // src/rules/date-iso8601/schema.json
760
+ var schema_default = {
761
+ $schema: "http://json-schema.org/draft-07/schema#",
762
+ type: "object",
763
+ additionalProperties: false,
764
+ properties: {
765
+ field: {
766
+ type: "string",
767
+ description: "Frontmatter key to read. Default: 'date'."
768
+ }
769
+ }
770
+ };
771
+
772
+ // src/rules/date-iso8601/index.ts
773
+ function normalizeDate(value) {
774
+ if (typeof value === "string") return value;
775
+ if (value instanceof Date) {
776
+ if (Number.isNaN(value.getTime())) return null;
777
+ return value.toISOString().slice(0, 10);
778
+ }
779
+ return null;
780
+ }
781
+ function isValidIso8601Date(s) {
782
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
783
+ const parts = s.split("-").map(Number);
784
+ const [year, month, day] = parts;
785
+ if (year === void 0 || month === void 0 || day === void 0) return false;
786
+ const d = new Date(Date.UTC(year, month - 1, day));
787
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
788
+ }
789
+ var rule = {
790
+ meta: {
791
+ name: "madr/date-iso8601",
792
+ type: "perFile",
793
+ versionCompat: ["v2", "v3", "v4"],
794
+ docs: {
795
+ description: "Validate that ADR `date` (frontmatter or v2 bold-list) is a valid ISO 8601 calendar date (YYYY-MM-DD)",
796
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/date-iso8601.md",
797
+ recommended: true
798
+ },
799
+ messages: {
800
+ missingDate: 'Metadata does not contain a "{{field}}" field (checked frontmatter and v2 bold-list)',
801
+ invalidDate: 'Date "{{date}}" is not a valid ISO 8601 calendar date (YYYY-MM-DD)'
802
+ },
803
+ defaultOptions: {
804
+ field: "date"
805
+ },
806
+ schema: schema_default
807
+ },
808
+ create(context) {
809
+ const meta = context.metadata;
810
+ const fieldName = context.options.field;
811
+ if (!meta) {
812
+ context.report({
813
+ messageId: "missingDate",
814
+ data: { field: fieldName }
815
+ });
816
+ return;
817
+ }
818
+ const raw = meta[fieldName];
819
+ const dateStr = normalizeDate(raw);
820
+ if (dateStr === null) {
821
+ context.report({
822
+ messageId: "missingDate",
823
+ data: { field: fieldName }
824
+ });
825
+ return;
826
+ }
827
+ if (!isValidIso8601Date(dateStr)) {
828
+ context.report({
829
+ messageId: "invalidDate",
830
+ data: { date: dateStr }
831
+ });
832
+ }
833
+ }
834
+ };
835
+ var date_iso8601_default = rule;
836
+
837
+ // src/rules/filename-format/index.ts
838
+ import { basename as basename2 } from "path";
839
+
840
+ // src/core/regex-safety.ts
841
+ import safeRegex from "safe-regex2";
842
+ var SAFE_REGEX_LIMIT = 25;
843
+ var cache = /* @__PURE__ */ new Map();
844
+ function assertSafeRegex(pattern, ruleName, optionPath) {
845
+ const cached = cache.get(pattern);
846
+ if (cached) return cached;
847
+ let regex;
848
+ try {
849
+ regex = new RegExp(pattern);
850
+ } catch (err) {
851
+ throw new RuleOptionsError(
852
+ ruleName,
853
+ `option "${optionPath}" is not a valid regular expression: ${err.message}`
854
+ );
855
+ }
856
+ if (!safeRegex(regex, { limit: SAFE_REGEX_LIMIT })) {
857
+ throw new RuleOptionsError(
858
+ ruleName,
859
+ `option "${optionPath}" (${pattern}) appears unsafe (catastrophic backtracking). Reject before runtime.`
860
+ );
861
+ }
862
+ cache.set(pattern, regex);
863
+ return regex;
864
+ }
865
+
866
+ // src/rules/filename-format/schema.json
867
+ var schema_default2 = {
868
+ $schema: "http://json-schema.org/draft-07/schema#",
869
+ type: "object",
870
+ additionalProperties: false,
871
+ properties: {
872
+ pattern: {
873
+ type: "string",
874
+ description: "Regex (as string) the basename must match. Defaults to ^[0-9]{4}-[a-z0-9-]+\\.md$"
875
+ }
876
+ }
877
+ };
878
+
879
+ // src/rules/filename-format/index.ts
880
+ var rule2 = {
881
+ meta: {
882
+ name: "madr/filename-format",
883
+ type: "perFile",
884
+ versionCompat: ["v2", "v3", "v4"],
885
+ docs: {
886
+ description: "Enforce ADR filename convention NNNN-kebab-case.md",
887
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/filename-format.md",
888
+ recommended: true
889
+ },
890
+ messages: {
891
+ invalidFilename: 'Filename "{{filename}}" does not match expected pattern "{{expected}}"'
892
+ },
893
+ defaultOptions: {
894
+ pattern: "^[0-9]{4}-[a-z0-9-]+\\.md$"
895
+ },
896
+ schema: schema_default2
897
+ },
898
+ create(context) {
899
+ const { pattern } = context.options;
900
+ const regex = assertSafeRegex(pattern, "madr/filename-format", "pattern");
901
+ const filename = basename2(context.file.path);
902
+ if (!regex.test(filename)) {
903
+ context.report({
904
+ messageId: "invalidFilename",
905
+ data: { filename, expected: pattern }
906
+ });
907
+ }
908
+ }
909
+ };
910
+ var filename_format_default = rule2;
911
+
912
+ // src/rules/no-broken-links/index.ts
913
+ import { dirname as dirname2, posix } from "path";
914
+
915
+ // src/rules/no-broken-links/schema.json
916
+ var schema_default3 = {
917
+ $schema: "http://json-schema.org/draft-07/schema#",
918
+ type: "object",
919
+ additionalProperties: false,
920
+ properties: {}
921
+ };
922
+
923
+ // src/rules/no-broken-links/index.ts
924
+ var PROTOCOL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
925
+ function isLinkNode(node) {
926
+ return node.type === "link" && "url" in node && typeof node.url === "string";
927
+ }
928
+ function isExternalOrAnchor(url) {
929
+ if (url === "" || url.startsWith("#")) return true;
930
+ return PROTOCOL_REGEX.test(url);
931
+ }
932
+ function stripAnchorAndQuery(url) {
933
+ const hash = url.indexOf("#");
934
+ const query = url.indexOf("?");
935
+ let cut = hash;
936
+ if (query !== -1 && (cut === -1 || query < cut)) cut = query;
937
+ return cut === -1 ? url : url.slice(0, cut);
938
+ }
939
+ function safeDecode(path) {
940
+ if (!path.includes("%")) return path;
941
+ try {
942
+ return decodeURIComponent(path);
943
+ } catch {
944
+ return path;
945
+ }
946
+ }
947
+ function resolveRelative(fromDir, url) {
948
+ if (url.startsWith("/")) return url.slice(1);
949
+ return posix.normalize(posix.join(fromDir, url));
950
+ }
951
+ function collectLinks(node, out) {
952
+ if (isLinkNode(node)) out.push(node);
953
+ if ("children" in node && Array.isArray(node.children)) {
954
+ for (const child of node.children) {
955
+ collectLinks(child, out);
956
+ }
957
+ }
958
+ }
959
+ var rule3 = {
960
+ meta: {
961
+ name: "madr/no-broken-links",
962
+ type: "project",
963
+ versionCompat: ["v2", "v3", "v4"],
964
+ docs: {
965
+ description: "Relative-path Markdown links must resolve to an existing file in the project",
966
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/no-broken-links.md",
967
+ recommended: true
968
+ },
969
+ messages: {
970
+ brokenLink: 'Link to "{{url}}" resolves to "{{resolvedPath}}", which does not exist in the project'
971
+ },
972
+ defaultOptions: {},
973
+ schema: schema_default3
974
+ },
975
+ check(context) {
976
+ const knownPaths = /* @__PURE__ */ new Set();
977
+ for (const file of context.files) {
978
+ knownPaths.add(file.path);
979
+ }
980
+ for (const file of context.files) {
981
+ const links = [];
982
+ collectLinks(file.ast, links);
983
+ for (const link of links) {
984
+ const url = link.url;
985
+ if (isExternalOrAnchor(url)) continue;
986
+ const pathOnly = safeDecode(stripAnchorAndQuery(url));
987
+ if (pathOnly === "") continue;
988
+ const fileDir = dirname2(file.path);
989
+ const resolvedPath = resolveRelative(fileDir, pathOnly);
990
+ const exists = knownPaths.has(resolvedPath) || (context.fileExists?.(resolvedPath) ?? false);
991
+ if (!exists) {
992
+ context.report({
993
+ messageId: "brokenLink",
994
+ path: file.path,
995
+ data: { url, resolvedPath }
996
+ });
997
+ }
998
+ }
999
+ }
1000
+ }
1001
+ };
1002
+ var no_broken_links_default = rule3;
1003
+
1004
+ // src/rules/no-duplicate-numbering/index.ts
1005
+ import { basename as basename3 } from "path";
1006
+
1007
+ // src/rules/no-duplicate-numbering/schema.json
1008
+ var schema_default4 = {
1009
+ $schema: "http://json-schema.org/draft-07/schema#",
1010
+ type: "object",
1011
+ additionalProperties: false,
1012
+ properties: {}
1013
+ };
1014
+
1015
+ // src/rules/no-duplicate-numbering/index.ts
1016
+ var NUMBER_REGEX = /^(\d{4})-/;
1017
+ var rule4 = {
1018
+ meta: {
1019
+ name: "madr/no-duplicate-numbering",
1020
+ type: "project",
1021
+ versionCompat: ["v2", "v3", "v4"],
1022
+ docs: {
1023
+ description: "No two ADRs share the same NNNN- number prefix",
1024
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/no-duplicate-numbering.md",
1025
+ recommended: true
1026
+ },
1027
+ messages: {
1028
+ duplicateNumber: "ADR number {{number}} is used by multiple files: {{paths}}"
1029
+ },
1030
+ defaultOptions: {},
1031
+ schema: schema_default4
1032
+ },
1033
+ check(context) {
1034
+ const byNumber = /* @__PURE__ */ new Map();
1035
+ for (const file of context.files) {
1036
+ const match = NUMBER_REGEX.exec(basename3(file.path));
1037
+ if (!match) continue;
1038
+ const number = match[1];
1039
+ if (number === void 0) continue;
1040
+ const group = byNumber.get(number) ?? [];
1041
+ group.push(file.path);
1042
+ byNumber.set(number, group);
1043
+ }
1044
+ for (const [number, paths] of byNumber) {
1045
+ if (paths.length > 1) {
1046
+ const pathList = paths.join(", ");
1047
+ for (const path of paths) {
1048
+ context.report({
1049
+ messageId: "duplicateNumber",
1050
+ path,
1051
+ data: { number, paths: pathList }
1052
+ });
1053
+ }
1054
+ }
1055
+ }
1056
+ }
1057
+ };
1058
+ var no_duplicate_numbering_default = rule4;
1059
+
1060
+ // src/rules/no-numbering-gap/index.ts
1061
+ import { basename as basename4 } from "path";
1062
+
1063
+ // src/rules/no-numbering-gap/schema.json
1064
+ var schema_default5 = {
1065
+ $schema: "http://json-schema.org/draft-07/schema#",
1066
+ type: "object",
1067
+ additionalProperties: false,
1068
+ properties: {}
1069
+ };
1070
+
1071
+ // src/rules/no-numbering-gap/index.ts
1072
+ var NUMBER_REGEX2 = /^(\d{4})-/;
1073
+ function format4(n) {
1074
+ return n.toString().padStart(4, "0");
1075
+ }
1076
+ var rule5 = {
1077
+ meta: {
1078
+ name: "madr/no-numbering-gap",
1079
+ type: "project",
1080
+ versionCompat: ["v2", "v3", "v4"],
1081
+ docs: {
1082
+ description: "Detect gaps in ADR numbering (e.g. 0001 and 0003 exist but 0002 is missing). Convention-only; default off.",
1083
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/no-numbering-gap.md",
1084
+ recommended: false
1085
+ },
1086
+ messages: {
1087
+ numberingGap: "Numbering gap: missing {{missing}} between ADR-{{from}} and ADR-{{to}}"
1088
+ },
1089
+ defaultOptions: {},
1090
+ schema: schema_default5
1091
+ },
1092
+ check(context) {
1093
+ const numberToPath = /* @__PURE__ */ new Map();
1094
+ for (const file of context.files) {
1095
+ const match = NUMBER_REGEX2.exec(basename4(file.path));
1096
+ if (!match?.[1]) continue;
1097
+ const n = Number.parseInt(match[1], 10);
1098
+ if (!Number.isNaN(n)) numberToPath.set(n, file.path);
1099
+ }
1100
+ if (numberToPath.size < 2) return;
1101
+ const sortedNumbers = Array.from(numberToPath.keys()).toSorted(
1102
+ (a, b) => a - b
1103
+ );
1104
+ for (let i = 1; i < sortedNumbers.length; i++) {
1105
+ const prev = sortedNumbers[i - 1];
1106
+ const curr = sortedNumbers[i];
1107
+ if (prev === void 0 || curr === void 0) continue;
1108
+ if (curr === prev + 1) continue;
1109
+ const missingNumbers = [];
1110
+ for (let n = prev + 1; n < curr; n++) {
1111
+ missingNumbers.push(format4(n));
1112
+ }
1113
+ const currPath = numberToPath.get(curr);
1114
+ if (!currPath) continue;
1115
+ context.report({
1116
+ messageId: "numberingGap",
1117
+ path: currPath,
1118
+ data: {
1119
+ from: format4(prev),
1120
+ to: format4(curr),
1121
+ missing: missingNumbers.join(", ")
1122
+ }
1123
+ });
1124
+ }
1125
+ }
1126
+ };
1127
+ var no_numbering_gap_default = rule5;
1128
+
1129
+ // src/rules/required-sections/index.ts
1130
+ import { toString as toString2 } from "mdast-util-to-string";
1131
+
1132
+ // src/rules/required-sections/schema.json
1133
+ var schema_default6 = {
1134
+ $schema: "http://json-schema.org/draft-07/schema#",
1135
+ type: "object",
1136
+ additionalProperties: false,
1137
+ properties: {
1138
+ sections: {
1139
+ type: "array",
1140
+ items: { type: "string" },
1141
+ description: "Required heading texts. Order does not matter."
1142
+ },
1143
+ matchMode: {
1144
+ type: "string",
1145
+ enum: ["exact", "startsWith"],
1146
+ description: "Comparison mode against heading text."
1147
+ }
1148
+ }
1149
+ };
1150
+
1151
+ // src/rules/required-sections/index.ts
1152
+ var rule6 = {
1153
+ meta: {
1154
+ name: "madr/required-sections",
1155
+ type: "perFile",
1156
+ versionCompat: ["v2", "v3", "v4"],
1157
+ docs: {
1158
+ description: "Enforce that ADRs contain required heading sections",
1159
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/required-sections.md",
1160
+ recommended: true
1161
+ },
1162
+ messages: {
1163
+ missingSection: 'Missing required section: "{{section}}"'
1164
+ },
1165
+ defaultOptions: {
1166
+ sections: [
1167
+ "Context and Problem Statement",
1168
+ "Decision Outcome",
1169
+ "Consequences"
1170
+ ],
1171
+ matchMode: "exact"
1172
+ },
1173
+ schema: schema_default6
1174
+ },
1175
+ create(context) {
1176
+ const seen = [];
1177
+ return {
1178
+ enter: {
1179
+ heading(node) {
1180
+ if (node.type !== "heading") return;
1181
+ seen.push(toString2(node).trim());
1182
+ }
1183
+ },
1184
+ exit: {
1185
+ root() {
1186
+ const { sections, matchMode } = context.options;
1187
+ for (const section of sections) {
1188
+ const matched = seen.some(
1189
+ (heading) => matchMode === "startsWith" ? heading.startsWith(section) : heading === section
1190
+ );
1191
+ if (!matched) {
1192
+ context.report({
1193
+ messageId: "missingSection",
1194
+ data: { section, found: [...seen] }
1195
+ });
1196
+ }
1197
+ }
1198
+ }
1199
+ }
1200
+ };
1201
+ }
1202
+ };
1203
+ var required_sections_default = rule6;
1204
+
1205
+ // src/rules/status-enum/schema.json
1206
+ var schema_default7 = {
1207
+ $schema: "http://json-schema.org/draft-07/schema#",
1208
+ type: "object",
1209
+ additionalProperties: false,
1210
+ properties: {
1211
+ values: {
1212
+ type: "array",
1213
+ items: { type: "string" },
1214
+ description: "Exact-match allowed status values."
1215
+ },
1216
+ prefixValues: {
1217
+ type: "array",
1218
+ items: { type: "string" },
1219
+ description: "startsWith-match allowed prefixes (e.g. 'superseded by')."
1220
+ },
1221
+ caseSensitive: {
1222
+ type: "boolean",
1223
+ description: "When false, comparisons are case-insensitive."
1224
+ }
1225
+ }
1226
+ };
1227
+
1228
+ // src/rules/status-enum/index.ts
1229
+ var rule7 = {
1230
+ meta: {
1231
+ name: "madr/status-enum",
1232
+ type: "perFile",
1233
+ versionCompat: ["v2", "v3", "v4"],
1234
+ docs: {
1235
+ description: "Validate that ADR `status` (frontmatter or v2 bold-list) is in the allowed enum",
1236
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/status-enum.md",
1237
+ recommended: true
1238
+ },
1239
+ messages: {
1240
+ missingStatus: 'Metadata does not contain a "status" field (checked frontmatter and v2 bold-list)',
1241
+ invalidStatus: 'Status "{{status}}" is not one of: {{allowed}}'
1242
+ },
1243
+ defaultOptions: {
1244
+ values: ["proposed", "rejected", "accepted", "deprecated"],
1245
+ prefixValues: ["superseded by"],
1246
+ caseSensitive: false
1247
+ },
1248
+ schema: schema_default7
1249
+ },
1250
+ create(context) {
1251
+ const meta = context.metadata;
1252
+ if (!meta || typeof meta.status !== "string") {
1253
+ context.report({ messageId: "missingStatus", data: {} });
1254
+ return;
1255
+ }
1256
+ const { values, prefixValues, caseSensitive } = context.options;
1257
+ const status = meta.status;
1258
+ const compareStatus = caseSensitive ? status : status.toLowerCase();
1259
+ const compareValues = caseSensitive ? values : values.map((v) => v.toLowerCase());
1260
+ const comparePrefixes = caseSensitive ? prefixValues : prefixValues.map((p) => p.toLowerCase());
1261
+ const exactMatch = compareValues.includes(compareStatus);
1262
+ const prefixMatch = comparePrefixes.some(
1263
+ (p) => compareStatus.startsWith(p)
1264
+ );
1265
+ if (!exactMatch && !prefixMatch) {
1266
+ const allowed = [
1267
+ ...values,
1268
+ ...prefixValues.map((p) => `${p} ...`)
1269
+ ];
1270
+ context.report({
1271
+ messageId: "invalidStatus",
1272
+ data: { status, allowed }
1273
+ });
1274
+ }
1275
+ }
1276
+ };
1277
+ var status_enum_default = rule7;
1278
+
1279
+ // src/rules/supersedes-bidirectional/index.ts
1280
+ import { basename as basename5 } from "path";
1281
+
1282
+ // src/rules/supersedes-bidirectional/schema.json
1283
+ var schema_default8 = {
1284
+ $schema: "http://json-schema.org/draft-07/schema#",
1285
+ type: "object",
1286
+ additionalProperties: false,
1287
+ properties: {}
1288
+ };
1289
+
1290
+ // src/rules/supersedes-bidirectional/index.ts
1291
+ var ADR_NUMBER_REGEX = /^(\d{4})-/;
1292
+ var FORWARD_FIELD = "supersedes";
1293
+ var BACKWARD_FIELD = "superseded-by";
1294
+ function extractAdrNumber(path) {
1295
+ const match = ADR_NUMBER_REGEX.exec(basename5(path));
1296
+ return match?.[1] ?? null;
1297
+ }
1298
+ function normalizeRefs(value) {
1299
+ if (typeof value === "string") return [value];
1300
+ if (Array.isArray(value)) {
1301
+ return value.filter((v) => typeof v === "string");
1302
+ }
1303
+ return [];
1304
+ }
1305
+ var rule8 = {
1306
+ meta: {
1307
+ name: "madr/supersedes-bidirectional",
1308
+ type: "project",
1309
+ versionCompat: ["v3", "v4"],
1310
+ docs: {
1311
+ description: "Frontmatter `supersedes` and `superseded-by` must reference each other bidirectionally",
1312
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/supersedes-bidirectional.md",
1313
+ recommended: true
1314
+ },
1315
+ messages: {
1316
+ unknownReference: "Frontmatter `{{direction}}: {{ref}}` references an ADR that does not exist",
1317
+ missingBackReference: "`{{source}}` declares `{{direction}}: {{ref}}`, but `{{ref}}` (this file) does not back-reference it via `{{expected}}`"
1318
+ },
1319
+ defaultOptions: {},
1320
+ schema: schema_default8
1321
+ },
1322
+ check(context) {
1323
+ const adrIndex = /* @__PURE__ */ new Map();
1324
+ for (const file of context.files) {
1325
+ const num = extractAdrNumber(file.path);
1326
+ if (num) adrIndex.set(`ADR-${num}`, file);
1327
+ }
1328
+ for (const file of context.files) {
1329
+ const fm = file.frontmatter;
1330
+ if (!fm) continue;
1331
+ const fileNumber = extractAdrNumber(file.path);
1332
+ if (!fileNumber) continue;
1333
+ const fileRef = `ADR-${fileNumber}`;
1334
+ const forwardRefs = normalizeRefs(fm[FORWARD_FIELD]);
1335
+ const backwardRefs = normalizeRefs(fm[BACKWARD_FIELD]);
1336
+ for (const ref of forwardRefs) {
1337
+ const target = adrIndex.get(ref);
1338
+ if (!target) {
1339
+ context.report({
1340
+ messageId: "unknownReference",
1341
+ path: file.path,
1342
+ data: { ref, direction: FORWARD_FIELD }
1343
+ });
1344
+ continue;
1345
+ }
1346
+ const targetBack = normalizeRefs(target.frontmatter?.[BACKWARD_FIELD]);
1347
+ if (!targetBack.includes(fileRef)) {
1348
+ context.report({
1349
+ messageId: "missingBackReference",
1350
+ path: target.path,
1351
+ data: {
1352
+ ref,
1353
+ source: file.path,
1354
+ direction: BACKWARD_FIELD,
1355
+ expected: fileRef
1356
+ }
1357
+ });
1358
+ }
1359
+ }
1360
+ for (const ref of backwardRefs) {
1361
+ const target = adrIndex.get(ref);
1362
+ if (!target) {
1363
+ context.report({
1364
+ messageId: "unknownReference",
1365
+ path: file.path,
1366
+ data: { ref, direction: BACKWARD_FIELD }
1367
+ });
1368
+ continue;
1369
+ }
1370
+ const targetFwd = normalizeRefs(target.frontmatter?.[FORWARD_FIELD]);
1371
+ if (!targetFwd.includes(fileRef)) {
1372
+ context.report({
1373
+ messageId: "missingBackReference",
1374
+ path: target.path,
1375
+ data: {
1376
+ ref,
1377
+ source: file.path,
1378
+ direction: FORWARD_FIELD,
1379
+ expected: fileRef
1380
+ }
1381
+ });
1382
+ }
1383
+ }
1384
+ }
1385
+ }
1386
+ };
1387
+ var supersedes_bidirectional_default = rule8;
1388
+
1389
+ // src/cli.ts
1390
+ function toPosix2(p) {
1391
+ if (sep2 === "/") return p;
1392
+ return p.split(sep2).join("/");
1393
+ }
1394
+ var pkg = JSON.parse(
1395
+ readFileSync4(new URL("../package.json", import.meta.url), "utf8")
1396
+ );
1397
+ var main = defineCommand({
1398
+ meta: {
1399
+ name: "madr-lint",
1400
+ version: pkg.version,
1401
+ description: "A linter for MADR (Markdown Architectural Decision Records)"
1402
+ },
1403
+ args: {
1404
+ paths: {
1405
+ type: "positional",
1406
+ description: "One or more paths (files or directories). Defaults to config.adrDir",
1407
+ required: false
1408
+ },
1409
+ cache: {
1410
+ type: "boolean",
1411
+ description: "Use per-file content-hash cache (use --no-cache to disable)",
1412
+ default: true
1413
+ },
1414
+ "cache-dir": {
1415
+ type: "string",
1416
+ description: "Cache directory (default: .madr-lint/cache)",
1417
+ required: false
1418
+ },
1419
+ format: {
1420
+ type: "string",
1421
+ description: "Reporter format: text (default), json, or sarif",
1422
+ default: "text"
1423
+ }
1424
+ },
1425
+ run({ args }) {
1426
+ const cwd = process.cwd();
1427
+ let config = loadConfig(cwd);
1428
+ if (Object.keys(config.rules).length === 0) {
1429
+ config = resolveExtends({ extends: ["madr-lint:recommended"] });
1430
+ }
1431
+ const positionals = args._ ?? [];
1432
+ const inputs = positionals.length > 0 ? positionals : [config.adrDir];
1433
+ const seen = /* @__PURE__ */ new Set();
1434
+ const allFiles = [];
1435
+ for (const input of inputs) {
1436
+ for (const file of findAdrFiles(input)) {
1437
+ if (!seen.has(file)) {
1438
+ seen.add(file);
1439
+ allFiles.push(file);
1440
+ }
1441
+ }
1442
+ }
1443
+ const files = allFiles.filter(
1444
+ (absPath) => !shouldIgnore(toPosix2(relative2(cwd, absPath)), config.ignorePatterns)
1445
+ );
1446
+ if (files.length === 0) {
1447
+ const skipped = allFiles.length - files.length;
1448
+ const note = skipped > 0 ? ` (${skipped} ignored by config)` : "";
1449
+ const where = inputs.length === 1 ? inputs[0] : inputs.join(", ");
1450
+ console.log(`No .md files to lint in ${where}${note}`);
1451
+ process.exit(0);
1452
+ }
1453
+ const cacheEnabled = (args.cache ?? config.cache) === true;
1454
+ const cacheDir = args["cache-dir"] ?? config.cacheLocation;
1455
+ const cacheConfig = cacheEnabled ? {
1456
+ dir: resolve3(cwd, cacheDir),
1457
+ configHash: computeConfigHash({
1458
+ rules: config.rules,
1459
+ ignorePatterns: config.ignorePatterns,
1460
+ madrVersion: config.madrVersion
1461
+ }),
1462
+ pkgVersion: pkg.version
1463
+ } : null;
1464
+ const allRulesArray = Object.values(rules_exports);
1465
+ let result;
1466
+ try {
1467
+ result = lintFiles({
1468
+ rules: allRulesArray,
1469
+ ruleSeverity: config.rules,
1470
+ files,
1471
+ cwd,
1472
+ cache: cacheConfig
1473
+ });
1474
+ } catch (err) {
1475
+ if (err instanceof RuleOptionsError) {
1476
+ console.error(`Invalid rule options in config: ${err.message}`);
1477
+ process.exit(2);
1478
+ }
1479
+ throw err;
1480
+ }
1481
+ const rulesByName = new Map(
1482
+ allRulesArray.map((r) => [r.meta.name, r])
1483
+ );
1484
+ const format = args.format ?? "text";
1485
+ const reporter = reporters[format];
1486
+ if (!reporter) {
1487
+ console.error(
1488
+ `Unknown --format "${format}". Available: ${Object.keys(reporters).join(", ")}`
1489
+ );
1490
+ process.exit(2);
1491
+ }
1492
+ console.log(reporter.format(result.diagnostics, rulesByName));
1493
+ const errorCount = result.diagnostics.filter(
1494
+ (d) => d.severity === "error"
1495
+ ).length;
1496
+ process.exit(errorCount > 0 ? 1 : 0);
1497
+ }
1498
+ });
1499
+ await runMain(main);
1500
+ //# sourceMappingURL=cli.js.map