madr-lint 0.3.0 → 0.4.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,198 +6,17 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/cli.ts
9
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
10
- import { relative as relative2, resolve as resolve3, sep as sep2 } from "path";
11
- import { defineCommand, runMain } from "citty";
9
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
10
+ import { relative as relative2, resolve as resolve5, sep as sep2 } from "path";
11
+ import { defineCommand as defineCommand2, runMain } from "citty";
12
12
 
13
- // src/core/baseline.ts
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
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";
155
- var CACHE_SCHEMA_VERSION = 2;
156
- function computeContentHash(content) {
157
- return createHash("sha1").update(content, "utf8").digest("hex");
158
- }
159
- function computeConfigHash(config) {
160
- return createHash("sha1").update(stableStringify(config), "utf8").digest("hex");
161
- }
162
- function stableStringify(value) {
163
- if (value === null || typeof value !== "object") {
164
- return JSON.stringify(value);
165
- }
166
- if (Array.isArray(value)) {
167
- return "[" + value.map(stableStringify).join(",") + "]";
168
- }
169
- const entries = Object.entries(value).toSorted(
170
- ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
171
- );
172
- return "{" + entries.map(([k, v]) => JSON.stringify(k) + ":" + stableStringify(v)).join(",") + "}";
173
- }
174
- function manifestPath(cacheDir) {
175
- return join2(cacheDir, "manifest.json");
176
- }
177
- function loadManifest(path) {
178
- if (!existsSync2(path)) return null;
179
- try {
180
- const data = JSON.parse(readFileSync2(path, "utf8"));
181
- if (
182
- // Single invalidation point for schema drift: an unknown (or absent)
183
- // schema version nulls here, so every consumer sees a cold cache — no
184
- // scattered re-hydration of stale-shape entries downstream.
185
- data.schemaVersion !== CACHE_SCHEMA_VERSION || typeof data.version !== "string" || typeof data.configHash !== "string" || typeof data.files !== "object" || data.files === null
186
- ) {
187
- return null;
188
- }
189
- return data;
190
- } catch {
191
- return null;
192
- }
193
- }
194
- function saveManifest(path, manifest) {
195
- mkdirSync2(dirname2(path), { recursive: true });
196
- writeFileSync2(path, JSON.stringify(manifest), "utf8");
197
- }
13
+ // src/commands/init.ts
14
+ import { writeFileSync as writeFileSync3 } from "fs";
15
+ import { resolve as resolve4 } from "path";
16
+ import { defineCommand } from "citty";
198
17
 
199
18
  // src/core/config.ts
200
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
19
+ import { existsSync, readFileSync } from "fs";
201
20
  import { resolve } from "path";
202
21
  import { createJiti } from "jiti";
203
22
 
