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/index.js ADDED
@@ -0,0 +1,975 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/core/runner.ts
8
+ import Ajv from "ajv";
9
+
10
+ // src/core/parser.ts
11
+ import { fromMarkdown } from "mdast-util-from-markdown";
12
+ import { toString } from "mdast-util-to-string";
13
+ import grayMatter from "gray-matter";
14
+ function parseFile(content) {
15
+ const matter = grayMatter(content);
16
+ const data = matter.data;
17
+ const frontmatter = Object.keys(data).length > 0 ? data : null;
18
+ const ast = fromMarkdown(matter.content);
19
+ const listMetadata = extractListMetadata(ast);
20
+ let metadata = null;
21
+ if (frontmatter && listMetadata) {
22
+ const frontmatterDefined = {};
23
+ for (const [k, v] of Object.entries(frontmatter)) {
24
+ if (v !== null && v !== void 0) frontmatterDefined[k] = v;
25
+ }
26
+ metadata = { ...listMetadata, ...frontmatterDefined };
27
+ } else if (frontmatter) {
28
+ metadata = frontmatter;
29
+ } else if (listMetadata) {
30
+ metadata = listMetadata;
31
+ }
32
+ return { frontmatter, listMetadata, metadata, ast, body: matter.content };
33
+ }
34
+ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
35
+ "status",
36
+ "date",
37
+ "deciders",
38
+ "decision-makers",
39
+ "consulted",
40
+ "informed"
41
+ ]);
42
+ function extractListMetadata(ast) {
43
+ let firstList = null;
44
+ for (const child of ast.children) {
45
+ if (child.type === "heading") {
46
+ if (child.depth >= 2) break;
47
+ continue;
48
+ }
49
+ if (child.type === "html") continue;
50
+ if (child.type === "list") {
51
+ firstList = child;
52
+ break;
53
+ }
54
+ break;
55
+ }
56
+ if (!firstList) return null;
57
+ const result = {};
58
+ for (const item of firstList.children) {
59
+ const pair = extractListItemKV(item);
60
+ if (pair && !(pair.key in result)) {
61
+ result[pair.key] = pair.value;
62
+ }
63
+ }
64
+ const hasRecognizedKey = Object.keys(result).some(
65
+ (k) => RECOGNIZED_METADATA_KEYS.has(k)
66
+ );
67
+ return hasRecognizedKey ? result : null;
68
+ }
69
+ var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
70
+ var SEPARATOR_PATTERN = /^\s*:\s*/;
71
+ function normalizeKey(rawKey) {
72
+ return rawKey.trim().toLowerCase().replace(/\s+/g, "-");
73
+ }
74
+ function extractListItemKV(item) {
75
+ const para = item.children[0];
76
+ if (para?.type !== "paragraph") return null;
77
+ const paragraph = para;
78
+ const first = paragraph.children[0];
79
+ if (!first) return null;
80
+ if (first.type === "strong") {
81
+ const keyNode = first.children[0];
82
+ if (keyNode?.type !== "text" || !KEY_PATTERN.test(keyNode.value)) {
83
+ return null;
84
+ }
85
+ const sep = paragraph.children[1];
86
+ if (sep?.type !== "text" || !SEPARATOR_PATTERN.test(sep.value)) return null;
87
+ const valueHead = sep.value.replace(SEPARATOR_PATTERN, "");
88
+ const valueTail = paragraph.children.slice(2).map((n) => toString(n)).join("");
89
+ const value = (valueHead + valueTail).trim();
90
+ if (value === "") return null;
91
+ return { key: normalizeKey(keyNode.value), value };
92
+ }
93
+ if (first.type === "text") {
94
+ const colonIdx = first.value.indexOf(":");
95
+ if (colonIdx === -1) return null;
96
+ const rawKey = first.value.slice(0, colonIdx);
97
+ if (!KEY_PATTERN.test(rawKey)) return null;
98
+ const valueHead = first.value.slice(colonIdx + 1).replace(/^\s+/, "");
99
+ const valueTail = paragraph.children.slice(1).map((n) => toString(n)).join("");
100
+ const value = (valueHead + valueTail).trim();
101
+ if (value === "") return null;
102
+ return { key: normalizeKey(rawKey), value };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // src/core/runner.ts
108
+ var ajv = new Ajv({ strict: true, allErrors: true });
109
+ var validatorCache = /* @__PURE__ */ new WeakMap();
110
+ function getValidator(schema) {
111
+ let validator = validatorCache.get(schema);
112
+ if (!validator) {
113
+ validator = ajv.compile(schema);
114
+ validatorCache.set(schema, validator);
115
+ }
116
+ return validator;
117
+ }
118
+ var INTERNAL_ERROR_RULE_NAME = "core/internal-error";
119
+ var RuleOptionsError = class extends Error {
120
+ constructor(ruleName, ajvErrors) {
121
+ super(`Invalid options for rule ${ruleName}: ${ajvErrors}`);
122
+ this.ruleName = ruleName;
123
+ this.ajvErrors = ajvErrors;
124
+ this.name = "RuleOptionsError";
125
+ }
126
+ ruleName;
127
+ ajvErrors;
128
+ };
129
+ function runRule(rule9, file, runtime = {}) {
130
+ return runRulesOnFile([rule9], file, runtime);
131
+ }
132
+ function runRulesOnFile(rules, file, runtime = {}) {
133
+ const diagnostics = [];
134
+ const severity = runtime.severity ?? "error";
135
+ let parsed = null;
136
+ const ensureParsed = () => {
137
+ parsed ??= parseFile(file.content);
138
+ return parsed;
139
+ };
140
+ const enterMap = {};
141
+ const exitMap = {};
142
+ let hasASTRule = false;
143
+ for (const rule9 of rules) {
144
+ const perRuleOptions = runtime.optionsByRule?.[rule9.meta.name] ?? runtime.options;
145
+ const mergedOptions = {
146
+ ...rule9.meta.defaultOptions,
147
+ ...perRuleOptions
148
+ };
149
+ if (rule9.meta.schema && !runtime.skipValidation) {
150
+ const validator = getValidator(rule9.meta.schema);
151
+ if (!validator(mergedOptions)) {
152
+ throw new RuleOptionsError(rule9.meta.name, ajv.errorsText(validator.errors));
153
+ }
154
+ }
155
+ const context = {
156
+ file,
157
+ get frontmatter() {
158
+ return ensureParsed().frontmatter;
159
+ },
160
+ get metadata() {
161
+ return ensureParsed().metadata;
162
+ },
163
+ options: mergedOptions,
164
+ report(d) {
165
+ diagnostics.push({
166
+ ruleName: rule9.meta.name,
167
+ severity,
168
+ path: file.path,
169
+ ...d
170
+ });
171
+ }
172
+ };
173
+ let listeners;
174
+ try {
175
+ listeners = rule9.create(context);
176
+ } catch (err) {
177
+ if (err instanceof RuleOptionsError) throw err;
178
+ diagnostics.push(
179
+ internalErrorDiagnostic(rule9.meta.name, "create", err, file.path)
180
+ );
181
+ continue;
182
+ }
183
+ if (listeners) {
184
+ hasASTRule = true;
185
+ collectListeners(rule9, listeners, enterMap, exitMap, diagnostics, file);
186
+ }
187
+ }
188
+ if (hasASTRule) {
189
+ walk(ensureParsed().ast, enterMap, exitMap);
190
+ }
191
+ return diagnostics;
192
+ }
193
+ function internalErrorDiagnostic(ruleName, operation, err, path) {
194
+ return {
195
+ ruleName: INTERNAL_ERROR_RULE_NAME,
196
+ messageId: "ruleThrew",
197
+ severity: "error",
198
+ path,
199
+ data: {
200
+ rule: ruleName,
201
+ operation,
202
+ error: err instanceof Error ? err.message : String(err)
203
+ }
204
+ };
205
+ }
206
+ function collectListeners(rule9, listeners, enterMap, exitMap, diagnostics, file) {
207
+ const wrap = (op) => (handler) => (node) => {
208
+ try {
209
+ handler(node);
210
+ } catch (err) {
211
+ diagnostics.push(
212
+ internalErrorDiagnostic(rule9.meta.name, op, err, file.path)
213
+ );
214
+ }
215
+ };
216
+ if (listeners.enter) {
217
+ const wrapEnter = wrap("enter");
218
+ for (const [type, handler] of Object.entries(listeners.enter)) {
219
+ if (handler) (enterMap[type] ??= []).push(wrapEnter(handler));
220
+ }
221
+ }
222
+ if (listeners.exit) {
223
+ const wrapExit = wrap("exit");
224
+ for (const [type, handler] of Object.entries(listeners.exit)) {
225
+ if (handler) (exitMap[type] ??= []).push(wrapExit(handler));
226
+ }
227
+ }
228
+ }
229
+ function walk(node, enterMap, exitMap) {
230
+ const enterHandlers = enterMap[node.type];
231
+ if (enterHandlers) {
232
+ for (const handler of enterHandlers) handler(node);
233
+ }
234
+ if ("children" in node && Array.isArray(node.children)) {
235
+ for (const child of node.children) {
236
+ walk(child, enterMap, exitMap);
237
+ }
238
+ }
239
+ const exitHandlers = exitMap[node.type];
240
+ if (exitHandlers) {
241
+ for (const handler of exitHandlers) handler(node);
242
+ }
243
+ }
244
+ function buildProjectFile(file) {
245
+ const parsed = parseFile(file.content);
246
+ return {
247
+ path: file.path,
248
+ content: file.content,
249
+ frontmatter: parsed.frontmatter,
250
+ metadata: parsed.metadata,
251
+ ast: parsed.ast
252
+ };
253
+ }
254
+ function runRulesOnProject(rules, files, runtime = {}) {
255
+ const diagnostics = [];
256
+ const severity = runtime.severity ?? "error";
257
+ for (const rule9 of rules) {
258
+ const perRuleOptions = runtime.optionsByRule?.[rule9.meta.name] ?? runtime.options;
259
+ const mergedOptions = {
260
+ ...rule9.meta.defaultOptions,
261
+ ...perRuleOptions
262
+ };
263
+ if (rule9.meta.schema && !runtime.skipValidation) {
264
+ const validator = getValidator(rule9.meta.schema);
265
+ if (!validator(mergedOptions)) {
266
+ throw new RuleOptionsError(
267
+ rule9.meta.name,
268
+ ajv.errorsText(validator.errors)
269
+ );
270
+ }
271
+ }
272
+ const context = {
273
+ files,
274
+ options: mergedOptions,
275
+ fileExists: runtime.fileExists,
276
+ report(d) {
277
+ diagnostics.push({
278
+ ruleName: rule9.meta.name,
279
+ severity,
280
+ ...d
281
+ });
282
+ }
283
+ };
284
+ try {
285
+ rule9.check(context);
286
+ } catch (err) {
287
+ diagnostics.push(
288
+ internalErrorDiagnostic(rule9.meta.name, "check", err, "<project>")
289
+ );
290
+ }
291
+ }
292
+ return diagnostics;
293
+ }
294
+
295
+ // src/core/types.ts
296
+ function isProjectRule(rule9) {
297
+ return "check" in rule9;
298
+ }
299
+
300
+ // src/rules/index.ts
301
+ var rules_exports = {};
302
+ __export(rules_exports, {
303
+ dateIso8601: () => date_iso8601_default,
304
+ filenameFormat: () => filename_format_default,
305
+ noBrokenLinks: () => no_broken_links_default,
306
+ noDuplicateNumbering: () => no_duplicate_numbering_default,
307
+ noNumberingGap: () => no_numbering_gap_default,
308
+ requiredSections: () => required_sections_default,
309
+ statusEnum: () => status_enum_default,
310
+ supersedesBidirectional: () => supersedes_bidirectional_default
311
+ });
312
+
313
+ // src/rules/date-iso8601/schema.json
314
+ var schema_default = {
315
+ $schema: "http://json-schema.org/draft-07/schema#",
316
+ type: "object",
317
+ additionalProperties: false,
318
+ properties: {
319
+ field: {
320
+ type: "string",
321
+ description: "Frontmatter key to read. Default: 'date'."
322
+ }
323
+ }
324
+ };
325
+
326
+ // src/rules/date-iso8601/index.ts
327
+ function normalizeDate(value) {
328
+ if (typeof value === "string") return value;
329
+ if (value instanceof Date) {
330
+ if (Number.isNaN(value.getTime())) return null;
331
+ return value.toISOString().slice(0, 10);
332
+ }
333
+ return null;
334
+ }
335
+ function isValidIso8601Date(s) {
336
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
337
+ const parts = s.split("-").map(Number);
338
+ const [year, month, day] = parts;
339
+ if (year === void 0 || month === void 0 || day === void 0) return false;
340
+ const d = new Date(Date.UTC(year, month - 1, day));
341
+ return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
342
+ }
343
+ var rule = {
344
+ meta: {
345
+ name: "madr/date-iso8601",
346
+ type: "perFile",
347
+ versionCompat: ["v2", "v3", "v4"],
348
+ docs: {
349
+ description: "Validate that ADR `date` (frontmatter or v2 bold-list) is a valid ISO 8601 calendar date (YYYY-MM-DD)",
350
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/date-iso8601.md",
351
+ recommended: true
352
+ },
353
+ messages: {
354
+ missingDate: 'Metadata does not contain a "{{field}}" field (checked frontmatter and v2 bold-list)',
355
+ invalidDate: 'Date "{{date}}" is not a valid ISO 8601 calendar date (YYYY-MM-DD)'
356
+ },
357
+ defaultOptions: {
358
+ field: "date"
359
+ },
360
+ schema: schema_default
361
+ },
362
+ create(context) {
363
+ const meta = context.metadata;
364
+ const fieldName = context.options.field;
365
+ if (!meta) {
366
+ context.report({
367
+ messageId: "missingDate",
368
+ data: { field: fieldName }
369
+ });
370
+ return;
371
+ }
372
+ const raw = meta[fieldName];
373
+ const dateStr = normalizeDate(raw);
374
+ if (dateStr === null) {
375
+ context.report({
376
+ messageId: "missingDate",
377
+ data: { field: fieldName }
378
+ });
379
+ return;
380
+ }
381
+ if (!isValidIso8601Date(dateStr)) {
382
+ context.report({
383
+ messageId: "invalidDate",
384
+ data: { date: dateStr }
385
+ });
386
+ }
387
+ }
388
+ };
389
+ var date_iso8601_default = rule;
390
+
391
+ // src/rules/filename-format/index.ts
392
+ import { basename } from "path";
393
+
394
+ // src/core/regex-safety.ts
395
+ import safeRegex from "safe-regex2";
396
+ var SAFE_REGEX_LIMIT = 25;
397
+ var cache = /* @__PURE__ */ new Map();
398
+ function assertSafeRegex(pattern, ruleName, optionPath) {
399
+ const cached = cache.get(pattern);
400
+ if (cached) return cached;
401
+ let regex;
402
+ try {
403
+ regex = new RegExp(pattern);
404
+ } catch (err) {
405
+ throw new RuleOptionsError(
406
+ ruleName,
407
+ `option "${optionPath}" is not a valid regular expression: ${err.message}`
408
+ );
409
+ }
410
+ if (!safeRegex(regex, { limit: SAFE_REGEX_LIMIT })) {
411
+ throw new RuleOptionsError(
412
+ ruleName,
413
+ `option "${optionPath}" (${pattern}) appears unsafe (catastrophic backtracking). Reject before runtime.`
414
+ );
415
+ }
416
+ cache.set(pattern, regex);
417
+ return regex;
418
+ }
419
+
420
+ // src/rules/filename-format/schema.json
421
+ var schema_default2 = {
422
+ $schema: "http://json-schema.org/draft-07/schema#",
423
+ type: "object",
424
+ additionalProperties: false,
425
+ properties: {
426
+ pattern: {
427
+ type: "string",
428
+ description: "Regex (as string) the basename must match. Defaults to ^[0-9]{4}-[a-z0-9-]+\\.md$"
429
+ }
430
+ }
431
+ };
432
+
433
+ // src/rules/filename-format/index.ts
434
+ var rule2 = {
435
+ meta: {
436
+ name: "madr/filename-format",
437
+ type: "perFile",
438
+ versionCompat: ["v2", "v3", "v4"],
439
+ docs: {
440
+ description: "Enforce ADR filename convention NNNN-kebab-case.md",
441
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/filename-format.md",
442
+ recommended: true
443
+ },
444
+ messages: {
445
+ invalidFilename: 'Filename "{{filename}}" does not match expected pattern "{{expected}}"'
446
+ },
447
+ defaultOptions: {
448
+ pattern: "^[0-9]{4}-[a-z0-9-]+\\.md$"
449
+ },
450
+ schema: schema_default2
451
+ },
452
+ create(context) {
453
+ const { pattern } = context.options;
454
+ const regex = assertSafeRegex(pattern, "madr/filename-format", "pattern");
455
+ const filename = basename(context.file.path);
456
+ if (!regex.test(filename)) {
457
+ context.report({
458
+ messageId: "invalidFilename",
459
+ data: { filename, expected: pattern }
460
+ });
461
+ }
462
+ }
463
+ };
464
+ var filename_format_default = rule2;
465
+
466
+ // src/rules/no-broken-links/index.ts
467
+ import { dirname, posix } from "path";
468
+
469
+ // src/rules/no-broken-links/schema.json
470
+ var schema_default3 = {
471
+ $schema: "http://json-schema.org/draft-07/schema#",
472
+ type: "object",
473
+ additionalProperties: false,
474
+ properties: {}
475
+ };
476
+
477
+ // src/rules/no-broken-links/index.ts
478
+ var PROTOCOL_REGEX = /^[a-z][a-z0-9+.-]*:/i;
479
+ function isLinkNode(node) {
480
+ return node.type === "link" && "url" in node && typeof node.url === "string";
481
+ }
482
+ function isExternalOrAnchor(url) {
483
+ if (url === "" || url.startsWith("#")) return true;
484
+ return PROTOCOL_REGEX.test(url);
485
+ }
486
+ function stripAnchorAndQuery(url) {
487
+ const hash = url.indexOf("#");
488
+ const query = url.indexOf("?");
489
+ let cut = hash;
490
+ if (query !== -1 && (cut === -1 || query < cut)) cut = query;
491
+ return cut === -1 ? url : url.slice(0, cut);
492
+ }
493
+ function safeDecode(path) {
494
+ if (!path.includes("%")) return path;
495
+ try {
496
+ return decodeURIComponent(path);
497
+ } catch {
498
+ return path;
499
+ }
500
+ }
501
+ function resolveRelative(fromDir, url) {
502
+ if (url.startsWith("/")) return url.slice(1);
503
+ return posix.normalize(posix.join(fromDir, url));
504
+ }
505
+ function collectLinks(node, out) {
506
+ if (isLinkNode(node)) out.push(node);
507
+ if ("children" in node && Array.isArray(node.children)) {
508
+ for (const child of node.children) {
509
+ collectLinks(child, out);
510
+ }
511
+ }
512
+ }
513
+ var rule3 = {
514
+ meta: {
515
+ name: "madr/no-broken-links",
516
+ type: "project",
517
+ versionCompat: ["v2", "v3", "v4"],
518
+ docs: {
519
+ description: "Relative-path Markdown links must resolve to an existing file in the project",
520
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/no-broken-links.md",
521
+ recommended: true
522
+ },
523
+ messages: {
524
+ brokenLink: 'Link to "{{url}}" resolves to "{{resolvedPath}}", which does not exist in the project'
525
+ },
526
+ defaultOptions: {},
527
+ schema: schema_default3
528
+ },
529
+ check(context) {
530
+ const knownPaths = /* @__PURE__ */ new Set();
531
+ for (const file of context.files) {
532
+ knownPaths.add(file.path);
533
+ }
534
+ for (const file of context.files) {
535
+ const links = [];
536
+ collectLinks(file.ast, links);
537
+ for (const link of links) {
538
+ const url = link.url;
539
+ if (isExternalOrAnchor(url)) continue;
540
+ const pathOnly = safeDecode(stripAnchorAndQuery(url));
541
+ if (pathOnly === "") continue;
542
+ const fileDir = dirname(file.path);
543
+ const resolvedPath = resolveRelative(fileDir, pathOnly);
544
+ const exists = knownPaths.has(resolvedPath) || (context.fileExists?.(resolvedPath) ?? false);
545
+ if (!exists) {
546
+ context.report({
547
+ messageId: "brokenLink",
548
+ path: file.path,
549
+ data: { url, resolvedPath }
550
+ });
551
+ }
552
+ }
553
+ }
554
+ }
555
+ };
556
+ var no_broken_links_default = rule3;
557
+
558
+ // src/rules/no-duplicate-numbering/index.ts
559
+ import { basename as basename2 } from "path";
560
+
561
+ // src/rules/no-duplicate-numbering/schema.json
562
+ var schema_default4 = {
563
+ $schema: "http://json-schema.org/draft-07/schema#",
564
+ type: "object",
565
+ additionalProperties: false,
566
+ properties: {}
567
+ };
568
+
569
+ // src/rules/no-duplicate-numbering/index.ts
570
+ var NUMBER_REGEX = /^(\d{4})-/;
571
+ var rule4 = {
572
+ meta: {
573
+ name: "madr/no-duplicate-numbering",
574
+ type: "project",
575
+ versionCompat: ["v2", "v3", "v4"],
576
+ docs: {
577
+ description: "No two ADRs share the same NNNN- number prefix",
578
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/no-duplicate-numbering.md",
579
+ recommended: true
580
+ },
581
+ messages: {
582
+ duplicateNumber: "ADR number {{number}} is used by multiple files: {{paths}}"
583
+ },
584
+ defaultOptions: {},
585
+ schema: schema_default4
586
+ },
587
+ check(context) {
588
+ const byNumber = /* @__PURE__ */ new Map();
589
+ for (const file of context.files) {
590
+ const match = NUMBER_REGEX.exec(basename2(file.path));
591
+ if (!match) continue;
592
+ const number = match[1];
593
+ if (number === void 0) continue;
594
+ const group = byNumber.get(number) ?? [];
595
+ group.push(file.path);
596
+ byNumber.set(number, group);
597
+ }
598
+ for (const [number, paths] of byNumber) {
599
+ if (paths.length > 1) {
600
+ const pathList = paths.join(", ");
601
+ for (const path of paths) {
602
+ context.report({
603
+ messageId: "duplicateNumber",
604
+ path,
605
+ data: { number, paths: pathList }
606
+ });
607
+ }
608
+ }
609
+ }
610
+ }
611
+ };
612
+ var no_duplicate_numbering_default = rule4;
613
+
614
+ // src/rules/no-numbering-gap/index.ts
615
+ import { basename as basename3 } from "path";
616
+
617
+ // src/rules/no-numbering-gap/schema.json
618
+ var schema_default5 = {
619
+ $schema: "http://json-schema.org/draft-07/schema#",
620
+ type: "object",
621
+ additionalProperties: false,
622
+ properties: {}
623
+ };
624
+
625
+ // src/rules/no-numbering-gap/index.ts
626
+ var NUMBER_REGEX2 = /^(\d{4})-/;
627
+ function format4(n) {
628
+ return n.toString().padStart(4, "0");
629
+ }
630
+ var rule5 = {
631
+ meta: {
632
+ name: "madr/no-numbering-gap",
633
+ type: "project",
634
+ versionCompat: ["v2", "v3", "v4"],
635
+ docs: {
636
+ description: "Detect gaps in ADR numbering (e.g. 0001 and 0003 exist but 0002 is missing). Convention-only; default off.",
637
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/no-numbering-gap.md",
638
+ recommended: false
639
+ },
640
+ messages: {
641
+ numberingGap: "Numbering gap: missing {{missing}} between ADR-{{from}} and ADR-{{to}}"
642
+ },
643
+ defaultOptions: {},
644
+ schema: schema_default5
645
+ },
646
+ check(context) {
647
+ const numberToPath = /* @__PURE__ */ new Map();
648
+ for (const file of context.files) {
649
+ const match = NUMBER_REGEX2.exec(basename3(file.path));
650
+ if (!match?.[1]) continue;
651
+ const n = Number.parseInt(match[1], 10);
652
+ if (!Number.isNaN(n)) numberToPath.set(n, file.path);
653
+ }
654
+ if (numberToPath.size < 2) return;
655
+ const sortedNumbers = Array.from(numberToPath.keys()).toSorted(
656
+ (a, b) => a - b
657
+ );
658
+ for (let i = 1; i < sortedNumbers.length; i++) {
659
+ const prev = sortedNumbers[i - 1];
660
+ const curr = sortedNumbers[i];
661
+ if (prev === void 0 || curr === void 0) continue;
662
+ if (curr === prev + 1) continue;
663
+ const missingNumbers = [];
664
+ for (let n = prev + 1; n < curr; n++) {
665
+ missingNumbers.push(format4(n));
666
+ }
667
+ const currPath = numberToPath.get(curr);
668
+ if (!currPath) continue;
669
+ context.report({
670
+ messageId: "numberingGap",
671
+ path: currPath,
672
+ data: {
673
+ from: format4(prev),
674
+ to: format4(curr),
675
+ missing: missingNumbers.join(", ")
676
+ }
677
+ });
678
+ }
679
+ }
680
+ };
681
+ var no_numbering_gap_default = rule5;
682
+
683
+ // src/rules/required-sections/index.ts
684
+ import { toString as toString2 } from "mdast-util-to-string";
685
+
686
+ // src/rules/required-sections/schema.json
687
+ var schema_default6 = {
688
+ $schema: "http://json-schema.org/draft-07/schema#",
689
+ type: "object",
690
+ additionalProperties: false,
691
+ properties: {
692
+ sections: {
693
+ type: "array",
694
+ items: { type: "string" },
695
+ description: "Required heading texts. Order does not matter."
696
+ },
697
+ matchMode: {
698
+ type: "string",
699
+ enum: ["exact", "startsWith"],
700
+ description: "Comparison mode against heading text."
701
+ }
702
+ }
703
+ };
704
+
705
+ // src/rules/required-sections/index.ts
706
+ var rule6 = {
707
+ meta: {
708
+ name: "madr/required-sections",
709
+ type: "perFile",
710
+ versionCompat: ["v2", "v3", "v4"],
711
+ docs: {
712
+ description: "Enforce that ADRs contain required heading sections",
713
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/required-sections.md",
714
+ recommended: true
715
+ },
716
+ messages: {
717
+ missingSection: 'Missing required section: "{{section}}"'
718
+ },
719
+ defaultOptions: {
720
+ sections: [
721
+ "Context and Problem Statement",
722
+ "Decision Outcome",
723
+ "Consequences"
724
+ ],
725
+ matchMode: "exact"
726
+ },
727
+ schema: schema_default6
728
+ },
729
+ create(context) {
730
+ const seen = [];
731
+ return {
732
+ enter: {
733
+ heading(node) {
734
+ if (node.type !== "heading") return;
735
+ seen.push(toString2(node).trim());
736
+ }
737
+ },
738
+ exit: {
739
+ root() {
740
+ const { sections, matchMode } = context.options;
741
+ for (const section of sections) {
742
+ const matched = seen.some(
743
+ (heading) => matchMode === "startsWith" ? heading.startsWith(section) : heading === section
744
+ );
745
+ if (!matched) {
746
+ context.report({
747
+ messageId: "missingSection",
748
+ data: { section, found: [...seen] }
749
+ });
750
+ }
751
+ }
752
+ }
753
+ }
754
+ };
755
+ }
756
+ };
757
+ var required_sections_default = rule6;
758
+
759
+ // src/rules/status-enum/schema.json
760
+ var schema_default7 = {
761
+ $schema: "http://json-schema.org/draft-07/schema#",
762
+ type: "object",
763
+ additionalProperties: false,
764
+ properties: {
765
+ values: {
766
+ type: "array",
767
+ items: { type: "string" },
768
+ description: "Exact-match allowed status values."
769
+ },
770
+ prefixValues: {
771
+ type: "array",
772
+ items: { type: "string" },
773
+ description: "startsWith-match allowed prefixes (e.g. 'superseded by')."
774
+ },
775
+ caseSensitive: {
776
+ type: "boolean",
777
+ description: "When false, comparisons are case-insensitive."
778
+ }
779
+ }
780
+ };
781
+
782
+ // src/rules/status-enum/index.ts
783
+ var rule7 = {
784
+ meta: {
785
+ name: "madr/status-enum",
786
+ type: "perFile",
787
+ versionCompat: ["v2", "v3", "v4"],
788
+ docs: {
789
+ description: "Validate that ADR `status` (frontmatter or v2 bold-list) is in the allowed enum",
790
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/status-enum.md",
791
+ recommended: true
792
+ },
793
+ messages: {
794
+ missingStatus: 'Metadata does not contain a "status" field (checked frontmatter and v2 bold-list)',
795
+ invalidStatus: 'Status "{{status}}" is not one of: {{allowed}}'
796
+ },
797
+ defaultOptions: {
798
+ values: ["proposed", "rejected", "accepted", "deprecated"],
799
+ prefixValues: ["superseded by"],
800
+ caseSensitive: false
801
+ },
802
+ schema: schema_default7
803
+ },
804
+ create(context) {
805
+ const meta = context.metadata;
806
+ if (!meta || typeof meta.status !== "string") {
807
+ context.report({ messageId: "missingStatus", data: {} });
808
+ return;
809
+ }
810
+ const { values, prefixValues, caseSensitive } = context.options;
811
+ const status = meta.status;
812
+ const compareStatus = caseSensitive ? status : status.toLowerCase();
813
+ const compareValues = caseSensitive ? values : values.map((v) => v.toLowerCase());
814
+ const comparePrefixes = caseSensitive ? prefixValues : prefixValues.map((p) => p.toLowerCase());
815
+ const exactMatch = compareValues.includes(compareStatus);
816
+ const prefixMatch = comparePrefixes.some(
817
+ (p) => compareStatus.startsWith(p)
818
+ );
819
+ if (!exactMatch && !prefixMatch) {
820
+ const allowed = [
821
+ ...values,
822
+ ...prefixValues.map((p) => `${p} ...`)
823
+ ];
824
+ context.report({
825
+ messageId: "invalidStatus",
826
+ data: { status, allowed }
827
+ });
828
+ }
829
+ }
830
+ };
831
+ var status_enum_default = rule7;
832
+
833
+ // src/rules/supersedes-bidirectional/index.ts
834
+ import { basename as basename4 } from "path";
835
+
836
+ // src/rules/supersedes-bidirectional/schema.json
837
+ var schema_default8 = {
838
+ $schema: "http://json-schema.org/draft-07/schema#",
839
+ type: "object",
840
+ additionalProperties: false,
841
+ properties: {}
842
+ };
843
+
844
+ // src/rules/supersedes-bidirectional/index.ts
845
+ var ADR_NUMBER_REGEX = /^(\d{4})-/;
846
+ var FORWARD_FIELD = "supersedes";
847
+ var BACKWARD_FIELD = "superseded-by";
848
+ function extractAdrNumber(path) {
849
+ const match = ADR_NUMBER_REGEX.exec(basename4(path));
850
+ return match?.[1] ?? null;
851
+ }
852
+ function normalizeRefs(value) {
853
+ if (typeof value === "string") return [value];
854
+ if (Array.isArray(value)) {
855
+ return value.filter((v) => typeof v === "string");
856
+ }
857
+ return [];
858
+ }
859
+ var rule8 = {
860
+ meta: {
861
+ name: "madr/supersedes-bidirectional",
862
+ type: "project",
863
+ versionCompat: ["v3", "v4"],
864
+ docs: {
865
+ description: "Frontmatter `supersedes` and `superseded-by` must reference each other bidirectionally",
866
+ url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/supersedes-bidirectional.md",
867
+ recommended: true
868
+ },
869
+ messages: {
870
+ unknownReference: "Frontmatter `{{direction}}: {{ref}}` references an ADR that does not exist",
871
+ missingBackReference: "`{{source}}` declares `{{direction}}: {{ref}}`, but `{{ref}}` (this file) does not back-reference it via `{{expected}}`"
872
+ },
873
+ defaultOptions: {},
874
+ schema: schema_default8
875
+ },
876
+ check(context) {
877
+ const adrIndex = /* @__PURE__ */ new Map();
878
+ for (const file of context.files) {
879
+ const num = extractAdrNumber(file.path);
880
+ if (num) adrIndex.set(`ADR-${num}`, file);
881
+ }
882
+ for (const file of context.files) {
883
+ const fm = file.frontmatter;
884
+ if (!fm) continue;
885
+ const fileNumber = extractAdrNumber(file.path);
886
+ if (!fileNumber) continue;
887
+ const fileRef = `ADR-${fileNumber}`;
888
+ const forwardRefs = normalizeRefs(fm[FORWARD_FIELD]);
889
+ const backwardRefs = normalizeRefs(fm[BACKWARD_FIELD]);
890
+ for (const ref of forwardRefs) {
891
+ const target = adrIndex.get(ref);
892
+ if (!target) {
893
+ context.report({
894
+ messageId: "unknownReference",
895
+ path: file.path,
896
+ data: { ref, direction: FORWARD_FIELD }
897
+ });
898
+ continue;
899
+ }
900
+ const targetBack = normalizeRefs(target.frontmatter?.[BACKWARD_FIELD]);
901
+ if (!targetBack.includes(fileRef)) {
902
+ context.report({
903
+ messageId: "missingBackReference",
904
+ path: target.path,
905
+ data: {
906
+ ref,
907
+ source: file.path,
908
+ direction: BACKWARD_FIELD,
909
+ expected: fileRef
910
+ }
911
+ });
912
+ }
913
+ }
914
+ for (const ref of backwardRefs) {
915
+ const target = adrIndex.get(ref);
916
+ if (!target) {
917
+ context.report({
918
+ messageId: "unknownReference",
919
+ path: file.path,
920
+ data: { ref, direction: BACKWARD_FIELD }
921
+ });
922
+ continue;
923
+ }
924
+ const targetFwd = normalizeRefs(target.frontmatter?.[FORWARD_FIELD]);
925
+ if (!targetFwd.includes(fileRef)) {
926
+ context.report({
927
+ messageId: "missingBackReference",
928
+ path: target.path,
929
+ data: {
930
+ ref,
931
+ source: file.path,
932
+ direction: FORWARD_FIELD,
933
+ expected: fileRef
934
+ }
935
+ });
936
+ }
937
+ }
938
+ }
939
+ }
940
+ };
941
+ var supersedes_bidirectional_default = rule8;
942
+
943
+ // src/configs/recommended.ts
944
+ var recommended = {
945
+ "madr/date-iso8601": "error",
946
+ "madr/filename-format": "error",
947
+ "madr/no-broken-links": "error",
948
+ "madr/no-duplicate-numbering": "error",
949
+ // Convention-only — opt in if your team treats ADR numbering as a
950
+ // contiguous sequence. See docs/rules/no-numbering-gap.md.
951
+ "madr/no-numbering-gap": "off",
952
+ "madr/required-sections": "error",
953
+ "madr/status-enum": "error",
954
+ "madr/supersedes-bidirectional": "error"
955
+ };
956
+
957
+ // src/index.ts
958
+ function defineConfig(config) {
959
+ return config;
960
+ }
961
+ export {
962
+ INTERNAL_ERROR_RULE_NAME,
963
+ RuleOptionsError,
964
+ buildProjectFile,
965
+ defineConfig,
966
+ extractListMetadata,
967
+ isProjectRule,
968
+ parseFile,
969
+ recommended,
970
+ rules_exports as rules,
971
+ runRule,
972
+ runRulesOnFile,
973
+ runRulesOnProject
974
+ };
975
+ //# sourceMappingURL=index.js.map