madr-lint 0.1.0 → 0.2.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 CHANGED
@@ -6,14 +6,152 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/cli.ts
9
- import { readFileSync as readFileSync4 } from "fs";
9
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
10
10
  import { relative as relative2, resolve as resolve3, sep as sep2 } from "path";
11
11
  import { defineCommand, runMain } from "citty";
12
12
 
13
- // src/core/cache.ts
14
- import { createHash } from "crypto";
13
+ // src/core/baseline.ts
15
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
16
15
  import { dirname, join } from "path";
16
+
17
+ // src/core/types.ts
18
+ var INTERNAL_ERROR_RULE_NAME = "core/internal-error";
19
+ function isProjectRule(rule9) {
20
+ return "check" in rule9;
21
+ }
22
+
23
+ // src/core/baseline.ts
24
+ var BASELINE_VERSION = 1;
25
+ var BASELINE_DISPLAY_PATH = ".madr-lint/baseline.json";
26
+ function baselinePath(cwd) {
27
+ return join(cwd, ".madr-lint", "baseline.json");
28
+ }
29
+ function nullProtoMap() {
30
+ return /* @__PURE__ */ Object.create(null);
31
+ }
32
+ function buildBaseline(diagnostics) {
33
+ const entries = nullProtoMap();
34
+ for (const d of diagnostics) {
35
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) continue;
36
+ const byRule = entries[d.path] ??= nullProtoMap();
37
+ const byMessage = byRule[d.ruleName] ??= nullProtoMap();
38
+ byMessage[d.messageId] = (byMessage[d.messageId] ?? 0) + 1;
39
+ }
40
+ return { version: BASELINE_VERSION, entries };
41
+ }
42
+ function serializeBaseline(baseline) {
43
+ const sortedEntries = nullProtoMap();
44
+ for (const path of Object.keys(baseline.entries).toSorted()) {
45
+ const byRule = baseline.entries[path] ?? {};
46
+ const sortedByRule = nullProtoMap();
47
+ for (const rule9 of Object.keys(byRule).toSorted()) {
48
+ const byMessage = byRule[rule9] ?? {};
49
+ const sortedByMessage = nullProtoMap();
50
+ for (const messageId of Object.keys(byMessage).toSorted()) {
51
+ sortedByMessage[messageId] = byMessage[messageId] ?? 0;
52
+ }
53
+ sortedByRule[rule9] = sortedByMessage;
54
+ }
55
+ sortedEntries[path] = sortedByRule;
56
+ }
57
+ const ordered = { version: baseline.version, entries: sortedEntries };
58
+ return JSON.stringify(ordered, null, 2) + "\n";
59
+ }
60
+ function loadBaseline(path) {
61
+ if (!existsSync(path)) return null;
62
+ try {
63
+ const data = JSON.parse(readFileSync(path, "utf8"));
64
+ if (typeof data.entries !== "object" || data.entries === null) return null;
65
+ return {
66
+ version: typeof data.version === "number" ? data.version : BASELINE_VERSION,
67
+ entries: rebuildEntries(data.entries)
68
+ };
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function rebuildEntries(raw) {
74
+ const entries = nullProtoMap();
75
+ for (const [path, rawByRule] of Object.entries(raw)) {
76
+ if (typeof rawByRule !== "object" || rawByRule === null) continue;
77
+ const byRule = nullProtoMap();
78
+ for (const [rule9, rawByMessage] of Object.entries(rawByRule)) {
79
+ if (typeof rawByMessage !== "object" || rawByMessage === null) continue;
80
+ const byMessage = nullProtoMap();
81
+ for (const [messageId, count] of Object.entries(
82
+ rawByMessage
83
+ )) {
84
+ if (typeof count === "number") byMessage[messageId] = count;
85
+ }
86
+ byRule[rule9] = byMessage;
87
+ }
88
+ entries[path] = byRule;
89
+ }
90
+ return entries;
91
+ }
92
+ function writeBaseline(path, baseline) {
93
+ mkdirSync(dirname(path), { recursive: true });
94
+ writeFileSync(path, serializeBaseline(baseline), "utf8");
95
+ }
96
+ var KEY_SEP = "\0";
97
+ function fingerprint(d) {
98
+ return d.path + KEY_SEP + d.ruleName + KEY_SEP + d.messageId;
99
+ }
100
+ function applyBaseline(diagnostics, baseline) {
101
+ const remaining = /* @__PURE__ */ new Map();
102
+ for (const [path, byRule] of Object.entries(baseline.entries)) {
103
+ for (const [rule9, byMessage] of Object.entries(byRule)) {
104
+ if (rule9 === INTERNAL_ERROR_RULE_NAME) continue;
105
+ for (const [messageId, count] of Object.entries(byMessage)) {
106
+ remaining.set(path + KEY_SEP + rule9 + KEY_SEP + messageId, count);
107
+ }
108
+ }
109
+ }
110
+ const kept = [];
111
+ let hidden = 0;
112
+ for (const d of diagnostics) {
113
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) {
114
+ kept.push(d);
115
+ continue;
116
+ }
117
+ const key = fingerprint(d);
118
+ const left = remaining.get(key);
119
+ if (left !== void 0 && left > 0) {
120
+ remaining.set(key, left - 1);
121
+ hidden++;
122
+ } else {
123
+ kept.push(d);
124
+ }
125
+ }
126
+ return { kept, hidden };
127
+ }
128
+ function totalCount(baseline) {
129
+ let total = 0;
130
+ for (const byRule of Object.values(baseline.entries)) {
131
+ for (const byMessage of Object.values(byRule)) {
132
+ for (const count of Object.values(byMessage)) total += count;
133
+ }
134
+ }
135
+ return total;
136
+ }
137
+ function baselineWriteSummary(baseline) {
138
+ const violations = totalCount(baseline);
139
+ const files = Object.keys(baseline.entries).length;
140
+ const v = `${violations} ${violations === 1 ? "violation" : "violations"}`;
141
+ const f = `${files} ${files === 1 ? "file" : "files"}`;
142
+ return `Wrote ${v} across ${f} to ${BASELINE_DISPLAY_PATH}`;
143
+ }
144
+ function baselineMalformedWarning() {
145
+ return `madr-lint: warning: ${BASELINE_DISPLAY_PATH} is malformed; ignoring it (run --update-baseline to regenerate)`;
146
+ }
147
+ function baselineHiddenSummary(hidden) {
148
+ return `${hidden} ${hidden === 1 ? "problem" : "problems"} hidden by baseline (${BASELINE_DISPLAY_PATH})`;
149
+ }
150
+
151
+ // src/core/cache.ts
152
+ import { createHash } from "crypto";
153
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
154
+ import { dirname as dirname2, join as join2 } from "path";
17
155
  function computeContentHash(content) {
18
156
  return createHash("sha1").update(content, "utf8").digest("hex");
19
157
  }
@@ -33,12 +171,12 @@ function stableStringify(value) {
33
171
  return "{" + entries.map(([k, v]) => JSON.stringify(k) + ":" + stableStringify(v)).join(",") + "}";
34
172
  }
35
173
  function manifestPath(cacheDir) {
36
- return join(cacheDir, "manifest.json");
174
+ return join2(cacheDir, "manifest.json");
37
175
  }
38
176
  function loadManifest(path) {
39
- if (!existsSync(path)) return null;
177
+ if (!existsSync2(path)) return null;
40
178
  try {
41
- const data = JSON.parse(readFileSync(path, "utf8"));
179
+ const data = JSON.parse(readFileSync2(path, "utf8"));
42
180
  if (typeof data.version !== "string" || typeof data.configHash !== "string" || typeof data.files !== "object" || data.files === null) {
43
181
  return null;
44
182
  }
@@ -48,12 +186,12 @@ function loadManifest(path) {
48
186
  }
49
187
  }
50
188
  function saveManifest(path, manifest) {
51
- mkdirSync(dirname(path), { recursive: true });
52
- writeFileSync(path, JSON.stringify(manifest), "utf8");
189
+ mkdirSync2(dirname2(path), { recursive: true });
190
+ writeFileSync2(path, JSON.stringify(manifest), "utf8");
53
191
  }
54
192
 
55
193
  // src/core/config.ts
56
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
194
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
57
195
  import { resolve } from "path";
58
196
  import { createJiti } from "jiti";
59
197
 
@@ -64,7 +202,7 @@ var recommended = {
64
202
  "madr/no-broken-links": "error",
65
203
  "madr/no-duplicate-numbering": "error",
66
204
  // Convention-only — opt in if your team treats ADR numbering as a
67
- // contiguous sequence. See docs/rules/no-numbering-gap.md.
205
+ // contiguous sequence. See https://knktkc.github.io/madr-lint/rules/no-numbering-gap/.
68
206
  "madr/no-numbering-gap": "off",
69
207
  "madr/required-sections": "error",
70
208
  "madr/status-enum": "error",
@@ -85,12 +223,43 @@ var CONFIG_FILES = [
85
223
  "madr-lint.config.mjs",
86
224
  "madr-lint.config.cjs"
87
225
  ];
226
+ var ConfigFileNotFoundError = class extends Error {
227
+ constructor(message) {
228
+ super(message);
229
+ this.name = "ConfigFileNotFoundError";
230
+ }
231
+ };
232
+ function loadConfigFromPath(filePath) {
233
+ let raw;
234
+ try {
235
+ raw = readFileSync3(filePath, "utf8");
236
+ } catch (err) {
237
+ const code = err.code;
238
+ if (code === "ENOENT") {
239
+ throw new ConfigFileNotFoundError(`Config file not found: ${filePath}`);
240
+ }
241
+ if (code === "EISDIR") {
242
+ throw new ConfigFileNotFoundError(
243
+ `Config path is a directory, not a file: ${filePath}`
244
+ );
245
+ }
246
+ throw err;
247
+ }
248
+ if (filePath.endsWith(".json")) {
249
+ const parsed = JSON.parse(raw);
250
+ return resolveExtends(parsed);
251
+ }
252
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
253
+ const loaded = jiti(filePath);
254
+ const config = typeof loaded === "object" && loaded !== null && "default" in loaded ? loaded.default : loaded;
255
+ return resolveExtends(config);
256
+ }
88
257
  function loadConfig(cwd) {
89
258
  for (const name of CONFIG_FILES) {
90
259
  const path = resolve(cwd, name);
91
- if (!existsSync2(path)) continue;
260
+ if (!existsSync3(path)) continue;
92
261
  if (name.endsWith(".json")) {
93
- const parsed = JSON.parse(readFileSync2(path, "utf8"));
262
+ const parsed = JSON.parse(readFileSync3(path, "utf8"));
94
263
  return resolveExtends(parsed);
95
264
  }
96
265
  const jiti = createJiti(import.meta.url, { interopDefault: true });
@@ -115,17 +284,17 @@ function resolveExtends(config) {
115
284
  }
116
285
 
117
286
  // src/core/discover.ts
118
- import { existsSync as existsSync3, readdirSync, statSync } from "fs";
119
- import { join as join2 } from "path";
287
+ import { existsSync as existsSync4, readdirSync, statSync } from "fs";
288
+ import { join as join3 } from "path";
120
289
  function findAdrFiles(target) {
121
- if (!existsSync3(target)) return [];
290
+ if (!existsSync4(target)) return [];
122
291
  const stat = statSync(target);
123
292
  if (stat.isFile()) {
124
293
  return target.endsWith(".md") ? [target] : [];
125
294
  }
126
295
  if (!stat.isDirectory()) return [];
127
296
  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();
297
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => join3(e.parentPath, e.name)).toSorted();
129
298
  }
130
299
 
131
300
  // src/core/ignore.ts
@@ -152,7 +321,7 @@ function matchesPattern(relativePath, base, pattern) {
152
321
  }
153
322
 
154
323
  // src/core/lint.ts
155
- import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
324
+ import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
156
325
  import { isAbsolute, relative, resolve as resolve2, sep } from "path";
157
326
 
158
327
  // src/core/runner.ts
@@ -167,20 +336,33 @@ function parseFile(content) {
167
336
  const data = matter.data;
168
337
  const frontmatter = Object.keys(data).length > 0 ? data : null;
169
338
  const ast = fromMarkdown(matter.content);
170
- const listMetadata = extractListMetadata(ast);
339
+ const listResult = extractListMetadataWithLoc(ast);
340
+ const listMetadata = listResult?.values ?? null;
171
341
  let metadata = null;
342
+ let metadataLoc = listResult && Object.keys(listResult.loc).length > 0 ? { ...listResult.loc } : null;
172
343
  if (frontmatter && listMetadata) {
173
344
  const frontmatterDefined = {};
174
345
  for (const [k, v] of Object.entries(frontmatter)) {
175
346
  if (v !== null && v !== void 0) frontmatterDefined[k] = v;
176
347
  }
177
348
  metadata = { ...listMetadata, ...frontmatterDefined };
349
+ if (metadataLoc) {
350
+ for (const k of Object.keys(frontmatterDefined)) delete metadataLoc[k];
351
+ if (Object.keys(metadataLoc).length === 0) metadataLoc = null;
352
+ }
178
353
  } else if (frontmatter) {
179
354
  metadata = frontmatter;
180
355
  } else if (listMetadata) {
181
356
  metadata = listMetadata;
182
357
  }
183
- return { frontmatter, listMetadata, metadata, ast, body: matter.content };
358
+ return {
359
+ frontmatter,
360
+ listMetadata,
361
+ metadata,
362
+ metadataLoc,
363
+ ast,
364
+ body: matter.content
365
+ };
184
366
  }
185
367
  var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
186
368
  "status",
@@ -190,7 +372,7 @@ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
190
372
  "consulted",
191
373
  "informed"
192
374
  ]);
193
- function extractListMetadata(ast) {
375
+ function extractListMetadataWithLoc(ast) {
194
376
  let firstList = null;
195
377
  for (const child of ast.children) {
196
378
  if (child.type === "heading") {
@@ -205,17 +387,20 @@ function extractListMetadata(ast) {
205
387
  break;
206
388
  }
207
389
  if (!firstList) return null;
208
- const result = {};
390
+ const values = {};
391
+ const loc = {};
209
392
  for (const item of firstList.children) {
210
393
  const pair = extractListItemKV(item);
211
- if (pair && !(pair.key in result)) {
212
- result[pair.key] = pair.value;
394
+ if (pair && !(pair.key in values)) {
395
+ values[pair.key] = pair.value;
396
+ const start = item.position?.start;
397
+ if (start) loc[pair.key] = { line: start.line, column: start.column };
213
398
  }
214
399
  }
215
- const hasRecognizedKey = Object.keys(result).some(
400
+ const hasRecognizedKey = Object.keys(values).some(
216
401
  (k) => RECOGNIZED_METADATA_KEYS.has(k)
217
402
  );
218
- return hasRecognizedKey ? result : null;
403
+ return hasRecognizedKey ? { values, loc } : null;
219
404
  }
220
405
  var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
221
406
  var SEPARATOR_PATTERN = /^\s*:\s*/;
@@ -255,6 +440,118 @@ function extractListItemKV(item) {
255
440
  return null;
256
441
  }
257
442
 
443
+ // src/core/suppression.ts
444
+ var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
445
+ "disable-file",
446
+ "disable",
447
+ "enable",
448
+ "disable-next-line"
449
+ ]);
450
+ var DIRECTIVE_PREFIX = "madr-lint-";
451
+ function parseDirective(value, line) {
452
+ const trimmed = value.trim();
453
+ if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
454
+ const inner = trimmed.slice(4, -3).trim();
455
+ if (inner.includes("<!--") || inner.includes("-->")) return null;
456
+ if (!inner.startsWith(DIRECTIVE_PREFIX)) return null;
457
+ const afterPrefix = inner.slice(DIRECTIVE_PREFIX.length);
458
+ const wsIndex = afterPrefix.search(/\s/);
459
+ const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
460
+ if (!DIRECTIVE_KINDS.has(keyword)) return null;
461
+ const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
462
+ return { kind: keyword, rules: parseRuleList(rest), line };
463
+ }
464
+ function parseRuleList(rest) {
465
+ const trimmed = rest.trim();
466
+ if (trimmed === "") return null;
467
+ const rules = trimmed.split(",").map((s) => s.trim()).filter((s) => s !== "");
468
+ return rules.length > 0 ? rules : null;
469
+ }
470
+ function walkHtml(node, out) {
471
+ if (node.type === "html") {
472
+ const line = node.position?.start.line;
473
+ if (typeof line === "number") {
474
+ const directive = parseDirective(node.value, line);
475
+ if (directive) out.push(directive);
476
+ }
477
+ }
478
+ if ("children" in node && Array.isArray(node.children)) {
479
+ for (const child of node.children) walkHtml(child, out);
480
+ }
481
+ }
482
+ function collectDirectives(ast, body) {
483
+ const all = [];
484
+ walkHtml(ast, all);
485
+ if (all.length === 0) return null;
486
+ let lines = null;
487
+ const nextNonBlankLine = (after) => {
488
+ lines ??= body.split("\n");
489
+ for (let ln = after + 1; ln <= lines.length; ln++) {
490
+ if ((lines[ln - 1] ?? "").trim() !== "") return ln;
491
+ }
492
+ return null;
493
+ };
494
+ const index = {
495
+ fileDisableAll: false,
496
+ fileDisableRules: /* @__PURE__ */ new Set(),
497
+ ranged: [],
498
+ nextLine: /* @__PURE__ */ new Map()
499
+ };
500
+ for (const directive of all) {
501
+ switch (directive.kind) {
502
+ case "disable-file": {
503
+ if (directive.rules === null) index.fileDisableAll = true;
504
+ else for (const rule9 of directive.rules) index.fileDisableRules.add(rule9);
505
+ break;
506
+ }
507
+ case "disable":
508
+ case "enable": {
509
+ index.ranged.push(directive);
510
+ break;
511
+ }
512
+ case "disable-next-line": {
513
+ const target = nextNonBlankLine(directive.line);
514
+ if (target === null) break;
515
+ let entry = index.nextLine.get(target);
516
+ if (!entry) {
517
+ entry = { all: false, rules: /* @__PURE__ */ new Set() };
518
+ index.nextLine.set(target, entry);
519
+ }
520
+ if (directive.rules === null) entry.all = true;
521
+ else for (const rule9 of directive.rules) entry.rules.add(rule9);
522
+ break;
523
+ }
524
+ }
525
+ }
526
+ index.ranged.sort((a, b) => a.line - b.line);
527
+ return index;
528
+ }
529
+ function directiveApplies(directive, ruleName) {
530
+ return directive.rules === null || directive.rules.includes(ruleName);
531
+ }
532
+ function isSuppressed(index, ruleName, line) {
533
+ if (index.fileDisableAll || index.fileDisableRules.has(ruleName)) return true;
534
+ if (line !== void 0) {
535
+ const next = index.nextLine.get(line);
536
+ if (next && (next.all || next.rules.has(ruleName))) return true;
537
+ }
538
+ const ceiling = line ?? Number.POSITIVE_INFINITY;
539
+ let disabled = false;
540
+ for (const directive of index.ranged) {
541
+ if (directive.line > ceiling) break;
542
+ if (directiveApplies(directive, ruleName)) {
543
+ disabled = directive.kind === "disable";
544
+ }
545
+ }
546
+ return disabled;
547
+ }
548
+ function filterSuppressed(diagnostics, index) {
549
+ return diagnostics.filter((d) => {
550
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) return true;
551
+ return !isSuppressed(index, d.ruleName, d.loc?.line);
552
+ });
553
+ }
554
+
258
555
  // src/core/runner.ts
259
556
  var ajv = new Ajv({ strict: true, allErrors: true });
260
557
  var validatorCache = /* @__PURE__ */ new WeakMap();
@@ -266,7 +563,6 @@ function getValidator(schema) {
266
563
  }
267
564
  return validator;
268
565
  }
269
- var INTERNAL_ERROR_RULE_NAME = "core/internal-error";
270
566
  var RuleOptionsError = class extends Error {
271
567
  constructor(ruleName, ajvErrors) {
272
568
  super(`Invalid options for rule ${ruleName}: ${ajvErrors}`);
@@ -277,6 +573,27 @@ var RuleOptionsError = class extends Error {
277
573
  ruleName;
278
574
  ajvErrors;
279
575
  };
576
+ var PerFileRuleContext = class {
577
+ file;
578
+ options;
579
+ report;
580
+ ensureParsed;
581
+ constructor(file, options, ensureParsed, report) {
582
+ this.file = file;
583
+ this.options = options;
584
+ this.ensureParsed = ensureParsed;
585
+ this.report = report;
586
+ }
587
+ get frontmatter() {
588
+ return this.ensureParsed().frontmatter;
589
+ }
590
+ get metadata() {
591
+ return this.ensureParsed().metadata;
592
+ }
593
+ get metadataLoc() {
594
+ return this.ensureParsed().metadataLoc;
595
+ }
596
+ };
280
597
  function runRulesOnFile(rules, file, runtime = {}) {
281
598
  const diagnostics = [];
282
599
  const severity = runtime.severity ?? "error";
@@ -300,16 +617,11 @@ function runRulesOnFile(rules, file, runtime = {}) {
300
617
  throw new RuleOptionsError(rule9.meta.name, ajv.errorsText(validator.errors));
301
618
  }
302
619
  }
303
- const context = {
620
+ const context = new PerFileRuleContext(
304
621
  file,
305
- get frontmatter() {
306
- return ensureParsed().frontmatter;
307
- },
308
- get metadata() {
309
- return ensureParsed().metadata;
310
- },
311
- options: mergedOptions,
312
- report(d) {
622
+ mergedOptions,
623
+ ensureParsed,
624
+ (d) => {
313
625
  diagnostics.push({
314
626
  ruleName: rule9.meta.name,
315
627
  severity,
@@ -317,7 +629,7 @@ function runRulesOnFile(rules, file, runtime = {}) {
317
629
  ...d
318
630
  });
319
631
  }
320
- };
632
+ );
321
633
  let listeners;
322
634
  try {
323
635
  listeners = rule9.create(context);
@@ -336,7 +648,11 @@ function runRulesOnFile(rules, file, runtime = {}) {
336
648
  if (hasASTRule) {
337
649
  walk(ensureParsed().ast, enterMap, exitMap);
338
650
  }
339
- return diagnostics;
651
+ if (diagnostics.length === 0) return diagnostics;
652
+ if (!file.content.includes(DIRECTIVE_PREFIX)) return diagnostics;
653
+ const { ast, body } = ensureParsed();
654
+ const directives = collectDirectives(ast, body);
655
+ return directives ? filterSuppressed(diagnostics, directives) : diagnostics;
340
656
  }
341
657
  function internalErrorDiagnostic(ruleName, operation, err, path) {
342
658
  return {
@@ -394,6 +710,7 @@ function buildProjectFile(file) {
394
710
  return {
395
711
  path: file.path,
396
712
  content: file.content,
713
+ body: parsed.body,
397
714
  frontmatter: parsed.frontmatter,
398
715
  metadata: parsed.metadata,
399
716
  ast: parsed.ast
@@ -440,11 +757,6 @@ function runRulesOnProject(rules, files, runtime = {}) {
440
757
  return diagnostics;
441
758
  }
442
759
 
443
- // src/core/types.ts
444
- function isProjectRule(rule9) {
445
- return "check" in rule9;
446
- }
447
-
448
760
  // src/core/lint.ts
449
761
  function toPosix(p) {
450
762
  if (sep === "/") return p;
@@ -454,7 +766,7 @@ function lintFiles(opts) {
454
766
  const allDiagnostics = [];
455
767
  const fileEntries = opts.files.map((absolutePath) => ({
456
768
  relativePath: toPosix(relative(opts.cwd, absolutePath)),
457
- content: readFileSync3(absolutePath, "utf8")
769
+ content: readFileSync4(absolutePath, "utf8")
458
770
  }));
459
771
  const perFileRules = [];
460
772
  const projectRules = [];
@@ -545,8 +857,9 @@ function lintFiles(opts) {
545
857
  if (sev === "error") errorRules.push(rule9);
546
858
  else if (sev === "warn") warnRules.push(rule9);
547
859
  }
860
+ const projectDiagnostics = [];
548
861
  if (errorRules.length > 0) {
549
- allDiagnostics.push(
862
+ projectDiagnostics.push(
550
863
  ...runRulesOnProject(errorRules, projectFiles, {
551
864
  severity: "error",
552
865
  fileExists,
@@ -555,7 +868,7 @@ function lintFiles(opts) {
555
868
  );
556
869
  }
557
870
  if (warnRules.length > 0) {
558
- allDiagnostics.push(
871
+ projectDiagnostics.push(
559
872
  ...runRulesOnProject(warnRules, projectFiles, {
560
873
  severity: "warn",
561
874
  fileExists,
@@ -563,14 +876,27 @@ function lintFiles(opts) {
563
876
  })
564
877
  );
565
878
  }
879
+ allDiagnostics.push(
880
+ ...filterSuppressedProjectDiagnostics(projectDiagnostics, projectFiles)
881
+ );
566
882
  }
567
883
  if (manifest && opts.cache) {
568
884
  saveManifest(manifestPath(opts.cache.dir), manifest);
569
885
  }
886
+ if (!opts.baseline) {
887
+ return {
888
+ filesChecked: opts.files.length,
889
+ filesFromCache,
890
+ diagnostics: allDiagnostics,
891
+ baselineHidden: 0
892
+ };
893
+ }
894
+ const applied = applyBaseline(allDiagnostics, opts.baseline);
570
895
  return {
571
896
  filesChecked: opts.files.length,
572
897
  filesFromCache,
573
- diagnostics: allDiagnostics
898
+ diagnostics: applied.kept,
899
+ baselineHidden: applied.hidden
574
900
  };
575
901
  }
576
902
  function runPerFileRulesForFile(perFileRules, ruleSeverity, optionsByRule, file) {
@@ -605,6 +931,25 @@ function runPerFileRulesForFile(perFileRules, ruleSeverity, optionsByRule, file)
605
931
  }
606
932
  return diagnostics;
607
933
  }
934
+ function filterSuppressedProjectDiagnostics(diagnostics, projectFiles) {
935
+ if (diagnostics.length === 0) return [];
936
+ const fileByPath = new Map(projectFiles.map((f) => [f.path, f]));
937
+ const indexByPath = /* @__PURE__ */ new Map();
938
+ const indexFor = (path) => {
939
+ const cached = indexByPath.get(path);
940
+ if (cached !== void 0) return cached;
941
+ const file = fileByPath.get(path);
942
+ const index = file && file.content.includes(DIRECTIVE_PREFIX) ? collectDirectives(file.ast, file.body) : null;
943
+ indexByPath.set(path, index);
944
+ return index;
945
+ };
946
+ return diagnostics.filter((d) => {
947
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) return true;
948
+ const index = indexFor(d.path);
949
+ if (!index) return true;
950
+ return !isSuppressed(index, d.ruleName, d.loc?.line);
951
+ });
952
+ }
608
953
  function resolveSeverity(config) {
609
954
  if (config === void 0) return "off";
610
955
  if (typeof config === "string") return config;
@@ -621,6 +966,8 @@ function extractOptionsByRule(ruleSeverity) {
621
966
  // src/core/reporter.ts
622
967
  import pc from "picocolors";
623
968
  var textReporter = {
969
+ // Deliberately 2-param (meta unused): the CLI prints baselineHiddenSummary()
970
+ // itself for text format, so the footer can outlive a suppressed banner.
624
971
  format(diagnostics, rulesByName) {
625
972
  if (diagnostics.length === 0) {
626
973
  return pc.green("\u2713 All clear.");
@@ -669,7 +1016,7 @@ function formatSummary(errors, warnings) {
669
1016
  return parts.join(", ");
670
1017
  }
671
1018
  var jsonReporter = {
672
- format(diagnostics, rulesByName) {
1019
+ format(diagnostics, rulesByName, meta) {
673
1020
  const errors = diagnostics.filter((d) => d.severity === "error").length;
674
1021
  const warnings = diagnostics.filter((d) => d.severity === "warn").length;
675
1022
  const payload = {
@@ -677,7 +1024,10 @@ var jsonReporter = {
677
1024
  summary: {
678
1025
  total: diagnostics.length,
679
1026
  errors,
680
- warnings
1027
+ warnings,
1028
+ // Always present (0 when no baseline) so CI consumers can detect an
1029
+ // active baseline without probing for the key. SARIF stays untouched.
1030
+ baselineHidden: meta?.baselineHidden ?? 0
681
1031
  },
682
1032
  results: diagnostics.map((d) => ({
683
1033
  path: d.path,
@@ -737,10 +1087,40 @@ var sarifReporter = {
737
1087
  return JSON.stringify(sarif, null, 2);
738
1088
  }
739
1089
  };
1090
+ function escapeGhMessage(s) {
1091
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
1092
+ }
1093
+ function escapeGhProperty(s) {
1094
+ return escapeGhMessage(s).replace(/,/g, "%2C").replace(/:/g, "%3A");
1095
+ }
1096
+ var githubReporter = {
1097
+ format(diagnostics, rulesByName, meta) {
1098
+ const lines = [];
1099
+ for (const d of diagnostics) {
1100
+ const level = d.severity === "error" ? "error" : "warning";
1101
+ const file = escapeGhProperty(d.path);
1102
+ const title = escapeGhProperty(d.ruleName);
1103
+ const message = escapeGhMessage(renderMessage(d, rulesByName));
1104
+ const linePart = d.loc ? `,line=${d.loc.line}` : "";
1105
+ lines.push(`::${level} file=${file}${linePart},title=${title}::${message}`);
1106
+ }
1107
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
1108
+ const warnings = diagnostics.filter((d) => d.severity === "warn").length;
1109
+ const summaryParts = [];
1110
+ if (errors > 0) summaryParts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
1111
+ if (warnings > 0) summaryParts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
1112
+ if (summaryParts.length === 0) summaryParts.push("0 errors, 0 warnings");
1113
+ const baselineHidden = meta?.baselineHidden ?? 0;
1114
+ if (baselineHidden > 0) summaryParts.push(`${baselineHidden} hidden by baseline`);
1115
+ lines.push(summaryParts.join(", "));
1116
+ return lines.join("\n");
1117
+ }
1118
+ };
740
1119
  var reporters = {
741
1120
  text: textReporter,
742
1121
  json: jsonReporter,
743
- sarif: sarifReporter
1122
+ sarif: sarifReporter,
1123
+ github: githubReporter
744
1124
  };
745
1125
 
746
1126
  // src/rules/index.ts
@@ -793,7 +1173,7 @@ var rule = {
793
1173
  versionCompat: ["v2", "v3", "v4"],
794
1174
  docs: {
795
1175
  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",
1176
+ url: "https://knktkc.github.io/madr-lint/rules/date-iso8601/",
797
1177
  recommended: true
798
1178
  },
799
1179
  messages: {
@@ -825,8 +1205,10 @@ var rule = {
825
1205
  return;
826
1206
  }
827
1207
  if (!isValidIso8601Date(dateStr)) {
1208
+ const loc = context.metadataLoc?.[fieldName];
828
1209
  context.report({
829
1210
  messageId: "invalidDate",
1211
+ ...loc ? { loc } : {},
830
1212
  data: { date: dateStr }
831
1213
  });
832
1214
  }
@@ -884,7 +1266,7 @@ var rule2 = {
884
1266
  versionCompat: ["v2", "v3", "v4"],
885
1267
  docs: {
886
1268
  description: "Enforce ADR filename convention NNNN-kebab-case.md",
887
- url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/filename-format.md",
1269
+ url: "https://knktkc.github.io/madr-lint/rules/filename-format/",
888
1270
  recommended: true
889
1271
  },
890
1272
  messages: {
@@ -910,7 +1292,7 @@ var rule2 = {
910
1292
  var filename_format_default = rule2;
911
1293
 
912
1294
  // src/rules/no-broken-links/index.ts
913
- import { dirname as dirname2, posix } from "path";
1295
+ import { dirname as dirname3, posix } from "path";
914
1296
 
915
1297
  // src/rules/no-broken-links/schema.json
916
1298
  var schema_default3 = {
@@ -963,7 +1345,7 @@ var rule3 = {
963
1345
  versionCompat: ["v2", "v3", "v4"],
964
1346
  docs: {
965
1347
  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",
1348
+ url: "https://knktkc.github.io/madr-lint/rules/no-broken-links/",
967
1349
  recommended: true
968
1350
  },
969
1351
  messages: {
@@ -985,13 +1367,15 @@ var rule3 = {
985
1367
  if (isExternalOrAnchor(url)) continue;
986
1368
  const pathOnly = safeDecode(stripAnchorAndQuery(url));
987
1369
  if (pathOnly === "") continue;
988
- const fileDir = dirname2(file.path);
1370
+ const fileDir = dirname3(file.path);
989
1371
  const resolvedPath = resolveRelative(fileDir, pathOnly);
990
1372
  const exists = knownPaths.has(resolvedPath) || (context.fileExists?.(resolvedPath) ?? false);
991
1373
  if (!exists) {
1374
+ const start = link.position?.start;
992
1375
  context.report({
993
1376
  messageId: "brokenLink",
994
1377
  path: file.path,
1378
+ ...start ? { loc: { line: start.line, column: start.column } } : {},
995
1379
  data: { url, resolvedPath }
996
1380
  });
997
1381
  }
@@ -1021,7 +1405,7 @@ var rule4 = {
1021
1405
  versionCompat: ["v2", "v3", "v4"],
1022
1406
  docs: {
1023
1407
  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",
1408
+ url: "https://knktkc.github.io/madr-lint/rules/no-duplicate-numbering/",
1025
1409
  recommended: true
1026
1410
  },
1027
1411
  messages: {
@@ -1080,7 +1464,7 @@ var rule5 = {
1080
1464
  versionCompat: ["v2", "v3", "v4"],
1081
1465
  docs: {
1082
1466
  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",
1467
+ url: "https://knktkc.github.io/madr-lint/rules/no-numbering-gap/",
1084
1468
  recommended: false
1085
1469
  },
1086
1470
  messages: {
@@ -1156,7 +1540,7 @@ var rule6 = {
1156
1540
  versionCompat: ["v2", "v3", "v4"],
1157
1541
  docs: {
1158
1542
  description: "Enforce that ADRs contain required heading sections",
1159
- url: "https://github.com/knktkc/madr-lint/blob/main/docs/rules/required-sections.md",
1543
+ url: "https://knktkc.github.io/madr-lint/rules/required-sections/",
1160
1544
  recommended: true
1161
1545
  },
1162
1546
  messages: {
@@ -1233,7 +1617,7 @@ var rule7 = {
1233
1617
  versionCompat: ["v2", "v3", "v4"],
1234
1618
  docs: {
1235
1619
  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",
1620
+ url: "https://knktkc.github.io/madr-lint/rules/status-enum/",
1237
1621
  recommended: true
1238
1622
  },
1239
1623
  messages: {
@@ -1267,8 +1651,10 @@ var rule7 = {
1267
1651
  ...values,
1268
1652
  ...prefixValues.map((p) => `${p} ...`)
1269
1653
  ];
1654
+ const loc = context.metadataLoc?.status;
1270
1655
  context.report({
1271
1656
  messageId: "invalidStatus",
1657
+ ...loc ? { loc } : {},
1272
1658
  data: { status, allowed }
1273
1659
  });
1274
1660
  }
@@ -1309,7 +1695,7 @@ var rule8 = {
1309
1695
  versionCompat: ["v3", "v4"],
1310
1696
  docs: {
1311
1697
  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",
1698
+ url: "https://knktkc.github.io/madr-lint/rules/supersedes-bidirectional/",
1313
1699
  recommended: true
1314
1700
  },
1315
1701
  messages: {
@@ -1392,7 +1778,7 @@ function toPosix2(p) {
1392
1778
  return p.split(sep2).join("/");
1393
1779
  }
1394
1780
  var pkg = JSON.parse(
1395
- readFileSync4(new URL("../package.json", import.meta.url), "utf8")
1781
+ readFileSync5(new URL("../package.json", import.meta.url), "utf8")
1396
1782
  );
1397
1783
  var main = defineCommand({
1398
1784
  meta: {
@@ -1418,14 +1804,71 @@ var main = defineCommand({
1418
1804
  },
1419
1805
  format: {
1420
1806
  type: "string",
1421
- description: "Reporter format: text (default), json, or sarif",
1807
+ description: "Reporter format: text (default), json, sarif, or github",
1422
1808
  default: "text"
1809
+ },
1810
+ baseline: {
1811
+ type: "boolean",
1812
+ description: "Subtract .madr-lint/baseline.json when present (use --no-baseline to ignore it)",
1813
+ default: true
1814
+ },
1815
+ "update-baseline": {
1816
+ type: "boolean",
1817
+ description: "Rewrite .madr-lint/baseline.json from a full lint, then exit 0",
1818
+ default: false
1819
+ },
1820
+ quiet: {
1821
+ type: "boolean",
1822
+ description: "Report errors only; suppress warnings from output",
1823
+ default: false
1824
+ },
1825
+ "max-warnings": {
1826
+ type: "string",
1827
+ description: "Exit 1 when warning count exceeds n. n=0 means any warning fails. Negative = no limit.",
1828
+ required: false
1829
+ },
1830
+ config: {
1831
+ type: "string",
1832
+ description: "Path to config file, bypassing auto-discovery",
1833
+ required: false
1423
1834
  }
1424
1835
  },
1425
1836
  run({ args }) {
1426
1837
  const cwd = process.cwd();
1427
- let config = loadConfig(cwd);
1428
- if (Object.keys(config.rules).length === 0) {
1838
+ let maxWarnings = -1;
1839
+ const rawMaxWarnings = args["max-warnings"];
1840
+ if (rawMaxWarnings !== void 0) {
1841
+ const parsed = Number(rawMaxWarnings);
1842
+ if (rawMaxWarnings.trim() === "" || !Number.isInteger(parsed)) {
1843
+ console.error(
1844
+ `Invalid --max-warnings "${rawMaxWarnings}": must be an integer.`
1845
+ );
1846
+ process.exit(2);
1847
+ }
1848
+ maxWarnings = parsed;
1849
+ }
1850
+ let config;
1851
+ let configExplicit = false;
1852
+ const configArg = args.config;
1853
+ if (configArg) {
1854
+ const absConfigPath = resolve3(cwd, configArg);
1855
+ try {
1856
+ config = loadConfigFromPath(absConfigPath);
1857
+ configExplicit = true;
1858
+ } catch (err) {
1859
+ if (err instanceof ConfigFileNotFoundError) {
1860
+ console.error(err.message);
1861
+ } else {
1862
+ console.error(
1863
+ `Failed to load config "${configArg}": ${err instanceof Error ? err.message : String(err)}`
1864
+ );
1865
+ }
1866
+ process.exit(2);
1867
+ }
1868
+ } else {
1869
+ config = loadConfig(cwd);
1870
+ }
1871
+ if (!configExplicit && Object.keys(config.rules).length === 0) {
1429
1872
  config = resolveExtends({ extends: ["madr-lint:recommended"] });
1430
1873
  }
1431
1874
  const positionals = args._ ?? [];
@@ -1461,6 +1904,13 @@ var main = defineCommand({
1461
1904
  }),
1462
1905
  pkgVersion: pkg.version
1463
1906
  } : null;
1907
+ const baselineFile = baselinePath(cwd);
1908
+ const updateBaseline = args["update-baseline"] === true;
1909
+ const useBaseline = !updateBaseline && args.baseline !== false;
1910
+ const baseline = useBaseline ? loadBaseline(baselineFile) : null;
1911
+ if (useBaseline && baseline === null && existsSync5(baselineFile)) {
1912
+ console.error(baselineMalformedWarning());
1913
+ }
1464
1914
  const allRulesArray = Object.values(rules_exports);
1465
1915
  let result;
1466
1916
  try {
@@ -1469,7 +1919,8 @@ var main = defineCommand({
1469
1919
  ruleSeverity: config.rules,
1470
1920
  files,
1471
1921
  cwd,
1472
- cache: cacheConfig
1922
+ cache: cacheConfig,
1923
+ baseline
1473
1924
  });
1474
1925
  } catch (err) {
1475
1926
  if (err instanceof RuleOptionsError) {
@@ -1478,6 +1929,12 @@ var main = defineCommand({
1478
1929
  }
1479
1930
  throw err;
1480
1931
  }
1932
+ if (updateBaseline) {
1933
+ const built = buildBaseline(result.diagnostics);
1934
+ writeBaseline(baselineFile, built);
1935
+ console.log(baselineWriteSummary(built));
1936
+ process.exit(0);
1937
+ }
1481
1938
  const rulesByName = new Map(
1482
1939
  allRulesArray.map((r) => [r.meta.name, r])
1483
1940
  );
@@ -1489,11 +1946,28 @@ var main = defineCommand({
1489
1946
  );
1490
1947
  process.exit(2);
1491
1948
  }
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);
1949
+ const allDiagnostics = result.diagnostics;
1950
+ const warningCount = allDiagnostics.filter((d) => d.severity === "warn").length;
1951
+ const errorCount = allDiagnostics.filter((d) => d.severity === "error").length;
1952
+ const overWarningLimit = maxWarnings >= 0 && warningCount > maxWarnings;
1953
+ const reported = args.quiet ? allDiagnostics.filter((d) => d.severity !== "warn") : allDiagnostics;
1954
+ const suppressAllClear = format === "text" && reported.length === 0 && overWarningLimit;
1955
+ if (!suppressAllClear) {
1956
+ console.log(
1957
+ reporter.format(reported, rulesByName, {
1958
+ baselineHidden: result.baselineHidden
1959
+ })
1960
+ );
1961
+ }
1962
+ if (format === "text" && result.baselineHidden > 0) {
1963
+ console.log(baselineHiddenSummary(result.baselineHidden));
1964
+ }
1965
+ if (overWarningLimit) {
1966
+ console.error(
1967
+ `madr-lint: ${warningCount} warning(s) found, exceeds --max-warnings ${maxWarnings}`
1968
+ );
1969
+ }
1970
+ process.exit(errorCount > 0 || overWarningLimit ? 1 : 0);
1497
1971
  }
1498
1972
  });
1499
1973
  await runMain(main);