@@ -238,7 +57,7 @@ var ConfigFileNotFoundError = class extends Error {
238
57
  function loadConfigFromPath(filePath) {
239
58
  let raw;
240
59
  try {
241
- raw = readFileSync3(filePath, "utf8");
60
+ raw = readFileSync(filePath, "utf8");
242
61
  } catch (err) {
243
62
  const code = err.code;
244
63
  if (code === "ENOENT") {
@@ -263,9 +82,9 @@ function loadConfigFromPath(filePath) {
263
82
  function loadConfig(cwd) {
264
83
  for (const name of CONFIG_FILES) {
265
84
  const path = resolve(cwd, name);
266
- if (!existsSync3(path)) continue;
85
+ if (!existsSync(path)) continue;
267
86
  if (name.endsWith(".json")) {
268
- const parsed = JSON.parse(readFileSync3(path, "utf8"));
87
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
269
88
  return resolveExtends(parsed);
270
89
  }
271
90
  const jiti = createJiti(import.meta.url, { interopDefault: true });
@@ -290,71 +109,40 @@ function resolveExtends(config) {
290
109
  }
291
110
 
292
111
  // src/core/discover.ts
293
- import { existsSync as existsSync4, readdirSync, statSync } from "fs";
294
- import { join as join3 } from "path";
112
+ import { existsSync as existsSync2, readdirSync, statSync } from "fs";
113
+ import { join } from "path";
295
114
  function findAdrFiles(target) {
296
- if (!existsSync4(target)) return [];
115
+ if (!existsSync2(target)) return [];
297
116
  const stat = statSync(target);
298
117
  if (stat.isFile()) {
299
118
  return target.endsWith(".md") ? [target] : [];
300
119
  }
301
120
  if (!stat.isDirectory()) return [];
302
121
  const entries = readdirSync(target, { recursive: true, withFileTypes: true });
303
- return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => join3(e.parentPath, e.name)).toSorted();
304
- }
305
-
306
- // src/core/ignore.ts
307
- import { basename } from "path";
308
- import picomatch from "picomatch";
309
- function shouldIgnore(relativePath, patterns) {
310
- if (patterns.length === 0) return false;
311
- const base = basename(relativePath);
312
- for (const pattern of patterns) {
313
- if (matchesPattern(relativePath, base, pattern)) return true;
314
- }
315
- return false;
316
- }
317
- function matchesPattern(relativePath, base, pattern) {
318
- if (relativePath === pattern) return true;
319
- const matcher = picomatch(pattern, { dot: true });
320
- if (matcher(relativePath)) return true;
321
- if (matcher(base)) return true;
322
- if (pattern.includes("/") && !pattern.startsWith("**/")) {
323
- const suffixMatcher = picomatch(`**/${pattern}`, { dot: true });
324
- if (suffixMatcher(relativePath)) return true;
325
- }
326
- return false;
122
+ return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => join(e.parentPath, e.name)).toSorted();
327
123
  }
328
124
 
329
- // src/core/lint.ts
330
- import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
331
- import { isAbsolute, relative, resolve as resolve2, sep } from "path";
332
-
333
- // src/core/runner.ts
334
- import Ajv from "ajv";
335
-
336
- // src/core/interpolate.ts
337
- function interpolate(template, data) {
338
- if (!template.includes("{{")) return template;
339
- return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
340
- const v = data[key];
341
- return v !== void 0 ? String(v) : `{{${key}}}`;
342
- });
343
- }
125
+ // src/core/init.ts
126
+ import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
127
+ import { join as join2, resolve as resolve2 } from "path";
344
128
 
345
129
  // src/core/parser.ts
346
130
  import { fromMarkdown } from "mdast-util-from-markdown";
347
131
  import { toString } from "mdast-util-to-string";
348
132
  import grayMatter from "gray-matter";
133
+ function frontmatterOffset(content) {
134
+ return content.length - grayMatter(content).content.length;
135
+ }
349
136
  function parseFile(content) {
350
137
  const matter = grayMatter(content);
351
138
  const data = matter.data;
352
139
  const frontmatter = Object.keys(data).length > 0 ? data : null;
353
140
  const ast = fromMarkdown(matter.content);
354
- const listResult = extractListMetadataWithLoc(ast);
141
+ const listResult = extractListMetadataWithLoc(ast, matter.content);
355
142
  const listMetadata = listResult?.values ?? null;
356
143
  let metadata = null;
357
144
  let metadataLoc = listResult && Object.keys(listResult.loc).length > 0 ? { ...listResult.loc } : null;
145
+ let metadataValueLoc = listResult && Object.keys(listResult.valueOffsets).length > 0 ? { ...listResult.valueOffsets } : null;
358
146
  if (frontmatter && listMetadata) {
359
147
  const frontmatterDefined = {};
360
148
  for (const [k, v] of Object.entries(frontmatter)) {
@@ -365,6 +153,10 @@ function parseFile(content) {
365
153
  for (const k of Object.keys(frontmatterDefined)) delete metadataLoc[k];
366
154
  if (Object.keys(metadataLoc).length === 0) metadataLoc = null;
367
155
  }
156
+ if (metadataValueLoc) {
157
+ for (const k of Object.keys(frontmatterDefined)) delete metadataValueLoc[k];
158
+ if (Object.keys(metadataValueLoc).length === 0) metadataValueLoc = null;
159
+ }
368
160
  } else if (frontmatter) {
369
161
  metadata = frontmatter;
370
162
  } else if (listMetadata) {
@@ -375,6 +167,7 @@ function parseFile(content) {
375
167
  listMetadata,
376
168
  metadata,
377
169
  metadataLoc,
170
+ metadataValueLoc,
378
171
  ast,
379
172
  body: matter.content
380
173
  };
@@ -387,7 +180,7 @@ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
387
180
  "consulted",
388
181
  "informed"
389
182
  ]);
390
- function extractListMetadataWithLoc(ast) {
183
+ function extractListMetadataWithLoc(ast, body) {
391
184
  let firstList = null;
392
185
  for (const child of ast.children) {
393
186
  if (child.type === "heading") {
@@ -404,18 +197,22 @@ function extractListMetadataWithLoc(ast) {
404
197
  if (!firstList) return null;
405
198
  const values = {};
406
199
  const loc = {};
200
+ const valueOffsets = {};
407
201
  for (const item of firstList.children) {
408
202
  const pair = extractListItemKV(item);
409
203
  if (pair && !(pair.key in values)) {
410
204
  values[pair.key] = pair.value;
411
205
  const start = item.position?.start;
412
206
  if (start) loc[pair.key] = { line: start.line, column: start.column };
207
+ if (body !== void 0 && pair.valueOffset && body.slice(pair.valueOffset.start, pair.valueOffset.end) === pair.value) {
208
+ valueOffsets[pair.key] = pair.valueOffset;
209
+ }
413
210
  }
414
211
  }
415
212
  const hasRecognizedKey = Object.keys(values).some(
416
213
  (k) => RECOGNIZED_METADATA_KEYS.has(k)
417
214
  );
418
- return hasRecognizedKey ? { values, loc } : null;
215
+ return hasRecognizedKey ? { values, loc, valueOffsets } : null;
419
216
  }
420
217
  var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
421
218
  var SEPARATOR_PATTERN = /^\s*:\s*/;
@@ -439,63 +236,589 @@ function extractListItemKV(item) {
439
236
  const valueTail = paragraph.children.slice(2).map((n) => toString(n)).join("");
440
237
  const value = (valueHead + valueTail).trim();
441
238
  if (value === "") return null;
442
- return { key: normalizeKey(keyNode.value), value };
239
+ let valueOffset;
240
+ const sepStart = sep3.position?.start.offset;
241
+ if (paragraph.children.length === 2 && typeof sepStart === "number" && valueHead !== "") {
242
+ const sepMatch = SEPARATOR_PATTERN.exec(sep3.value);
243
+ const vStart = sepStart + (sepMatch ? sepMatch[0].length : 0);
244
+ valueOffset = { start: vStart, end: vStart + value.length };
245
+ }
246
+ return { key: normalizeKey(keyNode.value), value, valueOffset };
443
247
  }
444
248
  if (first.type === "text") {
445
249
  const colonIdx = first.value.indexOf(":");
446
250
  if (colonIdx === -1) return null;
447
251
  const rawKey = first.value.slice(0, colonIdx);
448
252
  if (!KEY_PATTERN.test(rawKey)) return null;
449
- const valueHead = first.value.slice(colonIdx + 1).replace(/^\s+/, "");
253
+ const afterColon = first.value.slice(colonIdx + 1);
254
+ const valueHead = afterColon.replace(/^\s+/, "");
450
255
  const valueTail = paragraph.children.slice(1).map((n) => toString(n)).join("");
451
256
  const value = (valueHead + valueTail).trim();
452
257
  if (value === "") return null;
453
- return { key: normalizeKey(rawKey), value };
258
+ let valueOffset;
259
+ const nodeStart = first.position?.start.offset;
260
+ if (paragraph.children.length === 1 && typeof nodeStart === "number" && valueHead !== "") {
261
+ const leadingWs = afterColon.length - valueHead.length;
262
+ const vStart = nodeStart + colonIdx + 1 + leadingWs;
263
+ valueOffset = { start: vStart, end: vStart + value.length };
264
+ }
265
+ return { key: normalizeKey(rawKey), value, valueOffset };
454
266
  }
455
267
  return null;
456
268
  }
457
269
 
458
- // src/core/suppression.ts
459
- var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
460
- "disable-file",
461
- "disable",
462
- "enable",
463
- "disable-next-line"
464
- ]);
465
- var DIRECTIVE_PREFIX = "madr-lint-";
466
- function parseDirective(value, line) {
467
- const trimmed = value.trim();
468
- if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
469
- const inner = trimmed.slice(4, -3).trim();
470
- if (inner.includes("<!--") || inner.includes("-->")) return null;
471
- if (!inner.startsWith(DIRECTIVE_PREFIX)) return null;
472
- const afterPrefix = inner.slice(DIRECTIVE_PREFIX.length);
473
- const wsIndex = afterPrefix.search(/\s/);
474
- const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
475
- if (!DIRECTIVE_KINDS.has(keyword)) return null;
476
- const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
477
- return { kind: keyword, rules: parseRuleList(rest), line };
478
- }
479
- function parseRuleList(rest) {
480
- const trimmed = rest.trim();
481
- if (trimmed === "") return null;
482
- const rules = trimmed.split(",").map((s) => s.trim()).filter((s) => s !== "");
483
- return rules.length > 0 ? rules : null;
484
- }
485
- function walkHtml(node, out) {
486
- if (node.type === "html") {
487
- const line = node.position?.start.line;
488
- if (typeof line === "number") {
489
- const directive = parseDirective(node.value, line);
490
- if (directive) out.push(directive);
270
+ // src/core/init.ts
271
+ var GETTING_STARTED_URL = "https://knktkc.github.io/madr-lint/guides/getting-started/";
272
+ var ADR_DIR_CANDIDATES = [
273
+ "docs/adr",
274
+ "docs/decisions",
275
+ "doc/adr",
276
+ "adr",
277
+ "docs/architecture/decisions"
278
+ ];
279
+ var ADR_FILE_PATTERN = /^\d{4}-.*\.md$/;
280
+ function detectAdrDir(cwd) {
281
+ for (const candidate of ADR_DIR_CANDIDATES) {
282
+ let entries;
283
+ try {
284
+ entries = readdirSync2(resolve2(cwd, candidate), { withFileTypes: true });
285
+ } catch {
286
+ continue;
287
+ }
288
+ if (entries.some((e) => e.isFile() && ADR_FILE_PATTERN.test(e.name))) {
289
+ return { adrDir: candidate, source: "detected" };
491
290
  }
492
291
  }
493
- if ("children" in node && Array.isArray(node.children)) {
494
- for (const child of node.children) walkHtml(child, out);
495
- }
292
+ return { adrDir: "docs/adr", source: "fallback" };
496
293
  }
497
- function collectDirectives(ast, body) {
498
- const all = [];
294
+ function detectFileVersion(content) {
295
+ let parsed;
296
+ try {
297
+ parsed = parseFile(content);
298
+ } catch {
299
+ return null;
300
+ }
301
+ if (parsed.frontmatter) {
302
+ return "decision-makers" in parsed.frontmatter ? "v4" : "v3";
303
+ }
304
+ if (parsed.listMetadata) return "v2";
305
+ return null;
306
+ }
307
+ function detectMadrVersion(cwd, adrDir) {
308
+ const dir = resolve2(cwd, adrDir);
309
+ let entries;
310
+ try {
311
+ entries = readdirSync2(dir, { withFileTypes: true });
312
+ } catch {
313
+ return "auto";
314
+ }
315
+ const sample = entries.filter((e) => e.isFile() && ADR_FILE_PATTERN.test(e.name)).map((e) => e.name).toSorted().slice(0, 20);
316
+ const votes = { v2: 0, v3: 0, v4: 0 };
317
+ for (const name of sample) {
318
+ let content;
319
+ try {
320
+ content = readFileSync2(join2(dir, name), "utf8");
321
+ } catch {
322
+ continue;
323
+ }
324
+ const version = detectFileVersion(content);
325
+ if (version) votes[version]++;
326
+ }
327
+ const max = Math.max(votes.v2, votes.v3, votes.v4);
328
+ if (max === 0) return "auto";
329
+ const winners = ["v2", "v3", "v4"].filter(
330
+ (v) => votes[v] === max
331
+ );
332
+ return winners.length === 1 ? winners[0] : "auto";
333
+ }
334
+ var CONFIG_FILENAME_BY_FORMAT = {
335
+ ts: "madr-lint.config.ts",
336
+ json: ".madrlintrc.json"
337
+ };
338
+ function detectConfigFormat(cwd) {
339
+ if (existsSync3(resolve2(cwd, "tsconfig.json"))) return "ts";
340
+ try {
341
+ const pkg2 = JSON.parse(
342
+ readFileSync2(resolve2(cwd, "package.json"), "utf8")
343
+ );
344
+ if (pkg2.dependencies?.["typescript"] !== void 0 || pkg2.devDependencies?.["typescript"] !== void 0) {
345
+ return "ts";
346
+ }
347
+ } catch {
348
+ }
349
+ return "json";
350
+ }
351
+ function findExistingConfigFile(cwd) {
352
+ for (const name of CONFIG_FILES) {
353
+ if (existsSync3(resolve2(cwd, name))) return name;
354
+ }
355
+ return null;
356
+ }
357
+ function tsQuote(value) {
358
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
359
+ }
360
+ function renderConfig(format, options) {
361
+ if (format === "json") {
362
+ const config = {
363
+ extends: ["madr-lint:recommended"],
364
+ adrDir: options.adrDir
365
+ };
366
+ if (options.madrVersion !== "auto") config.madrVersion = options.madrVersion;
367
+ return `${JSON.stringify(config, null, 2)}
368
+ `;
369
+ }
370
+ const versionLine = options.madrVersion !== "auto" ? `
371
+ madrVersion: ${tsQuote(options.madrVersion)},` : "";
372
+ return `import { defineConfig } from 'madr-lint';
373
+
374
+ export default defineConfig({
375
+ extends: ['madr-lint:recommended'],
376
+ adrDir: ${tsQuote(options.adrDir)},${versionLine}
377
+ });
378
+ `;
379
+ }
380
+ function buildEpilogue(input) {
381
+ const dirNote = input.adrDirSource === "detected" ? "(detected)" : input.adrDirSource === "override" ? "(from --dir)" : input.filesChecked === 0 ? "(default \u2014 no existing ADRs found, so init created nothing yet; add your first ADR here)" : "(default \u2014 no NNNN-*.md at its top level, though the initial lint found Markdown files nested inside)";
382
+ const lines = [
383
+ `Wrote ${input.configPath}`,
384
+ "",
385
+ ` ADR directory: ${input.adrDir} ${dirNote}`,
386
+ "",
387
+ "Next steps:",
388
+ " - Lint your ADRs: npx madr-lint"
389
+ ];
390
+ const total = input.errors + input.warnings;
391
+ if (input.filesChecked > 0 && total > 0) {
392
+ lines.push(
393
+ ` - The initial lint found ${input.errors} error(s) and ${input.warnings} warning(s) across ${input.filesChecked} file(s).`,
394
+ " Adopting on a legacy repo? Snapshot the debt so only new violations fail the build:",
395
+ " npx madr-lint --update-baseline"
396
+ );
397
+ }
398
+ lines.push(` - Docs: ${GETTING_STARTED_URL}`);
399
+ return lines.join("\n");
400
+ }
401
+
402
+ // src/core/lint.ts
403
+ import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
404
+ import { isAbsolute, relative, resolve as resolve3, sep } from "path";
405
+
406
+ // src/core/baseline.ts
407
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
408
+ import { dirname, join as join3 } from "path";
409
+
410
+ // src/core/types.ts
411
+ var INTERNAL_ERROR_RULE_NAME = "core/internal-error";
412
+ function isProjectRule(rule9) {
413
+ return "check" in rule9;
414
+ }
415
+
416
+ // src/core/baseline.ts
417
+ var BASELINE_VERSION = 1;
418
+ var BASELINE_DISPLAY_PATH = ".madr-lint/baseline.json";
419
+ function baselinePath(cwd) {
420
+ return join3(cwd, ".madr-lint", "baseline.json");
421
+ }
422
+ function nullProtoMap() {
423
+ return /* @__PURE__ */ Object.create(null);
424
+ }
425
+ function buildBaseline(diagnostics) {
426
+ const entries = nullProtoMap();
427
+ for (const d of diagnostics) {
428
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) continue;
429
+ const byRule = entries[d.path] ??= nullProtoMap();
430
+ const byMessage = byRule[d.ruleName] ??= nullProtoMap();
431
+ byMessage[d.messageId] = (byMessage[d.messageId] ?? 0) + 1;
432
+ }
433
+ return { version: BASELINE_VERSION, entries };
434
+ }
435
+ function serializeBaseline(baseline) {
436
+ const sortedEntries = nullProtoMap();
437
+ for (const path of Object.keys(baseline.entries).toSorted()) {
438
+ const byRule = baseline.entries[path] ?? {};
439
+ const sortedByRule = nullProtoMap();
440
+ for (const rule9 of Object.keys(byRule).toSorted()) {
441
+ const byMessage = byRule[rule9] ?? {};
442
+ const sortedByMessage = nullProtoMap();
443
+ for (const messageId of Object.keys(byMessage).toSorted()) {
444
+ sortedByMessage[messageId] = byMessage[messageId] ?? 0;
445
+ }
446
+ sortedByRule[rule9] = sortedByMessage;
447
+ }
448
+ sortedEntries[path] = sortedByRule;
449
+ }
450
+ const ordered = { version: baseline.version, entries: sortedEntries };
451
+ return JSON.stringify(ordered, null, 2) + "\n";
452
+ }
453
+ function loadBaseline(path) {
454
+ if (!existsSync4(path)) return null;
455
+ try {
456
+ const data = JSON.parse(readFileSync3(path, "utf8"));
457
+ if (typeof data.entries !== "object" || data.entries === null) return null;
458
+ return {
459
+ version: typeof data.version === "number" ? data.version : BASELINE_VERSION,
460
+ entries: rebuildEntries(data.entries)
461
+ };
462
+ } catch {
463
+ return null;
464
+ }
465
+ }
466
+ function rebuildEntries(raw) {
467
+ const entries = nullProtoMap();
468
+ for (const [path, rawByRule] of Object.entries(raw)) {
469
+ if (typeof rawByRule !== "object" || rawByRule === null) continue;
470
+ const byRule = nullProtoMap();
471
+ for (const [rule9, rawByMessage] of Object.entries(rawByRule)) {
472
+ if (typeof rawByMessage !== "object" || rawByMessage === null) continue;
473
+ const byMessage = nullProtoMap();
474
+ for (const [messageId, count] of Object.entries(
475
+ rawByMessage
476
+ )) {
477
+ if (typeof count === "number") byMessage[messageId] = count;
478
+ }
479
+ byRule[rule9] = byMessage;
480
+ }
481
+ entries[path] = byRule;
482
+ }
483
+ return entries;
484
+ }
485
+ function writeBaseline(path, baseline) {
486
+ mkdirSync(dirname(path), { recursive: true });
487
+ writeFileSync(path, serializeBaseline(baseline), "utf8");
488
+ }
489
+ var KEY_SEP = "\0";
490
+ function fingerprint(d) {
491
+ return d.path + KEY_SEP + d.ruleName + KEY_SEP + d.messageId;
492
+ }
493
+ function applyBaseline(diagnostics, baseline) {
494
+ const remaining = /* @__PURE__ */ new Map();
495
+ for (const [path, byRule] of Object.entries(baseline.entries)) {
496
+ for (const [rule9, byMessage] of Object.entries(byRule)) {
497
+ if (rule9 === INTERNAL_ERROR_RULE_NAME) continue;
498
+ for (const [messageId, count] of Object.entries(byMessage)) {
499
+ remaining.set(path + KEY_SEP + rule9 + KEY_SEP + messageId, count);
500
+ }
501
+ }
502
+ }
503
+ const kept = [];
504
+ let hidden = 0;
505
+ for (const d of diagnostics) {
506
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) {
507
+ kept.push(d);
508
+ continue;
509
+ }
510
+ const key = fingerprint(d);
511
+ const left = remaining.get(key);
512
+ if (left !== void 0 && left > 0) {
513
+ remaining.set(key, left - 1);
514
+ hidden++;
515
+ } else {
516
+ kept.push(d);
517
+ }
518
+ }
519
+ return { kept, hidden };
520
+ }
521
+ function totalCount(baseline) {
522
+ let total = 0;
523
+ for (const byRule of Object.values(baseline.entries)) {
524
+ for (const byMessage of Object.values(byRule)) {
525
+ for (const count of Object.values(byMessage)) total += count;
526
+ }
527
+ }
528
+ return total;
529
+ }
530
+ function baselineWriteSummary(baseline) {
531
+ const violations = totalCount(baseline);
532
+ const files = Object.keys(baseline.entries).length;
533
+ const v = `${violations} ${violations === 1 ? "violation" : "violations"}`;
534
+ const f = `${files} ${files === 1 ? "file" : "files"}`;
535
+ return `Wrote ${v} across ${f} to ${BASELINE_DISPLAY_PATH}`;
536
+ }
537
+ function baselineMalformedWarning() {
538
+ return `madr-lint: warning: ${BASELINE_DISPLAY_PATH} is malformed; ignoring it (run --update-baseline to regenerate)`;
539
+ }
540
+ function baselineHiddenSummary(hidden) {
541
+ return `${hidden} ${hidden === 1 ? "problem" : "problems"} hidden by baseline (${BASELINE_DISPLAY_PATH})`;
542
+ }
543
+
544
+ // src/core/cache.ts
545
+ import { createHash } from "crypto";
546
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
547
+ import { dirname as dirname2, join as join4 } from "path";
548
+ var CACHE_SCHEMA_VERSION = 3;
549
+ function computeContentHash(content) {
550
+ return createHash("sha1").update(content, "utf8").digest("hex");
551
+ }
552
+ function computeConfigHash(config) {
553
+ return createHash("sha1").update(stableStringify(config), "utf8").digest("hex");
554
+ }
555
+ function stableStringify(value) {
556
+ if (value === null || typeof value !== "object") {
557
+ return JSON.stringify(value);
558
+ }
559
+ if (Array.isArray(value)) {
560
+ return "[" + value.map(stableStringify).join(",") + "]";
561
+ }
562
+ const entries = Object.entries(value).toSorted(
563
+ ([a], [b]) => a < b ? -1 : a > b ? 1 : 0
564
+ );
565
+ return "{" + entries.map(([k, v]) => JSON.stringify(k) + ":" + stableStringify(v)).join(",") + "}";
566
+ }
567
+ function manifestPath(cacheDir) {
568
+ return join4(cacheDir, "manifest.json");
569
+ }
570
+ function loadManifest(path) {
571
+ if (!existsSync5(path)) return null;
572
+ try {
573
+ const data = JSON.parse(readFileSync4(path, "utf8"));
574
+ if (
575
+ // Single invalidation point for schema drift: an unknown (or absent)
576
+ // schema version nulls here, so every consumer sees a cold cache — no
577
+ // scattered re-hydration of stale-shape entries downstream.
578
+ data.schemaVersion !== CACHE_SCHEMA_VERSION || typeof data.version !== "string" || typeof data.configHash !== "string" || typeof data.files !== "object" || data.files === null
579
+ ) {
580
+ return null;
581
+ }
582
+ return data;
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
587
+ function saveManifest(path, manifest) {
588
+ mkdirSync2(dirname2(path), { recursive: true });
589
+ writeFileSync2(path, JSON.stringify(manifest), "utf8");
590
+ }
591
+
592
+ // src/core/fix.ts
593
+ var MAX_FIX_PASSES = 10;
594
+ function makeFixer(base) {
595
+ const shift = (n) => n + base;
596
+ return {
597
+ replaceRange(range, text) {
598
+ return { range: [shift(range[0]), shift(range[1])], text };
599
+ },
600
+ insertAt(offset, text) {
601
+ const at = shift(offset);
602
+ return { range: [at, at], text };
603
+ },
604
+ remove(range) {
605
+ return { range: [shift(range[0]), shift(range[1])], text: "" };
606
+ }
607
+ };
608
+ }
609
+ function applyEditsCounted(content, edits) {
610
+ const valid = edits.filter((e) => {
611
+ const [start, end] = e.range;
612
+ return Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start && end <= content.length;
613
+ });
614
+ const sorted = valid.toSorted(
615
+ (a, b) => a.range[0] - b.range[0] || a.range[1] - b.range[1]
616
+ );
617
+ let out = "";
618
+ let cursor = 0;
619
+ let applied = 0;
620
+ for (const edit of sorted) {
621
+ const [start, end] = edit.range;
622
+ if (start < cursor) continue;
623
+ out += content.slice(cursor, start) + edit.text;
624
+ cursor = end;
625
+ applied++;
626
+ }
627
+ return { text: out + content.slice(cursor), applied };
628
+ }
629
+ function collectFixes(diagnostics, content) {
630
+ const fixer = makeFixer(frontmatterOffset(content));
631
+ const edits = [];
632
+ for (const d of diagnostics) {
633
+ if (!d.fix) continue;
634
+ const result = d.fix(fixer);
635
+ if (result === null) continue;
636
+ if (Array.isArray(result)) edits.push(...result);
637
+ else edits.push(result);
638
+ }
639
+ return edits;
640
+ }
641
+ function collectProjectFixes(diagnostics, contentByPath) {
642
+ const fixer = makeFixer(0);
643
+ const byPath = /* @__PURE__ */ new Map();
644
+ for (const d of diagnostics) {
645
+ if (!d.fix) continue;
646
+ if (byPath.has(d.path)) continue;
647
+ if (contentByPath(d.path) === void 0) continue;
648
+ const result = d.fix(fixer);
649
+ if (result === null) continue;
650
+ byPath.set(d.path, Array.isArray(result) ? [...result] : [result]);
651
+ }
652
+ return byPath;
653
+ }
654
+ function fixFileContent(originalContent, lint, maxPasses = MAX_FIX_PASSES) {
655
+ let content = originalContent;
656
+ let passes = 0;
657
+ let applied = 0;
658
+ for (; ; ) {
659
+ const diagnostics = lint(content);
660
+ const done = () => ({
661
+ fixedContent: content,
662
+ remaining: diagnostics,
663
+ changed: content !== originalContent,
664
+ passes,
665
+ applied
666
+ });
667
+ if (passes >= maxPasses) return done();
668
+ const edits = collectFixes(diagnostics, content);
669
+ if (edits.length === 0) return done();
670
+ const { text: next, applied: landed } = applyEditsCounted(content, edits);
671
+ if (next === content) return done();
672
+ applied += landed;
673
+ content = next;
674
+ passes++;
675
+ }
676
+ }
677
+ var CONTEXT = 3;
678
+ function unifiedDiff(path, before, after) {
679
+ if (before === after) return "";
680
+ const a = splitLines(before);
681
+ const b = splitLines(after);
682
+ const ops = diffLines(a, b);
683
+ const hunks = groupHunks(ops);
684
+ if (hunks.length === 0) return "";
685
+ const lines = [`--- a/${path}`, `+++ b/${path}`];
686
+ for (const hunk of hunks) lines.push(...renderHunk(hunk));
687
+ return lines.join("\n") + "\n";
688
+ }
689
+ function splitLines(text) {
690
+ if (text === "") return [];
691
+ const parts = text.split("\n");
692
+ if (parts[parts.length - 1] === "") parts.pop();
693
+ return parts;
694
+ }
695
+ function diffLines(a, b) {
696
+ const n = a.length;
697
+ const m = b.length;
698
+ const lcs = Array.from(
699
+ { length: n + 1 },
700
+ () => Array.from({ length: m + 1 }).fill(0)
701
+ );
702
+ for (let i2 = n - 1; i2 >= 0; i2--) {
703
+ for (let j2 = m - 1; j2 >= 0; j2--) {
704
+ lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
705
+ }
706
+ }
707
+ const ops = [];
708
+ let i = 0;
709
+ let j = 0;
710
+ while (i < n && j < m) {
711
+ if (a[i] === b[j]) {
712
+ ops.push({ kind: "eq", text: a[i] });
713
+ i++;
714
+ j++;
715
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
716
+ ops.push({ kind: "del", text: a[i] });
717
+ i++;
718
+ } else {
719
+ ops.push({ kind: "ins", text: b[j] });
720
+ j++;
721
+ }
722
+ }
723
+ while (i < n) ops.push({ kind: "del", text: a[i++] });
724
+ while (j < m) ops.push({ kind: "ins", text: b[j++] });
725
+ return ops;
726
+ }
727
+ function groupHunks(ops) {
728
+ const changeIdx = [];
729
+ ops.forEach((op, idx) => {
730
+ if (op.kind !== "eq") changeIdx.push(idx);
731
+ });
732
+ if (changeIdx.length === 0) return [];
733
+ const windows = [];
734
+ for (const idx of changeIdx) {
735
+ const start = Math.max(0, idx - CONTEXT);
736
+ const end = Math.min(ops.length - 1, idx + CONTEXT);
737
+ const last = windows[windows.length - 1];
738
+ if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
739
+ else windows.push([start, end]);
740
+ }
741
+ const hunks = [];
742
+ for (const [start, end] of windows) {
743
+ let aLine = 1;
744
+ let bLine = 1;
745
+ for (let k = 0; k < start; k++) {
746
+ if (ops[k].kind !== "ins") aLine++;
747
+ if (ops[k].kind !== "del") bLine++;
748
+ }
749
+ const hunkOps = ops.slice(start, end + 1);
750
+ let aCount = 0;
751
+ let bCount = 0;
752
+ for (const op of hunkOps) {
753
+ if (op.kind !== "ins") aCount++;
754
+ if (op.kind !== "del") bCount++;
755
+ }
756
+ hunks.push({ aStart: aLine, aCount, bStart: bLine, bCount, ops: hunkOps });
757
+ }
758
+ return hunks;
759
+ }
760
+ function renderHunk(hunk) {
761
+ const header = `@@ -${hunk.aStart},${hunk.aCount} +${hunk.bStart},${hunk.bCount} @@`;
762
+ const body = hunk.ops.map((op) => {
763
+ const prefix = op.kind === "eq" ? " " : op.kind === "del" ? "-" : "+";
764
+ return `${prefix}${op.text}`;
765
+ });
766
+ return [header, ...body];
767
+ }
768
+
769
+ // src/core/runner.ts
770
+ import Ajv from "ajv";
771
+
772
+ // src/core/interpolate.ts
773
+ function interpolate(template, data) {
774
+ if (!template.includes("{{")) return template;
775
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
776
+ const v = data[key];
777
+ return v !== void 0 ? String(v) : `{{${key}}}`;
778
+ });
779
+ }
780
+
781
+ // src/core/suppression.ts
782
+ var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
783
+ "disable-file",
784
+ "disable",
785
+ "enable",
786
+ "disable-next-line"
787
+ ]);
788
+ var DIRECTIVE_PREFIX = "madr-lint-";
789
+ function parseDirective(value, line) {
790
+ const trimmed = value.trim();
791
+ if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
792
+ const inner = trimmed.slice(4, -3).trim();
793
+ if (inner.includes("<!--") || inner.includes("-->")) return null;
794
+ if (!inner.startsWith(DIRECTIVE_PREFIX)) return null;
795
+ const afterPrefix = inner.slice(DIRECTIVE_PREFIX.length);
796
+ const wsIndex = afterPrefix.search(/\s/);
797
+ const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
798
+ if (!DIRECTIVE_KINDS.has(keyword)) return null;
799
+ const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
800
+ return { kind: keyword, rules: parseRuleList(rest), line };
801
+ }
802
+ function parseRuleList(rest) {
803
+ const trimmed = rest.trim();
804
+ if (trimmed === "") return null;
805
+ const rules = trimmed.split(",").map((s) => s.trim()).filter((s) => s !== "");
806
+ return rules.length > 0 ? rules : null;
807
+ }
808
+ function walkHtml(node, out) {
809
+ if (node.type === "html") {
810
+ const line = node.position?.start.line;
811
+ if (typeof line === "number") {
812
+ const directive = parseDirective(node.value, line);
813
+ if (directive) out.push(directive);
814
+ }
815
+ }
816
+ if ("children" in node && Array.isArray(node.children)) {
817
+ for (const child of node.children) walkHtml(child, out);
818
+ }
819
+ }
820
+ function collectDirectives(ast, body) {
821
+ const all = [];
499
822
  walkHtml(ast, all);
500
823
  if (all.length === 0) return null;
501
824
  let lines = null;
@@ -609,6 +932,9 @@ var PerFileRuleContext = class {
609
932
  get metadataLoc() {
610
933
  return this.ensureParsed().metadataLoc;
611
934
  }
935
+ get metadataValueLoc() {
936
+ return this.ensureParsed().metadataValueLoc;
937
+ }
612
938
  };
613
939
  function runRulesOnFile(rules, file, runtime = {}) {
614
940
  const diagnostics = [];
@@ -636,18 +962,26 @@ function runRulesOnFile(rules, file, runtime = {}) {
636
962
  const ruleName = rule9.meta.name;
637
963
  const suggestions = rule9.meta.suggestions;
638
964
  const docsUrl = rule9.meta.docs.url ?? "";
965
+ const fixableRule = rule9.meta.fixable === "code";
639
966
  const context = new PerFileRuleContext(
640
967
  file,
641
968
  mergedOptions,
642
969
  ensureParsed,
643
970
  (d) => {
644
971
  const template = suggestions?.[d.messageId];
972
+ const hasFix = d.fix !== void 0;
973
+ if (hasFix && !fixableRule) {
974
+ throw new Error(
975
+ `Rule "${ruleName}" attached a fix but meta.fixable is not 'code'`
976
+ );
977
+ }
645
978
  diagnostics.push({
646
979
  ruleName,
647
980
  severity,
648
981
  path: file.path,
649
982
  suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
650
983
  docsUrl,
984
+ fixable: hasFix,
651
985
  ...d
652
986
  });
653
987
  }
@@ -684,6 +1018,7 @@ function internalErrorDiagnostic(ruleName, operation, err, path) {
684
1018
  path,
685
1019
  suggestion: null,
686
1020
  docsUrl: INTERNAL_ERROR_DOCS_URL,
1021
+ fixable: false,
687
1022
  data: {
688
1023
  rule: ruleName,
689
1024
  operation,
@@ -761,17 +1096,25 @@ function runRulesOnProject(rules, files, runtime = {}) {
761
1096
  const ruleName = rule9.meta.name;
762
1097
  const suggestions = rule9.meta.suggestions;
763
1098
  const docsUrl = rule9.meta.docs.url ?? "";
1099
+ const fixableRule = rule9.meta.fixable === "code";
764
1100
  const context = {
765
1101
  files,
766
1102
  options: mergedOptions,
767
1103
  fileExists: runtime.fileExists,
768
1104
  report(d) {
769
1105
  const template = suggestions?.[d.messageId];
1106
+ const hasFix = d.fix !== void 0;
1107
+ if (hasFix && !fixableRule) {
1108
+ throw new Error(
1109
+ `Rule "${ruleName}" attached a fix but meta.fixable is not 'code'`
1110
+ );
1111
+ }
770
1112
  diagnostics.push({
771
1113
  ruleName,
772
1114
  severity,
773
1115
  suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
774
1116
  docsUrl,
1117
+ fixable: hasFix,
775
1118
  ...d
776
1119
  });
777
1120
  }
@@ -796,7 +1139,7 @@ function lintFiles(opts) {
796
1139
  const allDiagnostics = [];
797
1140
  const fileEntries = opts.files.map((absolutePath) => ({
798
1141
  relativePath: toPosix(relative(opts.cwd, absolutePath)),
799
- content: readFileSync4(absolutePath, "utf8")
1142
+ content: readFileSync5(absolutePath, "utf8")
800
1143
  }));
801
1144
  const perFileRules = [];
802
1145
  const projectRules = [];
@@ -860,55 +1203,14 @@ function lintFiles(opts) {
860
1203
  const projectFiles = fileEntries.map(
861
1204
  (f) => buildProjectFile({ path: f.relativePath, content: f.content })
862
1205
  );
863
- const projectRoot = resolve2(opts.cwd);
864
- const existsCache = /* @__PURE__ */ new Map();
865
- const fileExists = (resolvedPath) => {
866
- const cached = existsCache.get(resolvedPath);
867
- if (cached !== void 0) return cached;
868
- const abs = resolve2(projectRoot, resolvedPath);
869
- const rel = relative(projectRoot, abs);
870
- const escapesRoot = rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
871
- let result;
872
- if (escapesRoot) {
873
- result = false;
874
- } else {
875
- try {
876
- result = statSync2(abs, { throwIfNoEntry: false })?.isFile() ?? false;
877
- } catch {
878
- result = false;
879
- }
880
- }
881
- existsCache.set(resolvedPath, result);
882
- return result;
883
- };
884
- const errorRules = [];
885
- const warnRules = [];
886
- for (const rule9 of enabledProjectRules) {
887
- const sev = resolveSeverity(opts.ruleSeverity[rule9.meta.name]);
888
- if (sev === "error") errorRules.push(rule9);
889
- else if (sev === "warn") warnRules.push(rule9);
890
- }
891
- const projectDiagnostics = [];
892
- if (errorRules.length > 0) {
893
- projectDiagnostics.push(
894
- ...runRulesOnProject(errorRules, projectFiles, {
895
- severity: "error",
896
- fileExists,
897
- optionsByRule
898
- })
899
- );
900
- }
901
- if (warnRules.length > 0) {
902
- projectDiagnostics.push(
903
- ...runRulesOnProject(warnRules, projectFiles, {
904
- severity: "warn",
905
- fileExists,
906
- optionsByRule
907
- })
908
- );
909
- }
910
1206
  allDiagnostics.push(
911
- ...filterSuppressedProjectDiagnostics(projectDiagnostics, projectFiles)
1207
+ ...runProjectPass(
1208
+ projectFiles,
1209
+ enabledProjectRules,
1210
+ opts.ruleSeverity,
1211
+ optionsByRule,
1212
+ opts.cwd
1213
+ )
912
1214
  );
913
1215
  }
914
1216
  if (manifest && opts.cache) {
@@ -930,241 +1232,225 @@ function lintFiles(opts) {
930
1232
  baselineHidden: applied.hidden
931
1233
  };
932
1234
  }
933
- function runPerFileRulesForFile(perFileRules, ruleSeverity, optionsByRule, file) {
934
- const fileContext = {
935
- content: file.content,
936
- path: file.relativePath
937
- };
938
- const errorRules = [];
939
- const warnRules = [];
940
- for (const rule9 of perFileRules) {
941
- const sev = resolveSeverity(ruleSeverity[rule9.meta.name]);
942
- if (sev === "off") continue;
943
- if (sev === "error") errorRules.push(rule9);
944
- else warnRules.push(rule9);
945
- }
946
- const diagnostics = [];
947
- if (errorRules.length > 0) {
948
- diagnostics.push(
949
- ...runRulesOnFile(errorRules, fileContext, {
950
- severity: "error",
951
- optionsByRule
952
- })
953
- );
954
- }
955
- if (warnRules.length > 0) {
956
- diagnostics.push(
957
- ...runRulesOnFile(warnRules, fileContext, {
958
- severity: "warn",
959
- optionsByRule
960
- })
961
- );
962
- }
963
- return diagnostics;
964
- }
965
- function filterSuppressedProjectDiagnostics(diagnostics, projectFiles) {
966
- if (diagnostics.length === 0) return [];
967
- const fileByPath = new Map(projectFiles.map((f) => [f.path, f]));
968
- const indexByPath = /* @__PURE__ */ new Map();
969
- const indexFor = (path) => {
970
- const cached = indexByPath.get(path);
971
- if (cached !== void 0) return cached;
972
- const file = fileByPath.get(path);
973
- const index = file && file.content.includes(DIRECTIVE_PREFIX) ? collectDirectives(file.ast, file.body) : null;
974
- indexByPath.set(path, index);
975
- return index;
976
- };
977
- return diagnostics.filter((d) => {
978
- if (d.ruleName === INTERNAL_ERROR_RULE_NAME) return true;
979
- const index = indexFor(d.path);
980
- if (!index) return true;
981
- return !isSuppressed(index, d.ruleName, d.loc?.line);
982
- });
983
- }
984
- function resolveSeverity(config) {
985
- if (config === void 0) return "off";
986
- if (typeof config === "string") return config;
987
- return config[0];
988
- }
989
- function extractOptionsByRule(ruleSeverity) {
990
- const map = {};
991
- for (const [name, config] of Object.entries(ruleSeverity)) {
992
- if (Array.isArray(config) && config[1]) map[name] = config[1];
1235
+ function lintAndFix(opts) {
1236
+ const perFileRules = [];
1237
+ const projectRules = [];
1238
+ for (const rule9 of opts.rules) {
1239
+ if (isProjectRule(rule9)) projectRules.push(rule9);
1240
+ else perFileRules.push(rule9);
993
1241
  }
994
- return map;
995
- }
996
-
997
- // src/core/reporter.ts
998
- import pc from "picocolors";
999
- var textReporter = {
1000
- // Deliberately 2-param (meta unused): the CLI prints baselineHiddenSummary()
1001
- // itself for text format, so the footer can outlive a suppressed banner.
1002
- format(diagnostics, rulesByName) {
1003
- if (diagnostics.length === 0) {
1004
- return pc.green("\u2713 All clear.");
1005
- }
1006
- const byFile = groupByFile(diagnostics);
1007
- const lines = [];
1008
- const gutter = " ".repeat(9 + 28 + 2);
1009
- for (const [file, fileDiagnostics] of byFile) {
1010
- lines.push(pc.underline(file));
1011
- const docsByRule = /* @__PURE__ */ new Map();
1012
- for (const d of fileDiagnostics) {
1013
- const sev = d.severity === "error" ? pc.red(" error ") : pc.yellow(" warn ");
1014
- const ruleId = pc.dim(d.ruleName.padEnd(28));
1015
- const message = renderMessage(d, rulesByName);
1016
- lines.push(`${sev}${ruleId} ${message}`);
1017
- if (d.suggestion) {
1018
- lines.push(`${gutter}${pc.cyan(`\u2192 ${d.suggestion}`)}`);
1019
- }
1020
- if (d.docsUrl && !docsByRule.has(d.ruleName)) {
1021
- docsByRule.set(d.ruleName, d.docsUrl);
1022
- }
1023
- }
1024
- for (const [ruleName, url] of docsByRule) {
1025
- lines.push(pc.dim(` ${ruleName} ${url}`));
1242
+ const optionsByRule = extractOptionsByRule(opts.ruleSeverity);
1243
+ const baseline = opts.baseline ?? null;
1244
+ const files = [];
1245
+ const remaining = [];
1246
+ let fixed = 0;
1247
+ let baselineHidden = 0;
1248
+ for (const absPath of opts.files) {
1249
+ const relativePath = toPosix(relative(opts.cwd, absPath));
1250
+ const original = readFileSync5(absPath, "utf8");
1251
+ let lastHidden = 0;
1252
+ const lint = (content) => {
1253
+ const raw = runPerFileRulesForFile(
1254
+ perFileRules,
1255
+ opts.ruleSeverity,
1256
+ optionsByRule,
1257
+ { relativePath, content }
1258
+ );
1259
+ if (!baseline) {
1260
+ lastHidden = 0;
1261
+ return raw;
1026
1262
  }
1027
- lines.push("");
1028
- }
1029
- const errors = diagnostics.filter((d) => d.severity === "error").length;
1030
- const warnings = diagnostics.filter((d) => d.severity === "warn").length;
1031
- lines.push(pc.bold(formatSummary(errors, warnings)));
1032
- return lines.join("\n");
1033
- }
1034
- };
1035
- function groupByFile(diagnostics) {
1036
- const map = /* @__PURE__ */ new Map();
1037
- for (const d of diagnostics) {
1038
- if (!map.has(d.path)) map.set(d.path, []);
1039
- map.get(d.path)?.push(d);
1040
- }
1041
- return map;
1042
- }
1043
- function renderMessage(d, rulesByName) {
1044
- const rule9 = rulesByName.get(d.ruleName);
1045
- const template = rule9?.meta.messages[d.messageId] ?? d.messageId;
1046
- return interpolate(template, d.data ?? {});
1047
- }
1048
- function formatSummary(errors, warnings) {
1049
- const parts = [];
1050
- if (errors > 0) parts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
1051
- if (warnings > 0) parts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
1052
- return parts.join(", ");
1053
- }
1054
- var jsonReporter = {
1055
- format(diagnostics, rulesByName, meta) {
1056
- const errors = diagnostics.filter((d) => d.severity === "error").length;
1057
- const warnings = diagnostics.filter((d) => d.severity === "warn").length;
1058
- const payload = {
1059
- version: 1,
1060
- summary: {
1061
- total: diagnostics.length,
1062
- errors,
1063
- warnings,
1064
- // Always present (0 when no baseline) so CI consumers can detect an
1065
- // active baseline without probing for the key. SARIF stays untouched.
1066
- baselineHidden: meta?.baselineHidden ?? 0
1067
- },
1068
- results: diagnostics.map((d) => ({
1069
- path: d.path,
1070
- ruleName: d.ruleName,
1071
- messageId: d.messageId,
1072
- severity: d.severity,
1073
- message: renderMessage(d, rulesByName),
1074
- // Self-contained diagnostics (#67): always present so consumers can
1075
- // rely on the keys — `suggestion` is null when the rule declares none.
1076
- suggestion: d.suggestion,
1077
- docsUrl: d.docsUrl,
1078
- data: d.data ?? {}
1079
- }))
1263
+ const sub = applyBaseline(raw, baseline);
1264
+ lastHidden = sub.hidden;
1265
+ return sub.kept;
1080
1266
  };
1081
- return JSON.stringify(payload, null, 2);
1082
- }
1083
- };
1084
- var sarifReporter = {
1085
- format(diagnostics, rulesByName) {
1086
- const ruleNames = [...new Set(diagnostics.map((d) => d.ruleName))];
1087
- const ruleIndex = new Map(ruleNames.map((name, i) => [name, i]));
1088
- const sarifRules = ruleNames.map((name) => {
1089
- const rule9 = rulesByName.get(name);
1090
- return {
1091
- id: name,
1092
- name: name.replace(/[^A-Za-z0-9]/g, "_"),
1093
- shortDescription: { text: rule9?.meta.docs.description ?? name },
1094
- helpUri: rule9?.meta.docs.url
1095
- };
1267
+ const res = fixFileContent(original, lint, opts.maxPasses);
1268
+ files.push({
1269
+ path: relativePath,
1270
+ absPath,
1271
+ original,
1272
+ fixed: res.fixedContent,
1273
+ changed: res.changed
1096
1274
  });
1097
- const sarifResults = diagnostics.map((d) => ({
1098
- ruleId: d.ruleName,
1099
- ruleIndex: ruleIndex.get(d.ruleName) ?? 0,
1100
- level: d.severity === "error" ? "error" : "warning",
1101
- message: { text: renderMessage(d, rulesByName) },
1102
- locations: [
1103
- {
1104
- physicalLocation: {
1105
- artifactLocation: { uri: d.path, uriBaseId: "%SRCROOT%" },
1106
- region: { startLine: 1 }
1107
- }
1108
- }
1109
- ]
1110
- }));
1111
- const sarif = {
1112
- $schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
1113
- version: "2.1.0",
1114
- runs: [
1115
- {
1116
- tool: {
1117
- driver: {
1118
- name: "madr-lint",
1119
- informationUri: "https://github.com/knktkc/madr-lint",
1120
- rules: sarifRules
1121
- }
1122
- },
1123
- results: sarifResults
1275
+ remaining.push(...res.remaining);
1276
+ fixed += res.applied;
1277
+ baselineHidden += lastHidden;
1278
+ }
1279
+ const enabledProjectRules = projectRules.filter(
1280
+ (rule9) => resolveSeverity(opts.ruleSeverity[rule9.meta.name]) !== "off"
1281
+ );
1282
+ if (enabledProjectRules.length > 0) {
1283
+ const contentByPath = new Map(files.map((f) => [f.path, f.fixed]));
1284
+ const maxPasses = opts.maxPasses ?? MAX_FIX_PASSES;
1285
+ let projectDiags = [];
1286
+ let passes = 0;
1287
+ for (; ; ) {
1288
+ const projectFiles = files.map(
1289
+ (f) => buildProjectFile({ path: f.path, content: contentByPath.get(f.path) ?? f.fixed })
1290
+ );
1291
+ let diags = runProjectPass(
1292
+ projectFiles,
1293
+ enabledProjectRules,
1294
+ opts.ruleSeverity,
1295
+ optionsByRule,
1296
+ opts.cwd
1297
+ );
1298
+ let hiddenThisPass = 0;
1299
+ if (baseline) {
1300
+ const sub = applyBaseline(diags, baseline);
1301
+ diags = sub.kept;
1302
+ hiddenThisPass = sub.hidden;
1303
+ }
1304
+ projectDiags = diags;
1305
+ if (passes >= maxPasses) {
1306
+ baselineHidden += hiddenThisPass;
1307
+ break;
1308
+ }
1309
+ const fixesByPath = collectProjectFixes(diags, (p) => contentByPath.get(p));
1310
+ let progressed = false;
1311
+ for (const [path, edits] of fixesByPath) {
1312
+ const before = contentByPath.get(path);
1313
+ if (before === void 0) continue;
1314
+ const { text, applied } = applyEditsCounted(before, edits);
1315
+ if (text !== before) {
1316
+ contentByPath.set(path, text);
1317
+ fixed += applied;
1318
+ progressed = true;
1124
1319
  }
1125
- ]
1126
- };
1127
- return JSON.stringify(sarif, null, 2);
1320
+ }
1321
+ if (!progressed) {
1322
+ baselineHidden += hiddenThisPass;
1323
+ break;
1324
+ }
1325
+ passes++;
1326
+ }
1327
+ for (const f of files) {
1328
+ const c = contentByPath.get(f.path);
1329
+ if (c !== void 0 && c !== f.fixed) {
1330
+ f.fixed = c;
1331
+ f.changed = f.fixed !== f.original;
1332
+ }
1333
+ }
1334
+ remaining.push(...projectDiags);
1128
1335
  }
1129
- };
1130
- function escapeGhMessage(s) {
1131
- return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
1336
+ return { files, diagnostics: remaining, fixed, baselineHidden };
1132
1337
  }
1133
- function escapeGhProperty(s) {
1134
- return escapeGhMessage(s).replace(/,/g, "%2C").replace(/:/g, "%3A");
1338
+ function runPerFileRulesForFile(perFileRules, ruleSeverity, optionsByRule, file) {
1339
+ const fileContext = {
1340
+ content: file.content,
1341
+ path: file.relativePath
1342
+ };
1343
+ const errorRules = [];
1344
+ const warnRules = [];
1345
+ for (const rule9 of perFileRules) {
1346
+ const sev = resolveSeverity(ruleSeverity[rule9.meta.name]);
1347
+ if (sev === "off") continue;
1348
+ if (sev === "error") errorRules.push(rule9);
1349
+ else warnRules.push(rule9);
1350
+ }
1351
+ const diagnostics = [];
1352
+ if (errorRules.length > 0) {
1353
+ diagnostics.push(
1354
+ ...runRulesOnFile(errorRules, fileContext, {
1355
+ severity: "error",
1356
+ optionsByRule
1357
+ })
1358
+ );
1359
+ }
1360
+ if (warnRules.length > 0) {
1361
+ diagnostics.push(
1362
+ ...runRulesOnFile(warnRules, fileContext, {
1363
+ severity: "warn",
1364
+ optionsByRule
1365
+ })
1366
+ );
1367
+ }
1368
+ return diagnostics;
1135
1369
  }
1136
- var githubReporter = {
1137
- format(diagnostics, rulesByName, meta) {
1138
- const lines = [];
1139
- for (const d of diagnostics) {
1140
- const level = d.severity === "error" ? "error" : "warning";
1141
- const file = escapeGhProperty(d.path);
1142
- const title = escapeGhProperty(d.ruleName);
1143
- const rendered = renderMessage(d, rulesByName);
1144
- const message = escapeGhMessage(
1145
- d.suggestion ? `${rendered} \u2014 ${d.suggestion}` : rendered
1146
- );
1147
- const linePart = d.loc ? `,line=${d.loc.line}` : "";
1148
- lines.push(`::${level} file=${file}${linePart},title=${title}::${message}`);
1370
+ function makeFileExists(cwd) {
1371
+ const projectRoot = resolve3(cwd);
1372
+ const existsCache = /* @__PURE__ */ new Map();
1373
+ return (resolvedPath) => {
1374
+ const cached = existsCache.get(resolvedPath);
1375
+ if (cached !== void 0) return cached;
1376
+ const abs = resolve3(projectRoot, resolvedPath);
1377
+ const rel = relative(projectRoot, abs);
1378
+ const escapesRoot = rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
1379
+ let result;
1380
+ if (escapesRoot) {
1381
+ result = false;
1382
+ } else {
1383
+ try {
1384
+ result = statSync2(abs, { throwIfNoEntry: false })?.isFile() ?? false;
1385
+ } catch {
1386
+ result = false;
1387
+ }
1149
1388
  }
1150
- const errors = diagnostics.filter((d) => d.severity === "error").length;
1151
- const warnings = diagnostics.filter((d) => d.severity === "warn").length;
1152
- const summaryParts = [];
1153
- if (errors > 0) summaryParts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
1154
- if (warnings > 0) summaryParts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
1155
- if (summaryParts.length === 0) summaryParts.push("0 errors, 0 warnings");
1156
- const baselineHidden = meta?.baselineHidden ?? 0;
1157
- if (baselineHidden > 0) summaryParts.push(`${baselineHidden} hidden by baseline`);
1158
- lines.push(summaryParts.join(", "));
1159
- return lines.join("\n");
1389
+ existsCache.set(resolvedPath, result);
1390
+ return result;
1391
+ };
1392
+ }
1393
+ function runProjectPass(projectFiles, enabledProjectRules, ruleSeverity, optionsByRule, cwd) {
1394
+ const fileExists = makeFileExists(cwd);
1395
+ const errorRules = [];
1396
+ const warnRules = [];
1397
+ for (const rule9 of enabledProjectRules) {
1398
+ const sev = resolveSeverity(ruleSeverity[rule9.meta.name]);
1399
+ if (sev === "error") errorRules.push(rule9);
1400
+ else if (sev === "warn") warnRules.push(rule9);
1160
1401
  }
1161
- };
1162
- var reporters = {
1163
- text: textReporter,
1164
- json: jsonReporter,
1165
- sarif: sarifReporter,
1166
- github: githubReporter
1167
- };
1402
+ const projectDiagnostics = [];
1403
+ if (errorRules.length > 0) {
1404
+ projectDiagnostics.push(
1405
+ ...runRulesOnProject(errorRules, projectFiles, {
1406
+ severity: "error",
1407
+ fileExists,
1408
+ optionsByRule
1409
+ })
1410
+ );
1411
+ }
1412
+ if (warnRules.length > 0) {
1413
+ projectDiagnostics.push(
1414
+ ...runRulesOnProject(warnRules, projectFiles, {
1415
+ severity: "warn",
1416
+ fileExists,
1417
+ optionsByRule
1418
+ })
1419
+ );
1420
+ }
1421
+ return filterSuppressedProjectDiagnostics(projectDiagnostics, projectFiles);
1422
+ }
1423
+ function filterSuppressedProjectDiagnostics(diagnostics, projectFiles) {
1424
+ if (diagnostics.length === 0) return [];
1425
+ const fileByPath = new Map(projectFiles.map((f) => [f.path, f]));
1426
+ const indexByPath = /* @__PURE__ */ new Map();
1427
+ const indexFor = (path) => {
1428
+ const cached = indexByPath.get(path);
1429
+ if (cached !== void 0) return cached;
1430
+ const file = fileByPath.get(path);
1431
+ const index = file && file.content.includes(DIRECTIVE_PREFIX) ? collectDirectives(file.ast, file.body) : null;
1432
+ indexByPath.set(path, index);
1433
+ return index;
1434
+ };
1435
+ return diagnostics.filter((d) => {
1436
+ if (d.ruleName === INTERNAL_ERROR_RULE_NAME) return true;
1437
+ const index = indexFor(d.path);
1438
+ if (!index) return true;
1439
+ return !isSuppressed(index, d.ruleName, d.loc?.line);
1440
+ });
1441
+ }
1442
+ function resolveSeverity(config) {
1443
+ if (config === void 0) return "off";
1444
+ if (typeof config === "string") return config;
1445
+ return config[0];
1446
+ }
1447
+ function extractOptionsByRule(ruleSeverity) {
1448
+ const map = {};
1449
+ for (const [name, config] of Object.entries(ruleSeverity)) {
1450
+ if (Array.isArray(config) && config[1]) map[name] = config[1];
1451
+ }
1452
+ return map;
1453
+ }
1168
1454
 
1169
1455
  // src/rules/index.ts
1170
1456
  var rules_exports = {};
@@ -1209,6 +1495,58 @@ function isValidIso8601Date(s) {
1209
1495
  const d = new Date(Date.UTC(year, month - 1, day));
1210
1496
  return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
1211
1497
  }
1498
+ var MONTHS = {
1499
+ jan: 1,
1500
+ january: 1,
1501
+ feb: 2,
1502
+ february: 2,
1503
+ mar: 3,
1504
+ march: 3,
1505
+ apr: 4,
1506
+ april: 4,
1507
+ may: 5,
1508
+ jun: 6,
1509
+ june: 6,
1510
+ jul: 7,
1511
+ july: 7,
1512
+ aug: 8,
1513
+ august: 8,
1514
+ sep: 9,
1515
+ sept: 9,
1516
+ september: 9,
1517
+ oct: 10,
1518
+ october: 10,
1519
+ nov: 11,
1520
+ november: 11,
1521
+ dec: 12,
1522
+ december: 12
1523
+ };
1524
+ function pad(n, width) {
1525
+ return String(n).padStart(width, "0");
1526
+ }
1527
+ function buildIso(year, month, day) {
1528
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
1529
+ const iso = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
1530
+ return isValidIso8601Date(iso) ? iso : null;
1531
+ }
1532
+ function normalizeToIso(raw) {
1533
+ const s = raw.trim();
1534
+ const numeric = /^(\d{4})([-/.])(\d{1,2})\2(\d{1,2})$/.exec(s);
1535
+ if (numeric) {
1536
+ return buildIso(Number(numeric[1]), Number(numeric[3]), Number(numeric[4]));
1537
+ }
1538
+ const dMonY = /^(\d{1,2})\s+([A-Za-z]+)\.?\s+(\d{4})$/.exec(s);
1539
+ if (dMonY) {
1540
+ const month = MONTHS[dMonY[2].toLowerCase()];
1541
+ return month ? buildIso(Number(dMonY[3]), month, Number(dMonY[1])) : null;
1542
+ }
1543
+ const monDY = /^([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})$/.exec(s);
1544
+ if (monDY) {
1545
+ const month = MONTHS[monDY[1].toLowerCase()];
1546
+ return month ? buildIso(Number(monDY[3]), month, Number(monDY[2])) : null;
1547
+ }
1548
+ return null;
1549
+ }
1212
1550
  var rule = {
1213
1551
  meta: {
1214
1552
  name: "madr/date-iso8601",
@@ -1227,6 +1565,10 @@ var rule = {
1227
1565
  missingDate: 'add a "{{field}}" field in YYYY-MM-DD format to the frontmatter (for MADR v2, a "* Date: ..." list item)',
1228
1566
  invalidDate: "use the YYYY-MM-DD calendar-date format, e.g. 2025-03-14"
1229
1567
  },
1568
+ // Autofix (#29): normalize UNAMBIGUOUS shapes of a v2 list-sourced value —
1569
+ // year-first numeric and English named-month forms. Ambiguous day/month
1570
+ // order, invalid calendar dates, and frontmatter-sourced values get no fix.
1571
+ fixable: "code",
1230
1572
  defaultOptions: {
1231
1573
  field: "date"
1232
1574
  },
@@ -1253,10 +1595,14 @@ var rule = {
1253
1595
  }
1254
1596
  if (!isValidIso8601Date(dateStr)) {
1255
1597
  const loc = context.metadataLoc?.[fieldName];
1598
+ const valueRange = context.metadataValueLoc?.[fieldName];
1599
+ const normalized = valueRange ? normalizeToIso(dateStr) : null;
1600
+ const fix = normalized !== null && normalized !== dateStr && valueRange ? (fixer) => fixer.replaceRange([valueRange.start, valueRange.end], normalized) : void 0;
1256
1601
  context.report({
1257
1602
  messageId: "invalidDate",
1258
1603
  ...loc ? { loc } : {},
1259
- data: { date: dateStr }
1604
+ data: { date: dateStr },
1605
+ ...fix ? { fix } : {}
1260
1606
  });
1261
1607
  }
1262
1608
  }
@@ -1264,7 +1610,7 @@ var rule = {
1264
1610
  var date_iso8601_default = rule;
1265
1611
 
1266
1612
  // src/rules/filename-format/index.ts
1267
- import { basename as basename2 } from "path";
1613
+ import { basename } from "path";
1268
1614
 
1269
1615
  // src/core/regex-safety.ts
1270
1616
  import safeRegex from "safe-regex2";
@@ -1332,7 +1678,7 @@ var rule2 = {
1332
1678
  create(context) {
1333
1679
  const { pattern } = context.options;
1334
1680
  const regex = assertSafeRegex(pattern, "madr/filename-format", "pattern");
1335
- const filename = basename2(context.file.path);
1681
+ const filename = basename(context.file.path);
1336
1682
  if (!regex.test(filename)) {
1337
1683
  context.report({
1338
1684
  messageId: "invalidFilename",
@@ -1441,7 +1787,7 @@ var rule3 = {
1441
1787
  var no_broken_links_default = rule3;
1442
1788
 
1443
1789
  // src/rules/no-duplicate-numbering/index.ts
1444
- import { basename as basename3 } from "path";
1790
+ import { basename as basename2 } from "path";
1445
1791
 
1446
1792
  // src/rules/no-duplicate-numbering/schema.json
1447
1793
  var schema_default4 = {
@@ -1475,7 +1821,7 @@ var rule4 = {
1475
1821
  check(context) {
1476
1822
  const byNumber = /* @__PURE__ */ new Map();
1477
1823
  for (const file of context.files) {
1478
- const match = NUMBER_REGEX.exec(basename3(file.path));
1824
+ const match = NUMBER_REGEX.exec(basename2(file.path));
1479
1825
  if (!match) continue;
1480
1826
  const number = match[1];
1481
1827
  if (number === void 0) continue;
@@ -1500,7 +1846,7 @@ var rule4 = {
1500
1846
  var no_duplicate_numbering_default = rule4;
1501
1847
 
1502
1848
  // src/rules/no-numbering-gap/index.ts
1503
- import { basename as basename4 } from "path";
1849
+ import { basename as basename3 } from "path";
1504
1850
 
1505
1851
  // src/rules/no-numbering-gap/schema.json
1506
1852
  var schema_default5 = {
@@ -1537,7 +1883,7 @@ var rule5 = {
1537
1883
  check(context) {
1538
1884
  const numberToPath = /* @__PURE__ */ new Map();
1539
1885
  for (const file of context.files) {
1540
- const match = NUMBER_REGEX2.exec(basename4(file.path));
1886
+ const match = NUMBER_REGEX2.exec(basename3(file.path));
1541
1887
  if (!match?.[1]) continue;
1542
1888
  const n = Number.parseInt(match[1], 10);
1543
1889
  if (!Number.isNaN(n)) numberToPath.set(n, file.path);
@@ -1674,6 +2020,43 @@ var schema_default7 = {
1674
2020
  };
1675
2021
 
1676
2022
  // src/rules/status-enum/index.ts
2023
+ var TYPO_CORRECTIONS = {
2024
+ superceded: "superseded",
2025
+ supercedes: "supersedes",
2026
+ depricated: "deprecated"
2027
+ };
2028
+ function repairTypos(lower) {
2029
+ return lower.split(" ").map((tok) => TYPO_CORRECTIONS[tok] ?? tok).join(" ");
2030
+ }
2031
+ function tailAfterWords(s, n) {
2032
+ let i = 0;
2033
+ for (let w = 0; w < n; w++) {
2034
+ while (i < s.length && /\s/.test(s[i])) i++;
2035
+ while (i < s.length && !/\s/.test(s[i])) i++;
2036
+ }
2037
+ return s.slice(i);
2038
+ }
2039
+ function correctStatus(status, values, prefixValues) {
2040
+ const repaired = repairTypos(status.toLowerCase());
2041
+ const exact = [...new Set(values.filter((v) => v.toLowerCase() === repaired))];
2042
+ if (exact.length === 1) {
2043
+ return exact[0] !== status ? exact[0] : void 0;
2044
+ }
2045
+ if (exact.length > 1) return void 0;
2046
+ const prefixes = [
2047
+ ...new Set(
2048
+ prefixValues.filter((p) => {
2049
+ const pl = p.toLowerCase();
2050
+ return repaired === pl || repaired.startsWith(`${pl} `);
2051
+ })
2052
+ )
2053
+ ];
2054
+ if (prefixes.length !== 1) return void 0;
2055
+ const prefix = prefixes[0];
2056
+ const tail = tailAfterWords(status, prefix.split(" ").length);
2057
+ const replacement = `${prefix}${tail}`;
2058
+ return replacement !== status ? replacement : void 0;
2059
+ }
1677
2060
  var rule7 = {
1678
2061
  meta: {
1679
2062
  name: "madr/status-enum",
@@ -1694,6 +2077,11 @@ var rule7 = {
1694
2077
  suggestions: {
1695
2078
  missingStatus: 'add a "status" field to the frontmatter (for MADR v2, a "* Status: ..." list item) \u2014 allowed values: {{allowed}}'
1696
2079
  },
2080
+ // Autofix (#28/#29): normalize a v2 list-sourced value to the unique
2081
+ // canonical enum entry — case difference, a curated misspelling, or a prefix
2082
+ // case/typo — preserving any tail (see create()). Ambiguous or
2083
+ // frontmatter-sourced values get no fix.
2084
+ fixable: "code",
1697
2085
  defaultOptions: {
1698
2086
  values: ["proposed", "rejected", "accepted", "deprecated"],
1699
2087
  prefixValues: ["superseded by"],
@@ -1719,10 +2107,14 @@ var rule7 = {
1719
2107
  );
1720
2108
  if (!exactMatch && !prefixMatch) {
1721
2109
  const loc = context.metadataLoc?.status;
2110
+ const canonical = correctStatus(status, values, prefixValues);
2111
+ const valueRange = context.metadataValueLoc?.status;
2112
+ const fix = canonical !== void 0 && canonical !== status && valueRange ? (fixer) => fixer.replaceRange([valueRange.start, valueRange.end], canonical) : void 0;
1722
2113
  context.report({
1723
2114
  messageId: "invalidStatus",
1724
2115
  ...loc ? { loc } : {},
1725
- data: { status, allowed }
2116
+ data: { status, allowed },
2117
+ ...fix ? { fix } : {}
1726
2118
  });
1727
2119
  }
1728
2120
  }
@@ -1730,7 +2122,7 @@ var rule7 = {
1730
2122
  var status_enum_default = rule7;
1731
2123
 
1732
2124
  // src/rules/supersedes-bidirectional/index.ts
1733
- import { basename as basename5 } from "path";
2125
+ import { basename as basename4 } from "path";
1734
2126
 
1735
2127
  // src/rules/supersedes-bidirectional/schema.json
1736
2128
  var schema_default8 = {
@@ -1745,7 +2137,7 @@ var ADR_NUMBER_REGEX = /^(\d{4})-/;
1745
2137
  var FORWARD_FIELD = "supersedes";
1746
2138
  var BACKWARD_FIELD = "superseded-by";
1747
2139
  function extractAdrNumber(path) {
1748
- const match = ADR_NUMBER_REGEX.exec(basename5(path));
2140
+ const match = ADR_NUMBER_REGEX.exec(basename4(path));
1749
2141
  return match?.[1] ?? null;
1750
2142
  }
1751
2143
  function normalizeRefs(value) {
@@ -1755,6 +2147,28 @@ function normalizeRefs(value) {
1755
2147
  }
1756
2148
  return [];
1757
2149
  }
2150
+ function escapeRegExp(s) {
2151
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2152
+ }
2153
+ function frontmatterInsertEdit(content, key, value) {
2154
+ const fmEnd = frontmatterOffset(content);
2155
+ if (fmEnd === 0) return null;
2156
+ const fmBlock = content.slice(0, fmEnd);
2157
+ const closeMatch = /\r?\n(---[ \t]*\r?\n)$/.exec(fmBlock);
2158
+ if (!closeMatch) return null;
2159
+ const fenceLine = closeMatch[1];
2160
+ const offset = closeMatch.index + (closeMatch[0].length - fenceLine.length);
2161
+ if (content.slice(offset, offset + 3) !== "---") return null;
2162
+ const keyPattern = new RegExp(`(^|\\r?\\n)${escapeRegExp(key)}[ \\t]*:`, "i");
2163
+ if (keyPattern.test(content.slice(0, offset))) return null;
2164
+ const eol = fenceLine.endsWith("\r\n") ? "\r\n" : "\n";
2165
+ return { offset, text: `${key}: ${value}${eol}` };
2166
+ }
2167
+ function backReferenceFix(target, key, value) {
2168
+ const edit = frontmatterInsertEdit(target.content, key, value);
2169
+ if (!edit) return void 0;
2170
+ return (fixer) => fixer.insertAt(edit.offset, edit.text);
2171
+ }
1758
2172
  var rule8 = {
1759
2173
  meta: {
1760
2174
  name: "madr/supersedes-bidirectional",
@@ -1777,6 +2191,11 @@ var rule8 = {
1777
2191
  unknownReference: 'correct "{{ref}}" to an existing ADR number, or remove "{{direction}}: {{ref}}" from the frontmatter',
1778
2192
  missingBackReference: 'add "{{direction}}: {{expected}}" to the frontmatter of this file'
1779
2193
  },
2194
+ // Cross-file autofix (#29): `missingBackReference` inserts the reciprocal
2195
+ // `<direction>: <expected>` line into the target's EXISTING frontmatter.
2196
+ // Targets without frontmatter, or where the key already exists, decline;
2197
+ // `unknownReference` is contextual and never fixable.
2198
+ fixable: "code",
1780
2199
  defaultOptions: {},
1781
2200
  schema: schema_default8
1782
2201
  },
@@ -1804,50 +2223,381 @@ var rule8 = {
1804
2223
  });
1805
2224
  continue;
1806
2225
  }
1807
- const targetBack = normalizeRefs(target.frontmatter?.[BACKWARD_FIELD]);
1808
- if (!targetBack.includes(fileRef)) {
1809
- context.report({
1810
- messageId: "missingBackReference",
1811
- path: target.path,
1812
- data: {
1813
- ref,
1814
- source: file.path,
1815
- declared: FORWARD_FIELD,
1816
- direction: BACKWARD_FIELD,
1817
- expected: fileRef
1818
- }
1819
- });
2226
+ const targetBack = normalizeRefs(target.frontmatter?.[BACKWARD_FIELD]);
2227
+ if (!targetBack.includes(fileRef)) {
2228
+ const fix = backReferenceFix(target, BACKWARD_FIELD, fileRef);
2229
+ context.report({
2230
+ messageId: "missingBackReference",
2231
+ path: target.path,
2232
+ data: {
2233
+ ref,
2234
+ source: file.path,
2235
+ declared: FORWARD_FIELD,
2236
+ direction: BACKWARD_FIELD,
2237
+ expected: fileRef
2238
+ },
2239
+ ...fix ? { fix } : {}
2240
+ });
2241
+ }
2242
+ }
2243
+ for (const ref of backwardRefs) {
2244
+ const target = adrIndex.get(ref);
2245
+ if (!target) {
2246
+ context.report({
2247
+ messageId: "unknownReference",
2248
+ path: file.path,
2249
+ data: { ref, direction: BACKWARD_FIELD }
2250
+ });
2251
+ continue;
2252
+ }
2253
+ const targetFwd = normalizeRefs(target.frontmatter?.[FORWARD_FIELD]);
2254
+ if (!targetFwd.includes(fileRef)) {
2255
+ const fix = backReferenceFix(target, FORWARD_FIELD, fileRef);
2256
+ context.report({
2257
+ messageId: "missingBackReference",
2258
+ path: target.path,
2259
+ data: {
2260
+ ref,
2261
+ source: file.path,
2262
+ declared: BACKWARD_FIELD,
2263
+ direction: FORWARD_FIELD,
2264
+ expected: fileRef
2265
+ },
2266
+ ...fix ? { fix } : {}
2267
+ });
2268
+ }
2269
+ }
2270
+ }
2271
+ }
2272
+ };
2273
+ var supersedes_bidirectional_default = rule8;
2274
+
2275
+ // src/commands/init.ts
2276
+ var initCommand = defineCommand({
2277
+ meta: {
2278
+ name: "init",
2279
+ description: "Scaffold a madr-lint config file (detects ADR directory, MADR version, and TS vs JSON)"
2280
+ },
2281
+ args: {
2282
+ force: {
2283
+ type: "boolean",
2284
+ description: "Overwrite an existing config file",
2285
+ default: false
2286
+ },
2287
+ dir: {
2288
+ type: "string",
2289
+ description: "ADR directory to write into the config, overriding detection",
2290
+ required: false
2291
+ },
2292
+ json: {
2293
+ type: "boolean",
2294
+ description: "Emit a machine-readable JSON summary instead of text",
2295
+ default: false
2296
+ }
2297
+ },
2298
+ run({ args }) {
2299
+ const cwd = process.cwd();
2300
+ const rawDir = args.dir;
2301
+ if (rawDir !== void 0 && rawDir.trim() === "") {
2302
+ console.error(`Invalid --dir "${rawDir}": must be a non-empty path.`);
2303
+ process.exit(2);
2304
+ }
2305
+ const existing = findExistingConfigFile(cwd);
2306
+ if (existing && args.force !== true) {
2307
+ console.error(
2308
+ `A madr-lint config already exists: ${existing}. Re-run with --force to overwrite.`
2309
+ );
2310
+ process.exit(2);
2311
+ }
2312
+ let adrDir;
2313
+ let adrDirSource;
2314
+ if (rawDir !== void 0) {
2315
+ adrDir = rawDir;
2316
+ adrDirSource = "override";
2317
+ } else {
2318
+ const detected = detectAdrDir(cwd);
2319
+ adrDir = detected.adrDir;
2320
+ adrDirSource = detected.source;
2321
+ }
2322
+ const madrVersion = detectMadrVersion(cwd, adrDir);
2323
+ const configFormat = detectConfigFormat(cwd);
2324
+ const configPath = CONFIG_FILENAME_BY_FORMAT[configFormat];
2325
+ writeFileSync3(
2326
+ resolve4(cwd, configPath),
2327
+ renderConfig(configFormat, { adrDir, madrVersion })
2328
+ );
2329
+ if (existing && existing !== configPath) {
2330
+ const oldWins = CONFIG_FILES.indexOf(existing) < CONFIG_FILES.indexOf(configPath);
2331
+ console.error(
2332
+ oldWins ? `Note: ${existing} still exists and precedes ${configPath} in config discovery \u2014 remove it to make the new config take effect.` : `Note: the leftover ${existing} is now shadowed by ${configPath}, which precedes it in config discovery \u2014 you can delete it.`
2333
+ );
2334
+ }
2335
+ const files = findAdrFiles(resolve4(cwd, adrDir));
2336
+ let errors = 0;
2337
+ let warnings = 0;
2338
+ if (files.length > 0) {
2339
+ try {
2340
+ const config = resolveExtends({ extends: ["madr-lint:recommended"] });
2341
+ const allRules = Object.values(rules_exports);
2342
+ const result = lintFiles({
2343
+ rules: allRules,
2344
+ ruleSeverity: config.rules,
2345
+ files,
2346
+ cwd,
2347
+ cache: null,
2348
+ baseline: null
2349
+ });
2350
+ for (const d of result.diagnostics) {
2351
+ if (d.severity === "error") errors++;
2352
+ else warnings++;
2353
+ }
2354
+ } catch (err) {
2355
+ console.error(
2356
+ `Note: the initial lint failed (${err instanceof Error ? err.message : String(err)}); the config was still written.`
2357
+ );
2358
+ }
2359
+ }
2360
+ const filesChecked = files.length;
2361
+ const suggestUpdateBaseline = filesChecked > 0 && errors + warnings > 0;
2362
+ if (args.json === true) {
2363
+ console.log(
2364
+ JSON.stringify(
2365
+ {
2366
+ written: true,
2367
+ configPath,
2368
+ configFormat,
2369
+ adrDir,
2370
+ adrDirSource,
2371
+ madrVersion,
2372
+ filesChecked,
2373
+ errors,
2374
+ warnings,
2375
+ suggestUpdateBaseline,
2376
+ docsUrl: GETTING_STARTED_URL
2377
+ },
2378
+ null,
2379
+ 2
2380
+ )
2381
+ );
2382
+ } else {
2383
+ console.log(
2384
+ buildEpilogue({
2385
+ adrDir,
2386
+ adrDirSource,
2387
+ configPath,
2388
+ filesChecked,
2389
+ errors,
2390
+ warnings
2391
+ })
2392
+ );
2393
+ }
2394
+ process.exit(0);
2395
+ }
2396
+ });
2397
+
2398
+ // src/core/ignore.ts
2399
+ import { basename as basename5 } from "path";
2400
+ import picomatch from "picomatch";
2401
+ function shouldIgnore(relativePath, patterns) {
2402
+ if (patterns.length === 0) return false;
2403
+ const base = basename5(relativePath);
2404
+ for (const pattern of patterns) {
2405
+ if (matchesPattern(relativePath, base, pattern)) return true;
2406
+ }
2407
+ return false;
2408
+ }
2409
+ function matchesPattern(relativePath, base, pattern) {
2410
+ if (relativePath === pattern) return true;
2411
+ const matcher = picomatch(pattern, { dot: true });
2412
+ if (matcher(relativePath)) return true;
2413
+ if (matcher(base)) return true;
2414
+ if (pattern.includes("/") && !pattern.startsWith("**/")) {
2415
+ const suffixMatcher = picomatch(`**/${pattern}`, { dot: true });
2416
+ if (suffixMatcher(relativePath)) return true;
2417
+ }
2418
+ return false;
2419
+ }
2420
+
2421
+ // src/core/reporter.ts
2422
+ import pc from "picocolors";
2423
+ var textReporter = {
2424
+ // Deliberately 2-param (meta unused): the CLI prints baselineHiddenSummary()
2425
+ // itself for text format, so the footer can outlive a suppressed banner.
2426
+ format(diagnostics, rulesByName) {
2427
+ if (diagnostics.length === 0) {
2428
+ return pc.green("\u2713 All clear.");
2429
+ }
2430
+ const byFile = groupByFile(diagnostics);
2431
+ const lines = [];
2432
+ const gutter = " ".repeat(9 + 28 + 2);
2433
+ for (const [file, fileDiagnostics] of byFile) {
2434
+ lines.push(pc.underline(file));
2435
+ const docsByRule = /* @__PURE__ */ new Map();
2436
+ for (const d of fileDiagnostics) {
2437
+ const sev = d.severity === "error" ? pc.red(" error ") : pc.yellow(" warn ");
2438
+ const ruleId = pc.dim(d.ruleName.padEnd(28));
2439
+ const message = renderMessage(d, rulesByName);
2440
+ const fixTag = d.fixable ? pc.dim(" \u{1F527} fixable") : "";
2441
+ lines.push(`${sev}${ruleId} ${message}${fixTag}`);
2442
+ if (d.suggestion) {
2443
+ lines.push(`${gutter}${pc.cyan(`\u2192 ${d.suggestion}`)}`);
2444
+ }
2445
+ if (d.docsUrl && !docsByRule.has(d.ruleName)) {
2446
+ docsByRule.set(d.ruleName, d.docsUrl);
1820
2447
  }
1821
2448
  }
1822
- for (const ref of backwardRefs) {
1823
- const target = adrIndex.get(ref);
1824
- if (!target) {
1825
- context.report({
1826
- messageId: "unknownReference",
1827
- path: file.path,
1828
- data: { ref, direction: BACKWARD_FIELD }
1829
- });
1830
- continue;
2449
+ for (const [ruleName, url] of docsByRule) {
2450
+ lines.push(pc.dim(` ${ruleName} ${url}`));
2451
+ }
2452
+ lines.push("");
2453
+ }
2454
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
2455
+ const warnings = diagnostics.filter((d) => d.severity === "warn").length;
2456
+ lines.push(pc.bold(formatSummary(errors, warnings)));
2457
+ return lines.join("\n");
2458
+ }
2459
+ };
2460
+ function groupByFile(diagnostics) {
2461
+ const map = /* @__PURE__ */ new Map();
2462
+ for (const d of diagnostics) {
2463
+ if (!map.has(d.path)) map.set(d.path, []);
2464
+ map.get(d.path)?.push(d);
2465
+ }
2466
+ return map;
2467
+ }
2468
+ function renderMessage(d, rulesByName) {
2469
+ const rule9 = rulesByName.get(d.ruleName);
2470
+ const template = rule9?.meta.messages[d.messageId] ?? d.messageId;
2471
+ return interpolate(template, d.data ?? {});
2472
+ }
2473
+ function formatSummary(errors, warnings) {
2474
+ const parts = [];
2475
+ if (errors > 0) parts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
2476
+ if (warnings > 0) parts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
2477
+ return parts.join(", ");
2478
+ }
2479
+ var jsonReporter = {
2480
+ format(diagnostics, rulesByName, meta) {
2481
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
2482
+ const warnings = diagnostics.filter((d) => d.severity === "warn").length;
2483
+ const payload = {
2484
+ version: 1,
2485
+ summary: {
2486
+ total: diagnostics.length,
2487
+ errors,
2488
+ warnings,
2489
+ // Always present (0 when no baseline) so CI consumers can detect an
2490
+ // active baseline without probing for the key. SARIF stays untouched.
2491
+ baselineHidden: meta?.baselineHidden ?? 0,
2492
+ // `fixed` appears ONLY when a fix pass ran (#28) — a plain lint keeps
2493
+ // the summary shape it always had.
2494
+ ...meta?.fixed !== void 0 ? { fixed: meta.fixed } : {}
2495
+ },
2496
+ // Dry-run diffs (#28): embedded so `--fix-dry-run --format json` keeps
2497
+ // stdout pure JSON while still delivering the diff. Absent otherwise.
2498
+ ...meta?.fixDiffs !== void 0 ? { diffs: meta.fixDiffs } : {},
2499
+ results: diagnostics.map((d) => ({
2500
+ path: d.path,
2501
+ ruleName: d.ruleName,
2502
+ messageId: d.messageId,
2503
+ severity: d.severity,
2504
+ message: renderMessage(d, rulesByName),
2505
+ // Self-contained diagnostics (#67): always present so consumers can
2506
+ // rely on the keys — `suggestion` is null when the rule declares none.
2507
+ suggestion: d.suggestion,
2508
+ docsUrl: d.docsUrl,
2509
+ // Autofix (#28): whether `--fix` can repair this diagnostic.
2510
+ fixable: d.fixable,
2511
+ data: d.data ?? {}
2512
+ }))
2513
+ };
2514
+ return JSON.stringify(payload, null, 2);
2515
+ }
2516
+ };
2517
+ var sarifReporter = {
2518
+ format(diagnostics, rulesByName) {
2519
+ const ruleNames = [...new Set(diagnostics.map((d) => d.ruleName))];
2520
+ const ruleIndex = new Map(ruleNames.map((name, i) => [name, i]));
2521
+ const sarifRules = ruleNames.map((name) => {
2522
+ const rule9 = rulesByName.get(name);
2523
+ return {
2524
+ id: name,
2525
+ name: name.replace(/[^A-Za-z0-9]/g, "_"),
2526
+ shortDescription: { text: rule9?.meta.docs.description ?? name },
2527
+ helpUri: rule9?.meta.docs.url
2528
+ };
2529
+ });
2530
+ const sarifResults = diagnostics.map((d) => ({
2531
+ ruleId: d.ruleName,
2532
+ ruleIndex: ruleIndex.get(d.ruleName) ?? 0,
2533
+ level: d.severity === "error" ? "error" : "warning",
2534
+ message: { text: renderMessage(d, rulesByName) },
2535
+ locations: [
2536
+ {
2537
+ physicalLocation: {
2538
+ artifactLocation: { uri: d.path, uriBaseId: "%SRCROOT%" },
2539
+ region: { startLine: 1 }
2540
+ }
1831
2541
  }
1832
- const targetFwd = normalizeRefs(target.frontmatter?.[FORWARD_FIELD]);
1833
- if (!targetFwd.includes(fileRef)) {
1834
- context.report({
1835
- messageId: "missingBackReference",
1836
- path: target.path,
1837
- data: {
1838
- ref,
1839
- source: file.path,
1840
- declared: BACKWARD_FIELD,
1841
- direction: FORWARD_FIELD,
1842
- expected: fileRef
2542
+ ]
2543
+ }));
2544
+ const sarif = {
2545
+ $schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
2546
+ version: "2.1.0",
2547
+ runs: [
2548
+ {
2549
+ tool: {
2550
+ driver: {
2551
+ name: "madr-lint",
2552
+ informationUri: "https://github.com/knktkc/madr-lint",
2553
+ rules: sarifRules
1843
2554
  }
1844
- });
2555
+ },
2556
+ results: sarifResults
1845
2557
  }
1846
- }
2558
+ ]
2559
+ };
2560
+ return JSON.stringify(sarif, null, 2);
2561
+ }
2562
+ };
2563
+ function escapeGhMessage(s) {
2564
+ return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
2565
+ }
2566
+ function escapeGhProperty(s) {
2567
+ return escapeGhMessage(s).replace(/,/g, "%2C").replace(/:/g, "%3A");
2568
+ }
2569
+ var githubReporter = {
2570
+ format(diagnostics, rulesByName, meta) {
2571
+ const lines = [];
2572
+ for (const d of diagnostics) {
2573
+ const level = d.severity === "error" ? "error" : "warning";
2574
+ const file = escapeGhProperty(d.path);
2575
+ const title = escapeGhProperty(d.ruleName);
2576
+ const rendered = renderMessage(d, rulesByName);
2577
+ const message = escapeGhMessage(
2578
+ d.suggestion ? `${rendered} \u2014 ${d.suggestion}` : rendered
2579
+ );
2580
+ const linePart = d.loc ? `,line=${d.loc.line}` : "";
2581
+ lines.push(`::${level} file=${file}${linePart},title=${title}::${message}`);
1847
2582
  }
2583
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
2584
+ const warnings = diagnostics.filter((d) => d.severity === "warn").length;
2585
+ const summaryParts = [];
2586
+ if (errors > 0) summaryParts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
2587
+ if (warnings > 0) summaryParts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
2588
+ if (summaryParts.length === 0) summaryParts.push("0 errors, 0 warnings");
2589
+ const baselineHidden = meta?.baselineHidden ?? 0;
2590
+ if (baselineHidden > 0) summaryParts.push(`${baselineHidden} hidden by baseline`);
2591
+ lines.push(summaryParts.join(", "));
2592
+ return lines.join("\n");
1848
2593
  }
1849
2594
  };
1850
- var supersedes_bidirectional_default = rule8;
2595
+ var reporters = {
2596
+ text: textReporter,
2597
+ json: jsonReporter,
2598
+ sarif: sarifReporter,
2599
+ github: githubReporter
2600
+ };
1851
2601
 
1852
2602
  // src/cli.ts
1853
2603
  function toPosix2(p) {
@@ -1855,9 +2605,9 @@ function toPosix2(p) {
1855
2605
  return p.split(sep2).join("/");
1856
2606
  }
1857
2607
  var pkg = JSON.parse(
1858
- readFileSync5(new URL("../package.json", import.meta.url), "utf8")
2608
+ readFileSync6(new URL("../package.json", import.meta.url), "utf8")
1859
2609
  );
1860
- var main = defineCommand({
2610
+ var main = defineCommand2({
1861
2611
  meta: {
1862
2612
  name: "madr-lint",
1863
2613
  version: pkg.version,
@@ -1908,6 +2658,16 @@ var main = defineCommand({
1908
2658
  type: "string",
1909
2659
  description: "Path to config file, bypassing auto-discovery",
1910
2660
  required: false
2661
+ },
2662
+ fix: {
2663
+ type: "boolean",
2664
+ description: "Apply autofixes in place; exit code reflects remaining problems",
2665
+ default: false
2666
+ },
2667
+ "fix-dry-run": {
2668
+ type: "boolean",
2669
+ description: "Show a diff of the fixes that --fix would apply; write nothing",
2670
+ default: false
1911
2671
  }
1912
2672
  },
1913
2673
  run({ args }) {
@@ -1928,7 +2688,7 @@ var main = defineCommand({
1928
2688
  let configExplicit = false;
1929
2689
  const configArg = args.config;
1930
2690
  if (configArg) {
1931
- const absConfigPath = resolve3(cwd, configArg);
2691
+ const absConfigPath = resolve5(cwd, configArg);
1932
2692
  try {
1933
2693
  config = loadConfigFromPath(absConfigPath);
1934
2694
  configExplicit = true;
@@ -1973,7 +2733,7 @@ var main = defineCommand({
1973
2733
  const cacheEnabled = (args.cache ?? config.cache) === true;
1974
2734
  const cacheDir = args["cache-dir"] ?? config.cacheLocation;
1975
2735
  const cacheConfig = cacheEnabled ? {
1976
- dir: resolve3(cwd, cacheDir),
2736
+ dir: resolve5(cwd, cacheDir),
1977
2737
  configHash: computeConfigHash({
1978
2738
  rules: config.rules,
1979
2739
  ignorePatterns: config.ignorePatterns,
@@ -1985,10 +2745,107 @@ var main = defineCommand({
1985
2745
  const updateBaseline = args["update-baseline"] === true;
1986
2746
  const useBaseline = !updateBaseline && args.baseline !== false;
1987
2747
  const baseline = useBaseline ? loadBaseline(baselineFile) : null;
1988
- if (useBaseline && baseline === null && existsSync5(baselineFile)) {
2748
+ if (useBaseline && baseline === null && existsSync6(baselineFile)) {
1989
2749
  console.error(baselineMalformedWarning());
1990
2750
  }
1991
2751
  const allRulesArray = Object.values(rules_exports);
2752
+ const format = args.format ?? "text";
2753
+ const reportAndExit = (diagnostics, baselineHidden, fixedCount, dryRun, fixDiffs) => {
2754
+ const rulesByName = new Map(
2755
+ allRulesArray.map((r) => [r.meta.name, r])
2756
+ );
2757
+ const reporter = reporters[format];
2758
+ if (!reporter) {
2759
+ console.error(
2760
+ `Unknown --format "${format}". Available: ${Object.keys(reporters).join(", ")}`
2761
+ );
2762
+ process.exit(2);
2763
+ }
2764
+ const warningCount = diagnostics.filter((d) => d.severity === "warn").length;
2765
+ const errorCount = diagnostics.filter((d) => d.severity === "error").length;
2766
+ const overWarningLimit = maxWarnings >= 0 && warningCount > maxWarnings;
2767
+ const reported = args.quiet ? diagnostics.filter((d) => d.severity !== "warn") : diagnostics;
2768
+ const suppressAllClear = format === "text" && reported.length === 0 && overWarningLimit;
2769
+ if (!suppressAllClear) {
2770
+ console.log(
2771
+ reporter.format(reported, rulesByName, {
2772
+ baselineHidden,
2773
+ ...fixedCount !== void 0 ? { fixed: fixedCount } : {},
2774
+ ...fixDiffs !== void 0 ? { fixDiffs } : {}
2775
+ })
2776
+ );
2777
+ }
2778
+ if (format === "text" && baselineHidden > 0) {
2779
+ console.log(baselineHiddenSummary(baselineHidden));
2780
+ }
2781
+ if (format === "text" && fixedCount !== void 0 && fixedCount > 0) {
2782
+ const noun = fixedCount === 1 ? "problem" : "problems";
2783
+ console.log(
2784
+ dryRun ? `${fixedCount} ${noun} fixable (dry run; no files written)` : `Fixed ${fixedCount} ${noun}`
2785
+ );
2786
+ }
2787
+ if (overWarningLimit) {
2788
+ console.error(
2789
+ `madr-lint: ${warningCount} warning(s) found, exceeds --max-warnings ${maxWarnings}`
2790
+ );
2791
+ }
2792
+ process.exit(errorCount > 0 || overWarningLimit ? 1 : 0);
2793
+ };
2794
+ const fixMode = args.fix === true || args["fix-dry-run"] === true;
2795
+ if (fixMode && updateBaseline) {
2796
+ console.error(
2797
+ "madr-lint: --update-baseline cannot be combined with --fix or --fix-dry-run"
2798
+ );
2799
+ process.exit(2);
2800
+ }
2801
+ if (fixMode) {
2802
+ const dryRun = args["fix-dry-run"] === true;
2803
+ let fixResult;
2804
+ try {
2805
+ fixResult = lintAndFix({
2806
+ rules: allRulesArray,
2807
+ ruleSeverity: config.rules,
2808
+ files,
2809
+ cwd,
2810
+ baseline
2811
+ });
2812
+ } catch (err) {
2813
+ if (err instanceof RuleOptionsError) {
2814
+ console.error(`Invalid rule options in config: ${err.message}`);
2815
+ process.exit(2);
2816
+ }
2817
+ throw err;
2818
+ }
2819
+ let fixDiffs;
2820
+ if (dryRun) {
2821
+ const changed = fixResult.files.filter((f) => f.changed);
2822
+ if (format === "text") {
2823
+ for (const f of changed) {
2824
+ process.stdout.write(unifiedDiff(f.path, f.original, f.fixed));
2825
+ }
2826
+ } else if (format === "json") {
2827
+ fixDiffs = changed.map((f) => ({
2828
+ path: f.path,
2829
+ diff: unifiedDiff(f.path, f.original, f.fixed)
2830
+ }));
2831
+ } else {
2832
+ for (const f of changed) {
2833
+ process.stderr.write(unifiedDiff(f.path, f.original, f.fixed));
2834
+ }
2835
+ }
2836
+ } else {
2837
+ for (const f of fixResult.files) {
2838
+ if (f.changed) writeFileSync4(f.absPath, f.fixed, "utf8");
2839
+ }
2840
+ }
2841
+ reportAndExit(
2842
+ fixResult.diagnostics,
2843
+ fixResult.baselineHidden,
2844
+ fixResult.fixed,
2845
+ dryRun,
2846
+ fixDiffs
2847
+ );
2848
+ }
1992
2849
  let result;
1993
2850
  try {
1994
2851
  result = lintFiles({
@@ -2012,40 +2869,13 @@ var main = defineCommand({
2012
2869
  console.log(baselineWriteSummary(built));
2013
2870
  process.exit(0);
2014
2871
  }
2015
- const rulesByName = new Map(
2016
- allRulesArray.map((r) => [r.meta.name, r])
2017
- );
2018
- const format = args.format ?? "text";
2019
- const reporter = reporters[format];
2020
- if (!reporter) {
2021
- console.error(
2022
- `Unknown --format "${format}". Available: ${Object.keys(reporters).join(", ")}`
2023
- );
2024
- process.exit(2);
2025
- }
2026
- const allDiagnostics = result.diagnostics;
2027
- const warningCount = allDiagnostics.filter((d) => d.severity === "warn").length;
2028
- const errorCount = allDiagnostics.filter((d) => d.severity === "error").length;
2029
- const overWarningLimit = maxWarnings >= 0 && warningCount > maxWarnings;
2030
- const reported = args.quiet ? allDiagnostics.filter((d) => d.severity !== "warn") : allDiagnostics;
2031
- const suppressAllClear = format === "text" && reported.length === 0 && overWarningLimit;
2032
- if (!suppressAllClear) {
2033
- console.log(
2034
- reporter.format(reported, rulesByName, {
2035
- baselineHidden: result.baselineHidden
2036
- })
2037
- );
2038
- }
2039
- if (format === "text" && result.baselineHidden > 0) {
2040
- console.log(baselineHiddenSummary(result.baselineHidden));
2041
- }
2042
- if (overWarningLimit) {
2043
- console.error(
2044
- `madr-lint: ${warningCount} warning(s) found, exceeds --max-warnings ${maxWarnings}`
2045
- );
2046
- }
2047
- process.exit(errorCount > 0 || overWarningLimit ? 1 : 0);
2872
+ reportAndExit(result.diagnostics, result.baselineHidden, void 0, false);
2048
2873
  }
2049
2874
  });
2050
- await runMain(main);
2875
+ var rawArgs = process.argv.slice(2);
2876
+ if (rawArgs[0] === "init") {
2877
+ await runMain(initCommand, { rawArgs: rawArgs.slice(1) });
2878
+ } else {
2879
+ await runMain(main);
2880
+ }
2051
2881
  //# sourceMappingURL=cli.js.map