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