madr-lint 0.3.0 → 0.4.1
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 +48 -0
- package/README.md +19 -12
- package/dist/cli.js +1496 -640
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +186 -16
- package/dist/index.js +442 -42
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
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
|
|
10
|
-
import { relative as relative2, resolve as
|
|
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/
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
|
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 =
|
|
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 (!
|
|
85
|
+
if (!existsSync(path)) continue;
|
|
267
86
|
if (name.endsWith(".json")) {
|
|
268
|
-
const parsed = JSON.parse(
|
|
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
|
|
294
|
-
import { join
|
|
112
|
+
import { existsSync as existsSync2, readdirSync, statSync } from "fs";
|
|
113
|
+
import { join } from "path";
|
|
295
114
|
function findAdrFiles(target) {
|
|
296
|
-
if (!
|
|
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) =>
|
|
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/
|
|
330
|
-
import { readFileSync as
|
|
331
|
-
import {
|
|
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,35 +180,65 @@ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
|
387
180
|
"consulted",
|
|
388
181
|
"informed"
|
|
389
182
|
]);
|
|
390
|
-
function
|
|
391
|
-
let
|
|
183
|
+
function isHtmlComment(value) {
|
|
184
|
+
let rest = value.trim();
|
|
185
|
+
if (rest.length === 0) return false;
|
|
186
|
+
while (rest.length > 0) {
|
|
187
|
+
if (!rest.startsWith("<!--")) return false;
|
|
188
|
+
const end = rest.indexOf("-->", 2);
|
|
189
|
+
if (end === -1) return false;
|
|
190
|
+
rest = rest.slice(end + 3).trim();
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
function extractListMetadataWithLoc(ast, body) {
|
|
195
|
+
const segments = [];
|
|
196
|
+
let bridged = false;
|
|
392
197
|
for (const child of ast.children) {
|
|
393
|
-
if (
|
|
394
|
-
if (child.
|
|
198
|
+
if (segments.length === 0) {
|
|
199
|
+
if (child.type === "heading") {
|
|
200
|
+
if (child.depth >= 2) break;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (child.type === "html") continue;
|
|
204
|
+
if (child.type === "list") {
|
|
205
|
+
segments.push(child);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
if (child.type === "html" && isHtmlComment(child.value)) {
|
|
211
|
+
bridged = true;
|
|
395
212
|
continue;
|
|
396
213
|
}
|
|
397
|
-
if (child.type === "
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
214
|
+
if (child.type === "list" && bridged) {
|
|
215
|
+
segments.push(child);
|
|
216
|
+
bridged = false;
|
|
217
|
+
continue;
|
|
401
218
|
}
|
|
402
219
|
break;
|
|
403
220
|
}
|
|
404
|
-
if (
|
|
221
|
+
if (segments.length === 0) return null;
|
|
405
222
|
const values = {};
|
|
406
223
|
const loc = {};
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
224
|
+
const valueOffsets = {};
|
|
225
|
+
for (const list of segments) {
|
|
226
|
+
for (const item of list.children) {
|
|
227
|
+
const pair = extractListItemKV(item);
|
|
228
|
+
if (pair && !(pair.key in values)) {
|
|
229
|
+
values[pair.key] = pair.value;
|
|
230
|
+
const start = item.position?.start;
|
|
231
|
+
if (start) loc[pair.key] = { line: start.line, column: start.column };
|
|
232
|
+
if (body !== void 0 && pair.valueOffset && body.slice(pair.valueOffset.start, pair.valueOffset.end) === pair.value) {
|
|
233
|
+
valueOffsets[pair.key] = pair.valueOffset;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
413
236
|
}
|
|
414
237
|
}
|
|
415
238
|
const hasRecognizedKey = Object.keys(values).some(
|
|
416
239
|
(k) => RECOGNIZED_METADATA_KEYS.has(k)
|
|
417
240
|
);
|
|
418
|
-
return hasRecognizedKey ? { values, loc } : null;
|
|
241
|
+
return hasRecognizedKey ? { values, loc, valueOffsets } : null;
|
|
419
242
|
}
|
|
420
243
|
var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
|
|
421
244
|
var SEPARATOR_PATTERN = /^\s*:\s*/;
|
|
@@ -439,50 +262,576 @@ function extractListItemKV(item) {
|
|
|
439
262
|
const valueTail = paragraph.children.slice(2).map((n) => toString(n)).join("");
|
|
440
263
|
const value = (valueHead + valueTail).trim();
|
|
441
264
|
if (value === "") return null;
|
|
442
|
-
|
|
265
|
+
let valueOffset;
|
|
266
|
+
const sepStart = sep3.position?.start.offset;
|
|
267
|
+
if (paragraph.children.length === 2 && typeof sepStart === "number" && valueHead !== "") {
|
|
268
|
+
const sepMatch = SEPARATOR_PATTERN.exec(sep3.value);
|
|
269
|
+
const vStart = sepStart + (sepMatch ? sepMatch[0].length : 0);
|
|
270
|
+
valueOffset = { start: vStart, end: vStart + value.length };
|
|
271
|
+
}
|
|
272
|
+
return { key: normalizeKey(keyNode.value), value, valueOffset };
|
|
443
273
|
}
|
|
444
274
|
if (first.type === "text") {
|
|
445
275
|
const colonIdx = first.value.indexOf(":");
|
|
446
276
|
if (colonIdx === -1) return null;
|
|
447
277
|
const rawKey = first.value.slice(0, colonIdx);
|
|
448
278
|
if (!KEY_PATTERN.test(rawKey)) return null;
|
|
449
|
-
const
|
|
279
|
+
const afterColon = first.value.slice(colonIdx + 1);
|
|
280
|
+
const valueHead = afterColon.replace(/^\s+/, "");
|
|
450
281
|
const valueTail = paragraph.children.slice(1).map((n) => toString(n)).join("");
|
|
451
282
|
const value = (valueHead + valueTail).trim();
|
|
452
283
|
if (value === "") return null;
|
|
453
|
-
|
|
284
|
+
let valueOffset;
|
|
285
|
+
const nodeStart = first.position?.start.offset;
|
|
286
|
+
if (paragraph.children.length === 1 && typeof nodeStart === "number" && valueHead !== "") {
|
|
287
|
+
const leadingWs = afterColon.length - valueHead.length;
|
|
288
|
+
const vStart = nodeStart + colonIdx + 1 + leadingWs;
|
|
289
|
+
valueOffset = { start: vStart, end: vStart + value.length };
|
|
290
|
+
}
|
|
291
|
+
return { key: normalizeKey(rawKey), value, valueOffset };
|
|
454
292
|
}
|
|
455
293
|
return null;
|
|
456
294
|
}
|
|
457
295
|
|
|
458
|
-
// src/core/
|
|
459
|
-
var
|
|
460
|
-
|
|
461
|
-
"
|
|
462
|
-
"
|
|
463
|
-
"
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
if (trimmed === "") return null;
|
|
482
|
-
const rules = trimmed.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
483
|
-
return rules.length > 0 ? rules : null;
|
|
296
|
+
// src/core/init.ts
|
|
297
|
+
var GETTING_STARTED_URL = "https://knktkc.github.io/madr-lint/guides/getting-started/";
|
|
298
|
+
var ADR_DIR_CANDIDATES = [
|
|
299
|
+
"docs/adr",
|
|
300
|
+
"docs/decisions",
|
|
301
|
+
"doc/adr",
|
|
302
|
+
"adr",
|
|
303
|
+
"docs/architecture/decisions"
|
|
304
|
+
];
|
|
305
|
+
var ADR_FILE_PATTERN = /^\d{4}-.*\.md$/;
|
|
306
|
+
function detectAdrDir(cwd) {
|
|
307
|
+
for (const candidate of ADR_DIR_CANDIDATES) {
|
|
308
|
+
let entries;
|
|
309
|
+
try {
|
|
310
|
+
entries = readdirSync2(resolve2(cwd, candidate), { withFileTypes: true });
|
|
311
|
+
} catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (entries.some((e) => e.isFile() && ADR_FILE_PATTERN.test(e.name))) {
|
|
315
|
+
return { adrDir: candidate, source: "detected" };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { adrDir: "docs/adr", source: "fallback" };
|
|
484
319
|
}
|
|
485
|
-
function
|
|
320
|
+
function detectFileVersion(content) {
|
|
321
|
+
let parsed;
|
|
322
|
+
try {
|
|
323
|
+
parsed = parseFile(content);
|
|
324
|
+
} catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
if (parsed.frontmatter) {
|
|
328
|
+
return "decision-makers" in parsed.frontmatter ? "v4" : "v3";
|
|
329
|
+
}
|
|
330
|
+
if (parsed.listMetadata) return "v2";
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
function detectMadrVersion(cwd, adrDir) {
|
|
334
|
+
const dir = resolve2(cwd, adrDir);
|
|
335
|
+
let entries;
|
|
336
|
+
try {
|
|
337
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
338
|
+
} catch {
|
|
339
|
+
return "auto";
|
|
340
|
+
}
|
|
341
|
+
const sample = entries.filter((e) => e.isFile() && ADR_FILE_PATTERN.test(e.name)).map((e) => e.name).toSorted().slice(0, 20);
|
|
342
|
+
const votes = { v2: 0, v3: 0, v4: 0 };
|
|
343
|
+
for (const name of sample) {
|
|
344
|
+
let content;
|
|
345
|
+
try {
|
|
346
|
+
content = readFileSync2(join2(dir, name), "utf8");
|
|
347
|
+
} catch {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const version = detectFileVersion(content);
|
|
351
|
+
if (version) votes[version]++;
|
|
352
|
+
}
|
|
353
|
+
const max = Math.max(votes.v2, votes.v3, votes.v4);
|
|
354
|
+
if (max === 0) return "auto";
|
|
355
|
+
const winners = ["v2", "v3", "v4"].filter(
|
|
356
|
+
(v) => votes[v] === max
|
|
357
|
+
);
|
|
358
|
+
return winners.length === 1 ? winners[0] : "auto";
|
|
359
|
+
}
|
|
360
|
+
var CONFIG_FILENAME_BY_FORMAT = {
|
|
361
|
+
ts: "madr-lint.config.ts",
|
|
362
|
+
json: ".madrlintrc.json"
|
|
363
|
+
};
|
|
364
|
+
function detectConfigFormat(cwd) {
|
|
365
|
+
if (existsSync3(resolve2(cwd, "tsconfig.json"))) return "ts";
|
|
366
|
+
try {
|
|
367
|
+
const pkg2 = JSON.parse(
|
|
368
|
+
readFileSync2(resolve2(cwd, "package.json"), "utf8")
|
|
369
|
+
);
|
|
370
|
+
if (pkg2.dependencies?.["typescript"] !== void 0 || pkg2.devDependencies?.["typescript"] !== void 0) {
|
|
371
|
+
return "ts";
|
|
372
|
+
}
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
return "json";
|
|
376
|
+
}
|
|
377
|
+
function findExistingConfigFile(cwd) {
|
|
378
|
+
for (const name of CONFIG_FILES) {
|
|
379
|
+
if (existsSync3(resolve2(cwd, name))) return name;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
function tsQuote(value) {
|
|
384
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
385
|
+
}
|
|
386
|
+
function renderConfig(format, options) {
|
|
387
|
+
if (format === "json") {
|
|
388
|
+
const config = {
|
|
389
|
+
extends: ["madr-lint:recommended"],
|
|
390
|
+
adrDir: options.adrDir
|
|
391
|
+
};
|
|
392
|
+
if (options.madrVersion !== "auto") config.madrVersion = options.madrVersion;
|
|
393
|
+
return `${JSON.stringify(config, null, 2)}
|
|
394
|
+
`;
|
|
395
|
+
}
|
|
396
|
+
const versionLine = options.madrVersion !== "auto" ? `
|
|
397
|
+
madrVersion: ${tsQuote(options.madrVersion)},` : "";
|
|
398
|
+
return `import { defineConfig } from 'madr-lint';
|
|
399
|
+
|
|
400
|
+
export default defineConfig({
|
|
401
|
+
extends: ['madr-lint:recommended'],
|
|
402
|
+
adrDir: ${tsQuote(options.adrDir)},${versionLine}
|
|
403
|
+
});
|
|
404
|
+
`;
|
|
405
|
+
}
|
|
406
|
+
function buildEpilogue(input) {
|
|
407
|
+
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)";
|
|
408
|
+
const lines = [
|
|
409
|
+
`Wrote ${input.configPath}`,
|
|
410
|
+
"",
|
|
411
|
+
` ADR directory: ${input.adrDir} ${dirNote}`,
|
|
412
|
+
"",
|
|
413
|
+
"Next steps:",
|
|
414
|
+
" - Lint your ADRs: npx madr-lint"
|
|
415
|
+
];
|
|
416
|
+
const total = input.errors + input.warnings;
|
|
417
|
+
if (input.filesChecked > 0 && total > 0) {
|
|
418
|
+
lines.push(
|
|
419
|
+
` - The initial lint found ${input.errors} error(s) and ${input.warnings} warning(s) across ${input.filesChecked} file(s).`,
|
|
420
|
+
" Adopting on a legacy repo? Snapshot the debt so only new violations fail the build:",
|
|
421
|
+
" npx madr-lint --update-baseline"
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
lines.push(` - Docs: ${GETTING_STARTED_URL}`);
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// src/core/lint.ts
|
|
429
|
+
import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
|
|
430
|
+
import { isAbsolute, relative, resolve as resolve3, sep } from "path";
|
|
431
|
+
|
|
432
|
+
// src/core/baseline.ts
|
|
433
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
434
|
+
import { dirname, join as join3 } from "path";
|
|
435
|
+
|
|
436
|
+
// src/core/types.ts
|
|
437
|
+
var INTERNAL_ERROR_RULE_NAME = "core/internal-error";
|
|
438
|
+
function isProjectRule(rule9) {
|
|
439
|
+
return "check" in rule9;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// src/core/baseline.ts
|
|
443
|
+
var BASELINE_VERSION = 1;
|
|
444
|
+
var BASELINE_DISPLAY_PATH = ".madr-lint/baseline.json";
|
|
445
|
+
function baselinePath(cwd) {
|
|
446
|
+
return join3(cwd, ".madr-lint", "baseline.json");
|
|
447
|
+
}
|
|
448
|
+
function nullProtoMap() {
|
|
449
|
+
return /* @__PURE__ */ Object.create(null);
|
|
450
|
+
}
|
|
451
|
+
function buildBaseline(diagnostics) {
|
|
452
|
+
const entries = nullProtoMap();
|
|
453
|
+
for (const d of diagnostics) {
|
|
454
|
+
if (d.ruleName === INTERNAL_ERROR_RULE_NAME) continue;
|
|
455
|
+
const byRule = entries[d.path] ??= nullProtoMap();
|
|
456
|
+
const byMessage = byRule[d.ruleName] ??= nullProtoMap();
|
|
457
|
+
byMessage[d.messageId] = (byMessage[d.messageId] ?? 0) + 1;
|
|
458
|
+
}
|
|
459
|
+
return { version: BASELINE_VERSION, entries };
|
|
460
|
+
}
|
|
461
|
+
function serializeBaseline(baseline) {
|
|
462
|
+
const sortedEntries = nullProtoMap();
|
|
463
|
+
for (const path of Object.keys(baseline.entries).toSorted()) {
|
|
464
|
+
const byRule = baseline.entries[path] ?? {};
|
|
465
|
+
const sortedByRule = nullProtoMap();
|
|
466
|
+
for (const rule9 of Object.keys(byRule).toSorted()) {
|
|
467
|
+
const byMessage = byRule[rule9] ?? {};
|
|
468
|
+
const sortedByMessage = nullProtoMap();
|
|
469
|
+
for (const messageId of Object.keys(byMessage).toSorted()) {
|
|
470
|
+
sortedByMessage[messageId] = byMessage[messageId] ?? 0;
|
|
471
|
+
}
|
|
472
|
+
sortedByRule[rule9] = sortedByMessage;
|
|
473
|
+
}
|
|
474
|
+
sortedEntries[path] = sortedByRule;
|
|
475
|
+
}
|
|
476
|
+
const ordered = { version: baseline.version, entries: sortedEntries };
|
|
477
|
+
return JSON.stringify(ordered, null, 2) + "\n";
|
|
478
|
+
}
|
|
479
|
+
function loadBaseline(path) {
|
|
480
|
+
if (!existsSync4(path)) return null;
|
|
481
|
+
try {
|
|
482
|
+
const data = JSON.parse(readFileSync3(path, "utf8"));
|
|
483
|
+
if (typeof data.entries !== "object" || data.entries === null) return null;
|
|
484
|
+
return {
|
|
485
|
+
version: typeof data.version === "number" ? data.version : BASELINE_VERSION,
|
|
486
|
+
entries: rebuildEntries(data.entries)
|
|
487
|
+
};
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function rebuildEntries(raw) {
|
|
493
|
+
const entries = nullProtoMap();
|
|
494
|
+
for (const [path, rawByRule] of Object.entries(raw)) {
|
|
495
|
+
if (typeof rawByRule !== "object" || rawByRule === null) continue;
|
|
496
|
+
const byRule = nullProtoMap();
|
|
497
|
+
for (const [rule9, rawByMessage] of Object.entries(rawByRule)) {
|
|
498
|
+
if (typeof rawByMessage !== "object" || rawByMessage === null) continue;
|
|
499
|
+
const byMessage = nullProtoMap();
|
|
500
|
+
for (const [messageId, count] of Object.entries(
|
|
501
|
+
rawByMessage
|
|
502
|
+
)) {
|
|
503
|
+
if (typeof count === "number") byMessage[messageId] = count;
|
|
504
|
+
}
|
|
505
|
+
byRule[rule9] = byMessage;
|
|
506
|
+
}
|
|
507
|
+
entries[path] = byRule;
|
|
508
|
+
}
|
|
509
|
+
return entries;
|
|
510
|
+
}
|
|
511
|
+
function writeBaseline(path, baseline) {
|
|
512
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
513
|
+
writeFileSync(path, serializeBaseline(baseline), "utf8");
|
|
514
|
+
}
|
|
515
|
+
var KEY_SEP = "\0";
|
|
516
|
+
function fingerprint(d) {
|
|
517
|
+
return d.path + KEY_SEP + d.ruleName + KEY_SEP + d.messageId;
|
|
518
|
+
}
|
|
519
|
+
function applyBaseline(diagnostics, baseline) {
|
|
520
|
+
const remaining = /* @__PURE__ */ new Map();
|
|
521
|
+
for (const [path, byRule] of Object.entries(baseline.entries)) {
|
|
522
|
+
for (const [rule9, byMessage] of Object.entries(byRule)) {
|
|
523
|
+
if (rule9 === INTERNAL_ERROR_RULE_NAME) continue;
|
|
524
|
+
for (const [messageId, count] of Object.entries(byMessage)) {
|
|
525
|
+
remaining.set(path + KEY_SEP + rule9 + KEY_SEP + messageId, count);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const kept = [];
|
|
530
|
+
let hidden = 0;
|
|
531
|
+
for (const d of diagnostics) {
|
|
532
|
+
if (d.ruleName === INTERNAL_ERROR_RULE_NAME) {
|
|
533
|
+
kept.push(d);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const key = fingerprint(d);
|
|
537
|
+
const left = remaining.get(key);
|
|
538
|
+
if (left !== void 0 && left > 0) {
|
|
539
|
+
remaining.set(key, left - 1);
|
|
540
|
+
hidden++;
|
|
541
|
+
} else {
|
|
542
|
+
kept.push(d);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { kept, hidden };
|
|
546
|
+
}
|
|
547
|
+
function totalCount(baseline) {
|
|
548
|
+
let total = 0;
|
|
549
|
+
for (const byRule of Object.values(baseline.entries)) {
|
|
550
|
+
for (const byMessage of Object.values(byRule)) {
|
|
551
|
+
for (const count of Object.values(byMessage)) total += count;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return total;
|
|
555
|
+
}
|
|
556
|
+
function baselineWriteSummary(baseline) {
|
|
557
|
+
const violations = totalCount(baseline);
|
|
558
|
+
const files = Object.keys(baseline.entries).length;
|
|
559
|
+
const v = `${violations} ${violations === 1 ? "violation" : "violations"}`;
|
|
560
|
+
const f = `${files} ${files === 1 ? "file" : "files"}`;
|
|
561
|
+
return `Wrote ${v} across ${f} to ${BASELINE_DISPLAY_PATH}`;
|
|
562
|
+
}
|
|
563
|
+
function baselineMalformedWarning() {
|
|
564
|
+
return `madr-lint: warning: ${BASELINE_DISPLAY_PATH} is malformed; ignoring it (run --update-baseline to regenerate)`;
|
|
565
|
+
}
|
|
566
|
+
function baselineHiddenSummary(hidden) {
|
|
567
|
+
return `${hidden} ${hidden === 1 ? "problem" : "problems"} hidden by baseline (${BASELINE_DISPLAY_PATH})`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/core/cache.ts
|
|
571
|
+
import { createHash } from "crypto";
|
|
572
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
573
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
574
|
+
var CACHE_SCHEMA_VERSION = 3;
|
|
575
|
+
function computeContentHash(content) {
|
|
576
|
+
return createHash("sha1").update(content, "utf8").digest("hex");
|
|
577
|
+
}
|
|
578
|
+
function computeConfigHash(config) {
|
|
579
|
+
return createHash("sha1").update(stableStringify(config), "utf8").digest("hex");
|
|
580
|
+
}
|
|
581
|
+
function stableStringify(value) {
|
|
582
|
+
if (value === null || typeof value !== "object") {
|
|
583
|
+
return JSON.stringify(value);
|
|
584
|
+
}
|
|
585
|
+
if (Array.isArray(value)) {
|
|
586
|
+
return "[" + value.map(stableStringify).join(",") + "]";
|
|
587
|
+
}
|
|
588
|
+
const entries = Object.entries(value).toSorted(
|
|
589
|
+
([a], [b]) => a < b ? -1 : a > b ? 1 : 0
|
|
590
|
+
);
|
|
591
|
+
return "{" + entries.map(([k, v]) => JSON.stringify(k) + ":" + stableStringify(v)).join(",") + "}";
|
|
592
|
+
}
|
|
593
|
+
function manifestPath(cacheDir) {
|
|
594
|
+
return join4(cacheDir, "manifest.json");
|
|
595
|
+
}
|
|
596
|
+
function loadManifest(path) {
|
|
597
|
+
if (!existsSync5(path)) return null;
|
|
598
|
+
try {
|
|
599
|
+
const data = JSON.parse(readFileSync4(path, "utf8"));
|
|
600
|
+
if (
|
|
601
|
+
// Single invalidation point for schema drift: an unknown (or absent)
|
|
602
|
+
// schema version nulls here, so every consumer sees a cold cache — no
|
|
603
|
+
// scattered re-hydration of stale-shape entries downstream.
|
|
604
|
+
data.schemaVersion !== CACHE_SCHEMA_VERSION || typeof data.version !== "string" || typeof data.configHash !== "string" || typeof data.files !== "object" || data.files === null
|
|
605
|
+
) {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
return data;
|
|
609
|
+
} catch {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function saveManifest(path, manifest) {
|
|
614
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
615
|
+
writeFileSync2(path, JSON.stringify(manifest), "utf8");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/core/fix.ts
|
|
619
|
+
var MAX_FIX_PASSES = 10;
|
|
620
|
+
function makeFixer(base) {
|
|
621
|
+
const shift = (n) => n + base;
|
|
622
|
+
return {
|
|
623
|
+
replaceRange(range, text) {
|
|
624
|
+
return { range: [shift(range[0]), shift(range[1])], text };
|
|
625
|
+
},
|
|
626
|
+
insertAt(offset, text) {
|
|
627
|
+
const at = shift(offset);
|
|
628
|
+
return { range: [at, at], text };
|
|
629
|
+
},
|
|
630
|
+
remove(range) {
|
|
631
|
+
return { range: [shift(range[0]), shift(range[1])], text: "" };
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function applyEditsCounted(content, edits) {
|
|
636
|
+
const valid = edits.filter((e) => {
|
|
637
|
+
const [start, end] = e.range;
|
|
638
|
+
return Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start && end <= content.length;
|
|
639
|
+
});
|
|
640
|
+
const sorted = valid.toSorted(
|
|
641
|
+
(a, b) => a.range[0] - b.range[0] || a.range[1] - b.range[1]
|
|
642
|
+
);
|
|
643
|
+
let out = "";
|
|
644
|
+
let cursor = 0;
|
|
645
|
+
let applied = 0;
|
|
646
|
+
for (const edit of sorted) {
|
|
647
|
+
const [start, end] = edit.range;
|
|
648
|
+
if (start < cursor) continue;
|
|
649
|
+
out += content.slice(cursor, start) + edit.text;
|
|
650
|
+
cursor = end;
|
|
651
|
+
applied++;
|
|
652
|
+
}
|
|
653
|
+
return { text: out + content.slice(cursor), applied };
|
|
654
|
+
}
|
|
655
|
+
function collectFixes(diagnostics, content) {
|
|
656
|
+
const fixer = makeFixer(frontmatterOffset(content));
|
|
657
|
+
const edits = [];
|
|
658
|
+
for (const d of diagnostics) {
|
|
659
|
+
if (!d.fix) continue;
|
|
660
|
+
const result = d.fix(fixer);
|
|
661
|
+
if (result === null) continue;
|
|
662
|
+
if (Array.isArray(result)) edits.push(...result);
|
|
663
|
+
else edits.push(result);
|
|
664
|
+
}
|
|
665
|
+
return edits;
|
|
666
|
+
}
|
|
667
|
+
function collectProjectFixes(diagnostics, contentByPath) {
|
|
668
|
+
const fixer = makeFixer(0);
|
|
669
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
670
|
+
for (const d of diagnostics) {
|
|
671
|
+
if (!d.fix) continue;
|
|
672
|
+
if (byPath.has(d.path)) continue;
|
|
673
|
+
if (contentByPath(d.path) === void 0) continue;
|
|
674
|
+
const result = d.fix(fixer);
|
|
675
|
+
if (result === null) continue;
|
|
676
|
+
byPath.set(d.path, Array.isArray(result) ? [...result] : [result]);
|
|
677
|
+
}
|
|
678
|
+
return byPath;
|
|
679
|
+
}
|
|
680
|
+
function fixFileContent(originalContent, lint, maxPasses = MAX_FIX_PASSES) {
|
|
681
|
+
let content = originalContent;
|
|
682
|
+
let passes = 0;
|
|
683
|
+
let applied = 0;
|
|
684
|
+
for (; ; ) {
|
|
685
|
+
const diagnostics = lint(content);
|
|
686
|
+
const done = () => ({
|
|
687
|
+
fixedContent: content,
|
|
688
|
+
remaining: diagnostics,
|
|
689
|
+
changed: content !== originalContent,
|
|
690
|
+
passes,
|
|
691
|
+
applied
|
|
692
|
+
});
|
|
693
|
+
if (passes >= maxPasses) return done();
|
|
694
|
+
const edits = collectFixes(diagnostics, content);
|
|
695
|
+
if (edits.length === 0) return done();
|
|
696
|
+
const { text: next, applied: landed } = applyEditsCounted(content, edits);
|
|
697
|
+
if (next === content) return done();
|
|
698
|
+
applied += landed;
|
|
699
|
+
content = next;
|
|
700
|
+
passes++;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
var CONTEXT = 3;
|
|
704
|
+
function unifiedDiff(path, before, after) {
|
|
705
|
+
if (before === after) return "";
|
|
706
|
+
const a = splitLines(before);
|
|
707
|
+
const b = splitLines(after);
|
|
708
|
+
const ops = diffLines(a, b);
|
|
709
|
+
const hunks = groupHunks(ops);
|
|
710
|
+
if (hunks.length === 0) return "";
|
|
711
|
+
const lines = [`--- a/${path}`, `+++ b/${path}`];
|
|
712
|
+
for (const hunk of hunks) lines.push(...renderHunk(hunk));
|
|
713
|
+
return lines.join("\n") + "\n";
|
|
714
|
+
}
|
|
715
|
+
function splitLines(text) {
|
|
716
|
+
if (text === "") return [];
|
|
717
|
+
const parts = text.split("\n");
|
|
718
|
+
if (parts[parts.length - 1] === "") parts.pop();
|
|
719
|
+
return parts;
|
|
720
|
+
}
|
|
721
|
+
function diffLines(a, b) {
|
|
722
|
+
const n = a.length;
|
|
723
|
+
const m = b.length;
|
|
724
|
+
const lcs = Array.from(
|
|
725
|
+
{ length: n + 1 },
|
|
726
|
+
() => Array.from({ length: m + 1 }).fill(0)
|
|
727
|
+
);
|
|
728
|
+
for (let i2 = n - 1; i2 >= 0; i2--) {
|
|
729
|
+
for (let j2 = m - 1; j2 >= 0; j2--) {
|
|
730
|
+
lcs[i2][j2] = a[i2] === b[j2] ? lcs[i2 + 1][j2 + 1] + 1 : Math.max(lcs[i2 + 1][j2], lcs[i2][j2 + 1]);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const ops = [];
|
|
734
|
+
let i = 0;
|
|
735
|
+
let j = 0;
|
|
736
|
+
while (i < n && j < m) {
|
|
737
|
+
if (a[i] === b[j]) {
|
|
738
|
+
ops.push({ kind: "eq", text: a[i] });
|
|
739
|
+
i++;
|
|
740
|
+
j++;
|
|
741
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
742
|
+
ops.push({ kind: "del", text: a[i] });
|
|
743
|
+
i++;
|
|
744
|
+
} else {
|
|
745
|
+
ops.push({ kind: "ins", text: b[j] });
|
|
746
|
+
j++;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
while (i < n) ops.push({ kind: "del", text: a[i++] });
|
|
750
|
+
while (j < m) ops.push({ kind: "ins", text: b[j++] });
|
|
751
|
+
return ops;
|
|
752
|
+
}
|
|
753
|
+
function groupHunks(ops) {
|
|
754
|
+
const changeIdx = [];
|
|
755
|
+
ops.forEach((op, idx) => {
|
|
756
|
+
if (op.kind !== "eq") changeIdx.push(idx);
|
|
757
|
+
});
|
|
758
|
+
if (changeIdx.length === 0) return [];
|
|
759
|
+
const windows = [];
|
|
760
|
+
for (const idx of changeIdx) {
|
|
761
|
+
const start = Math.max(0, idx - CONTEXT);
|
|
762
|
+
const end = Math.min(ops.length - 1, idx + CONTEXT);
|
|
763
|
+
const last = windows[windows.length - 1];
|
|
764
|
+
if (last && start <= last[1] + 1) last[1] = Math.max(last[1], end);
|
|
765
|
+
else windows.push([start, end]);
|
|
766
|
+
}
|
|
767
|
+
const hunks = [];
|
|
768
|
+
for (const [start, end] of windows) {
|
|
769
|
+
let aLine = 1;
|
|
770
|
+
let bLine = 1;
|
|
771
|
+
for (let k = 0; k < start; k++) {
|
|
772
|
+
if (ops[k].kind !== "ins") aLine++;
|
|
773
|
+
if (ops[k].kind !== "del") bLine++;
|
|
774
|
+
}
|
|
775
|
+
const hunkOps = ops.slice(start, end + 1);
|
|
776
|
+
let aCount = 0;
|
|
777
|
+
let bCount = 0;
|
|
778
|
+
for (const op of hunkOps) {
|
|
779
|
+
if (op.kind !== "ins") aCount++;
|
|
780
|
+
if (op.kind !== "del") bCount++;
|
|
781
|
+
}
|
|
782
|
+
hunks.push({ aStart: aLine, aCount, bStart: bLine, bCount, ops: hunkOps });
|
|
783
|
+
}
|
|
784
|
+
return hunks;
|
|
785
|
+
}
|
|
786
|
+
function renderHunk(hunk) {
|
|
787
|
+
const header = `@@ -${hunk.aStart},${hunk.aCount} +${hunk.bStart},${hunk.bCount} @@`;
|
|
788
|
+
const body = hunk.ops.map((op) => {
|
|
789
|
+
const prefix = op.kind === "eq" ? " " : op.kind === "del" ? "-" : "+";
|
|
790
|
+
return `${prefix}${op.text}`;
|
|
791
|
+
});
|
|
792
|
+
return [header, ...body];
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// src/core/runner.ts
|
|
796
|
+
import Ajv from "ajv";
|
|
797
|
+
|
|
798
|
+
// src/core/interpolate.ts
|
|
799
|
+
function interpolate(template, data) {
|
|
800
|
+
if (!template.includes("{{")) return template;
|
|
801
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
802
|
+
const v = data[key];
|
|
803
|
+
return v !== void 0 ? String(v) : `{{${key}}}`;
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/core/suppression.ts
|
|
808
|
+
var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
|
|
809
|
+
"disable-file",
|
|
810
|
+
"disable",
|
|
811
|
+
"enable",
|
|
812
|
+
"disable-next-line"
|
|
813
|
+
]);
|
|
814
|
+
var DIRECTIVE_PREFIX = "madr-lint-";
|
|
815
|
+
function parseDirective(value, line) {
|
|
816
|
+
const trimmed = value.trim();
|
|
817
|
+
if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
|
|
818
|
+
const inner = trimmed.slice(4, -3).trim();
|
|
819
|
+
if (inner.includes("<!--") || inner.includes("-->")) return null;
|
|
820
|
+
if (!inner.startsWith(DIRECTIVE_PREFIX)) return null;
|
|
821
|
+
const afterPrefix = inner.slice(DIRECTIVE_PREFIX.length);
|
|
822
|
+
const wsIndex = afterPrefix.search(/\s/);
|
|
823
|
+
const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
|
|
824
|
+
if (!DIRECTIVE_KINDS.has(keyword)) return null;
|
|
825
|
+
const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
|
|
826
|
+
return { kind: keyword, rules: parseRuleList(rest), line };
|
|
827
|
+
}
|
|
828
|
+
function parseRuleList(rest) {
|
|
829
|
+
const trimmed = rest.trim();
|
|
830
|
+
if (trimmed === "") return null;
|
|
831
|
+
const rules = trimmed.split(",").map((s) => s.trim()).filter((s) => s !== "");
|
|
832
|
+
return rules.length > 0 ? rules : null;
|
|
833
|
+
}
|
|
834
|
+
function walkHtml(node, out) {
|
|
486
835
|
if (node.type === "html") {
|
|
487
836
|
const line = node.position?.start.line;
|
|
488
837
|
if (typeof line === "number") {
|
|
@@ -609,6 +958,9 @@ var PerFileRuleContext = class {
|
|
|
609
958
|
get metadataLoc() {
|
|
610
959
|
return this.ensureParsed().metadataLoc;
|
|
611
960
|
}
|
|
961
|
+
get metadataValueLoc() {
|
|
962
|
+
return this.ensureParsed().metadataValueLoc;
|
|
963
|
+
}
|
|
612
964
|
};
|
|
613
965
|
function runRulesOnFile(rules, file, runtime = {}) {
|
|
614
966
|
const diagnostics = [];
|
|
@@ -636,18 +988,26 @@ function runRulesOnFile(rules, file, runtime = {}) {
|
|
|
636
988
|
const ruleName = rule9.meta.name;
|
|
637
989
|
const suggestions = rule9.meta.suggestions;
|
|
638
990
|
const docsUrl = rule9.meta.docs.url ?? "";
|
|
991
|
+
const fixableRule = rule9.meta.fixable === "code";
|
|
639
992
|
const context = new PerFileRuleContext(
|
|
640
993
|
file,
|
|
641
994
|
mergedOptions,
|
|
642
995
|
ensureParsed,
|
|
643
996
|
(d) => {
|
|
644
997
|
const template = suggestions?.[d.messageId];
|
|
998
|
+
const hasFix = d.fix !== void 0;
|
|
999
|
+
if (hasFix && !fixableRule) {
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
`Rule "${ruleName}" attached a fix but meta.fixable is not 'code'`
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
645
1004
|
diagnostics.push({
|
|
646
1005
|
ruleName,
|
|
647
1006
|
severity,
|
|
648
1007
|
path: file.path,
|
|
649
1008
|
suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
|
|
650
1009
|
docsUrl,
|
|
1010
|
+
fixable: hasFix,
|
|
651
1011
|
...d
|
|
652
1012
|
});
|
|
653
1013
|
}
|
|
@@ -684,6 +1044,7 @@ function internalErrorDiagnostic(ruleName, operation, err, path) {
|
|
|
684
1044
|
path,
|
|
685
1045
|
suggestion: null,
|
|
686
1046
|
docsUrl: INTERNAL_ERROR_DOCS_URL,
|
|
1047
|
+
fixable: false,
|
|
687
1048
|
data: {
|
|
688
1049
|
rule: ruleName,
|
|
689
1050
|
operation,
|
|
@@ -761,17 +1122,25 @@ function runRulesOnProject(rules, files, runtime = {}) {
|
|
|
761
1122
|
const ruleName = rule9.meta.name;
|
|
762
1123
|
const suggestions = rule9.meta.suggestions;
|
|
763
1124
|
const docsUrl = rule9.meta.docs.url ?? "";
|
|
1125
|
+
const fixableRule = rule9.meta.fixable === "code";
|
|
764
1126
|
const context = {
|
|
765
1127
|
files,
|
|
766
1128
|
options: mergedOptions,
|
|
767
1129
|
fileExists: runtime.fileExists,
|
|
768
1130
|
report(d) {
|
|
769
1131
|
const template = suggestions?.[d.messageId];
|
|
1132
|
+
const hasFix = d.fix !== void 0;
|
|
1133
|
+
if (hasFix && !fixableRule) {
|
|
1134
|
+
throw new Error(
|
|
1135
|
+
`Rule "${ruleName}" attached a fix but meta.fixable is not 'code'`
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
770
1138
|
diagnostics.push({
|
|
771
1139
|
ruleName,
|
|
772
1140
|
severity,
|
|
773
1141
|
suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
|
|
774
1142
|
docsUrl,
|
|
1143
|
+
fixable: hasFix,
|
|
775
1144
|
...d
|
|
776
1145
|
});
|
|
777
1146
|
}
|
|
@@ -796,7 +1165,7 @@ function lintFiles(opts) {
|
|
|
796
1165
|
const allDiagnostics = [];
|
|
797
1166
|
const fileEntries = opts.files.map((absolutePath) => ({
|
|
798
1167
|
relativePath: toPosix(relative(opts.cwd, absolutePath)),
|
|
799
|
-
content:
|
|
1168
|
+
content: readFileSync5(absolutePath, "utf8")
|
|
800
1169
|
}));
|
|
801
1170
|
const perFileRules = [];
|
|
802
1171
|
const projectRules = [];
|
|
@@ -860,55 +1229,14 @@ function lintFiles(opts) {
|
|
|
860
1229
|
const projectFiles = fileEntries.map(
|
|
861
1230
|
(f) => buildProjectFile({ path: f.relativePath, content: f.content })
|
|
862
1231
|
);
|
|
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
1232
|
allDiagnostics.push(
|
|
911
|
-
...
|
|
1233
|
+
...runProjectPass(
|
|
1234
|
+
projectFiles,
|
|
1235
|
+
enabledProjectRules,
|
|
1236
|
+
opts.ruleSeverity,
|
|
1237
|
+
optionsByRule,
|
|
1238
|
+
opts.cwd
|
|
1239
|
+
)
|
|
912
1240
|
);
|
|
913
1241
|
}
|
|
914
1242
|
if (manifest && opts.cache) {
|
|
@@ -930,241 +1258,225 @@ function lintFiles(opts) {
|
|
|
930
1258
|
baselineHidden: applied.hidden
|
|
931
1259
|
};
|
|
932
1260
|
}
|
|
933
|
-
function
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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];
|
|
1261
|
+
function lintAndFix(opts) {
|
|
1262
|
+
const perFileRules = [];
|
|
1263
|
+
const projectRules = [];
|
|
1264
|
+
for (const rule9 of opts.rules) {
|
|
1265
|
+
if (isProjectRule(rule9)) projectRules.push(rule9);
|
|
1266
|
+
else perFileRules.push(rule9);
|
|
993
1267
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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}`));
|
|
1268
|
+
const optionsByRule = extractOptionsByRule(opts.ruleSeverity);
|
|
1269
|
+
const baseline = opts.baseline ?? null;
|
|
1270
|
+
const files = [];
|
|
1271
|
+
const remaining = [];
|
|
1272
|
+
let fixed = 0;
|
|
1273
|
+
let baselineHidden = 0;
|
|
1274
|
+
for (const absPath of opts.files) {
|
|
1275
|
+
const relativePath = toPosix(relative(opts.cwd, absPath));
|
|
1276
|
+
const original = readFileSync5(absPath, "utf8");
|
|
1277
|
+
let lastHidden = 0;
|
|
1278
|
+
const lint = (content) => {
|
|
1279
|
+
const raw = runPerFileRulesForFile(
|
|
1280
|
+
perFileRules,
|
|
1281
|
+
opts.ruleSeverity,
|
|
1282
|
+
optionsByRule,
|
|
1283
|
+
{ relativePath, content }
|
|
1284
|
+
);
|
|
1285
|
+
if (!baseline) {
|
|
1286
|
+
lastHidden = 0;
|
|
1287
|
+
return raw;
|
|
1026
1288
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
}))
|
|
1289
|
+
const sub = applyBaseline(raw, baseline);
|
|
1290
|
+
lastHidden = sub.hidden;
|
|
1291
|
+
return sub.kept;
|
|
1080
1292
|
};
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
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
|
-
};
|
|
1293
|
+
const res = fixFileContent(original, lint, opts.maxPasses);
|
|
1294
|
+
files.push({
|
|
1295
|
+
path: relativePath,
|
|
1296
|
+
absPath,
|
|
1297
|
+
original,
|
|
1298
|
+
fixed: res.fixedContent,
|
|
1299
|
+
changed: res.changed
|
|
1096
1300
|
});
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1301
|
+
remaining.push(...res.remaining);
|
|
1302
|
+
fixed += res.applied;
|
|
1303
|
+
baselineHidden += lastHidden;
|
|
1304
|
+
}
|
|
1305
|
+
const enabledProjectRules = projectRules.filter(
|
|
1306
|
+
(rule9) => resolveSeverity(opts.ruleSeverity[rule9.meta.name]) !== "off"
|
|
1307
|
+
);
|
|
1308
|
+
if (enabledProjectRules.length > 0) {
|
|
1309
|
+
const contentByPath = new Map(files.map((f) => [f.path, f.fixed]));
|
|
1310
|
+
const maxPasses = opts.maxPasses ?? MAX_FIX_PASSES;
|
|
1311
|
+
let projectDiags = [];
|
|
1312
|
+
let passes = 0;
|
|
1313
|
+
for (; ; ) {
|
|
1314
|
+
const projectFiles = files.map(
|
|
1315
|
+
(f) => buildProjectFile({ path: f.path, content: contentByPath.get(f.path) ?? f.fixed })
|
|
1316
|
+
);
|
|
1317
|
+
let diags = runProjectPass(
|
|
1318
|
+
projectFiles,
|
|
1319
|
+
enabledProjectRules,
|
|
1320
|
+
opts.ruleSeverity,
|
|
1321
|
+
optionsByRule,
|
|
1322
|
+
opts.cwd
|
|
1323
|
+
);
|
|
1324
|
+
let hiddenThisPass = 0;
|
|
1325
|
+
if (baseline) {
|
|
1326
|
+
const sub = applyBaseline(diags, baseline);
|
|
1327
|
+
diags = sub.kept;
|
|
1328
|
+
hiddenThisPass = sub.hidden;
|
|
1329
|
+
}
|
|
1330
|
+
projectDiags = diags;
|
|
1331
|
+
if (passes >= maxPasses) {
|
|
1332
|
+
baselineHidden += hiddenThisPass;
|
|
1333
|
+
break;
|
|
1334
|
+
}
|
|
1335
|
+
const fixesByPath = collectProjectFixes(diags, (p) => contentByPath.get(p));
|
|
1336
|
+
let progressed = false;
|
|
1337
|
+
for (const [path, edits] of fixesByPath) {
|
|
1338
|
+
const before = contentByPath.get(path);
|
|
1339
|
+
if (before === void 0) continue;
|
|
1340
|
+
const { text, applied } = applyEditsCounted(before, edits);
|
|
1341
|
+
if (text !== before) {
|
|
1342
|
+
contentByPath.set(path, text);
|
|
1343
|
+
fixed += applied;
|
|
1344
|
+
progressed = true;
|
|
1124
1345
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1346
|
+
}
|
|
1347
|
+
if (!progressed) {
|
|
1348
|
+
baselineHidden += hiddenThisPass;
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
passes++;
|
|
1352
|
+
}
|
|
1353
|
+
for (const f of files) {
|
|
1354
|
+
const c = contentByPath.get(f.path);
|
|
1355
|
+
if (c !== void 0 && c !== f.fixed) {
|
|
1356
|
+
f.fixed = c;
|
|
1357
|
+
f.changed = f.fixed !== f.original;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
remaining.push(...projectDiags);
|
|
1128
1361
|
}
|
|
1129
|
-
};
|
|
1130
|
-
function escapeGhMessage(s) {
|
|
1131
|
-
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
1362
|
+
return { files, diagnostics: remaining, fixed, baselineHidden };
|
|
1132
1363
|
}
|
|
1133
|
-
function
|
|
1134
|
-
|
|
1364
|
+
function runPerFileRulesForFile(perFileRules, ruleSeverity, optionsByRule, file) {
|
|
1365
|
+
const fileContext = {
|
|
1366
|
+
content: file.content,
|
|
1367
|
+
path: file.relativePath
|
|
1368
|
+
};
|
|
1369
|
+
const errorRules = [];
|
|
1370
|
+
const warnRules = [];
|
|
1371
|
+
for (const rule9 of perFileRules) {
|
|
1372
|
+
const sev = resolveSeverity(ruleSeverity[rule9.meta.name]);
|
|
1373
|
+
if (sev === "off") continue;
|
|
1374
|
+
if (sev === "error") errorRules.push(rule9);
|
|
1375
|
+
else warnRules.push(rule9);
|
|
1376
|
+
}
|
|
1377
|
+
const diagnostics = [];
|
|
1378
|
+
if (errorRules.length > 0) {
|
|
1379
|
+
diagnostics.push(
|
|
1380
|
+
...runRulesOnFile(errorRules, fileContext, {
|
|
1381
|
+
severity: "error",
|
|
1382
|
+
optionsByRule
|
|
1383
|
+
})
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
if (warnRules.length > 0) {
|
|
1387
|
+
diagnostics.push(
|
|
1388
|
+
...runRulesOnFile(warnRules, fileContext, {
|
|
1389
|
+
severity: "warn",
|
|
1390
|
+
optionsByRule
|
|
1391
|
+
})
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
return diagnostics;
|
|
1135
1395
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1396
|
+
function makeFileExists(cwd) {
|
|
1397
|
+
const projectRoot = resolve3(cwd);
|
|
1398
|
+
const existsCache = /* @__PURE__ */ new Map();
|
|
1399
|
+
return (resolvedPath) => {
|
|
1400
|
+
const cached = existsCache.get(resolvedPath);
|
|
1401
|
+
if (cached !== void 0) return cached;
|
|
1402
|
+
const abs = resolve3(projectRoot, resolvedPath);
|
|
1403
|
+
const rel = relative(projectRoot, abs);
|
|
1404
|
+
const escapesRoot = rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
1405
|
+
let result;
|
|
1406
|
+
if (escapesRoot) {
|
|
1407
|
+
result = false;
|
|
1408
|
+
} else {
|
|
1409
|
+
try {
|
|
1410
|
+
result = statSync2(abs, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
1411
|
+
} catch {
|
|
1412
|
+
result = false;
|
|
1413
|
+
}
|
|
1149
1414
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1415
|
+
existsCache.set(resolvedPath, result);
|
|
1416
|
+
return result;
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
function runProjectPass(projectFiles, enabledProjectRules, ruleSeverity, optionsByRule, cwd) {
|
|
1420
|
+
const fileExists = makeFileExists(cwd);
|
|
1421
|
+
const errorRules = [];
|
|
1422
|
+
const warnRules = [];
|
|
1423
|
+
for (const rule9 of enabledProjectRules) {
|
|
1424
|
+
const sev = resolveSeverity(ruleSeverity[rule9.meta.name]);
|
|
1425
|
+
if (sev === "error") errorRules.push(rule9);
|
|
1426
|
+
else if (sev === "warn") warnRules.push(rule9);
|
|
1160
1427
|
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1428
|
+
const projectDiagnostics = [];
|
|
1429
|
+
if (errorRules.length > 0) {
|
|
1430
|
+
projectDiagnostics.push(
|
|
1431
|
+
...runRulesOnProject(errorRules, projectFiles, {
|
|
1432
|
+
severity: "error",
|
|
1433
|
+
fileExists,
|
|
1434
|
+
optionsByRule
|
|
1435
|
+
})
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
if (warnRules.length > 0) {
|
|
1439
|
+
projectDiagnostics.push(
|
|
1440
|
+
...runRulesOnProject(warnRules, projectFiles, {
|
|
1441
|
+
severity: "warn",
|
|
1442
|
+
fileExists,
|
|
1443
|
+
optionsByRule
|
|
1444
|
+
})
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
return filterSuppressedProjectDiagnostics(projectDiagnostics, projectFiles);
|
|
1448
|
+
}
|
|
1449
|
+
function filterSuppressedProjectDiagnostics(diagnostics, projectFiles) {
|
|
1450
|
+
if (diagnostics.length === 0) return [];
|
|
1451
|
+
const fileByPath = new Map(projectFiles.map((f) => [f.path, f]));
|
|
1452
|
+
const indexByPath = /* @__PURE__ */ new Map();
|
|
1453
|
+
const indexFor = (path) => {
|
|
1454
|
+
const cached = indexByPath.get(path);
|
|
1455
|
+
if (cached !== void 0) return cached;
|
|
1456
|
+
const file = fileByPath.get(path);
|
|
1457
|
+
const index = file && file.content.includes(DIRECTIVE_PREFIX) ? collectDirectives(file.ast, file.body) : null;
|
|
1458
|
+
indexByPath.set(path, index);
|
|
1459
|
+
return index;
|
|
1460
|
+
};
|
|
1461
|
+
return diagnostics.filter((d) => {
|
|
1462
|
+
if (d.ruleName === INTERNAL_ERROR_RULE_NAME) return true;
|
|
1463
|
+
const index = indexFor(d.path);
|
|
1464
|
+
if (!index) return true;
|
|
1465
|
+
return !isSuppressed(index, d.ruleName, d.loc?.line);
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
function resolveSeverity(config) {
|
|
1469
|
+
if (config === void 0) return "off";
|
|
1470
|
+
if (typeof config === "string") return config;
|
|
1471
|
+
return config[0];
|
|
1472
|
+
}
|
|
1473
|
+
function extractOptionsByRule(ruleSeverity) {
|
|
1474
|
+
const map = {};
|
|
1475
|
+
for (const [name, config] of Object.entries(ruleSeverity)) {
|
|
1476
|
+
if (Array.isArray(config) && config[1]) map[name] = config[1];
|
|
1477
|
+
}
|
|
1478
|
+
return map;
|
|
1479
|
+
}
|
|
1168
1480
|
|
|
1169
1481
|
// src/rules/index.ts
|
|
1170
1482
|
var rules_exports = {};
|
|
@@ -1209,6 +1521,58 @@ function isValidIso8601Date(s) {
|
|
|
1209
1521
|
const d = new Date(Date.UTC(year, month - 1, day));
|
|
1210
1522
|
return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
|
|
1211
1523
|
}
|
|
1524
|
+
var MONTHS = {
|
|
1525
|
+
jan: 1,
|
|
1526
|
+
january: 1,
|
|
1527
|
+
feb: 2,
|
|
1528
|
+
february: 2,
|
|
1529
|
+
mar: 3,
|
|
1530
|
+
march: 3,
|
|
1531
|
+
apr: 4,
|
|
1532
|
+
april: 4,
|
|
1533
|
+
may: 5,
|
|
1534
|
+
jun: 6,
|
|
1535
|
+
june: 6,
|
|
1536
|
+
jul: 7,
|
|
1537
|
+
july: 7,
|
|
1538
|
+
aug: 8,
|
|
1539
|
+
august: 8,
|
|
1540
|
+
sep: 9,
|
|
1541
|
+
sept: 9,
|
|
1542
|
+
september: 9,
|
|
1543
|
+
oct: 10,
|
|
1544
|
+
october: 10,
|
|
1545
|
+
nov: 11,
|
|
1546
|
+
november: 11,
|
|
1547
|
+
dec: 12,
|
|
1548
|
+
december: 12
|
|
1549
|
+
};
|
|
1550
|
+
function pad(n, width) {
|
|
1551
|
+
return String(n).padStart(width, "0");
|
|
1552
|
+
}
|
|
1553
|
+
function buildIso(year, month, day) {
|
|
1554
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
|
1555
|
+
const iso = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
|
|
1556
|
+
return isValidIso8601Date(iso) ? iso : null;
|
|
1557
|
+
}
|
|
1558
|
+
function normalizeToIso(raw) {
|
|
1559
|
+
const s = raw.trim();
|
|
1560
|
+
const numeric = /^(\d{4})([-/.])(\d{1,2})\2(\d{1,2})$/.exec(s);
|
|
1561
|
+
if (numeric) {
|
|
1562
|
+
return buildIso(Number(numeric[1]), Number(numeric[3]), Number(numeric[4]));
|
|
1563
|
+
}
|
|
1564
|
+
const dMonY = /^(\d{1,2})\s+([A-Za-z]+)\.?\s+(\d{4})$/.exec(s);
|
|
1565
|
+
if (dMonY) {
|
|
1566
|
+
const month = MONTHS[dMonY[2].toLowerCase()];
|
|
1567
|
+
return month ? buildIso(Number(dMonY[3]), month, Number(dMonY[1])) : null;
|
|
1568
|
+
}
|
|
1569
|
+
const monDY = /^([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})$/.exec(s);
|
|
1570
|
+
if (monDY) {
|
|
1571
|
+
const month = MONTHS[monDY[1].toLowerCase()];
|
|
1572
|
+
return month ? buildIso(Number(monDY[3]), month, Number(monDY[2])) : null;
|
|
1573
|
+
}
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1212
1576
|
var rule = {
|
|
1213
1577
|
meta: {
|
|
1214
1578
|
name: "madr/date-iso8601",
|
|
@@ -1227,6 +1591,10 @@ var rule = {
|
|
|
1227
1591
|
missingDate: 'add a "{{field}}" field in YYYY-MM-DD format to the frontmatter (for MADR v2, a "* Date: ..." list item)',
|
|
1228
1592
|
invalidDate: "use the YYYY-MM-DD calendar-date format, e.g. 2025-03-14"
|
|
1229
1593
|
},
|
|
1594
|
+
// Autofix (#29): normalize UNAMBIGUOUS shapes of a v2 list-sourced value —
|
|
1595
|
+
// year-first numeric and English named-month forms. Ambiguous day/month
|
|
1596
|
+
// order, invalid calendar dates, and frontmatter-sourced values get no fix.
|
|
1597
|
+
fixable: "code",
|
|
1230
1598
|
defaultOptions: {
|
|
1231
1599
|
field: "date"
|
|
1232
1600
|
},
|
|
@@ -1253,10 +1621,14 @@ var rule = {
|
|
|
1253
1621
|
}
|
|
1254
1622
|
if (!isValidIso8601Date(dateStr)) {
|
|
1255
1623
|
const loc = context.metadataLoc?.[fieldName];
|
|
1624
|
+
const valueRange = context.metadataValueLoc?.[fieldName];
|
|
1625
|
+
const normalized = valueRange ? normalizeToIso(dateStr) : null;
|
|
1626
|
+
const fix = normalized !== null && normalized !== dateStr && valueRange ? (fixer) => fixer.replaceRange([valueRange.start, valueRange.end], normalized) : void 0;
|
|
1256
1627
|
context.report({
|
|
1257
1628
|
messageId: "invalidDate",
|
|
1258
1629
|
...loc ? { loc } : {},
|
|
1259
|
-
data: { date: dateStr }
|
|
1630
|
+
data: { date: dateStr },
|
|
1631
|
+
...fix ? { fix } : {}
|
|
1260
1632
|
});
|
|
1261
1633
|
}
|
|
1262
1634
|
}
|
|
@@ -1264,7 +1636,7 @@ var rule = {
|
|
|
1264
1636
|
var date_iso8601_default = rule;
|
|
1265
1637
|
|
|
1266
1638
|
// src/rules/filename-format/index.ts
|
|
1267
|
-
import { basename
|
|
1639
|
+
import { basename } from "path";
|
|
1268
1640
|
|
|
1269
1641
|
// src/core/regex-safety.ts
|
|
1270
1642
|
import safeRegex from "safe-regex2";
|
|
@@ -1332,7 +1704,7 @@ var rule2 = {
|
|
|
1332
1704
|
create(context) {
|
|
1333
1705
|
const { pattern } = context.options;
|
|
1334
1706
|
const regex = assertSafeRegex(pattern, "madr/filename-format", "pattern");
|
|
1335
|
-
const filename =
|
|
1707
|
+
const filename = basename(context.file.path);
|
|
1336
1708
|
if (!regex.test(filename)) {
|
|
1337
1709
|
context.report({
|
|
1338
1710
|
messageId: "invalidFilename",
|
|
@@ -1441,7 +1813,7 @@ var rule3 = {
|
|
|
1441
1813
|
var no_broken_links_default = rule3;
|
|
1442
1814
|
|
|
1443
1815
|
// src/rules/no-duplicate-numbering/index.ts
|
|
1444
|
-
import { basename as
|
|
1816
|
+
import { basename as basename2 } from "path";
|
|
1445
1817
|
|
|
1446
1818
|
// src/rules/no-duplicate-numbering/schema.json
|
|
1447
1819
|
var schema_default4 = {
|
|
@@ -1475,7 +1847,7 @@ var rule4 = {
|
|
|
1475
1847
|
check(context) {
|
|
1476
1848
|
const byNumber = /* @__PURE__ */ new Map();
|
|
1477
1849
|
for (const file of context.files) {
|
|
1478
|
-
const match = NUMBER_REGEX.exec(
|
|
1850
|
+
const match = NUMBER_REGEX.exec(basename2(file.path));
|
|
1479
1851
|
if (!match) continue;
|
|
1480
1852
|
const number = match[1];
|
|
1481
1853
|
if (number === void 0) continue;
|
|
@@ -1500,7 +1872,7 @@ var rule4 = {
|
|
|
1500
1872
|
var no_duplicate_numbering_default = rule4;
|
|
1501
1873
|
|
|
1502
1874
|
// src/rules/no-numbering-gap/index.ts
|
|
1503
|
-
import { basename as
|
|
1875
|
+
import { basename as basename3 } from "path";
|
|
1504
1876
|
|
|
1505
1877
|
// src/rules/no-numbering-gap/schema.json
|
|
1506
1878
|
var schema_default5 = {
|
|
@@ -1537,7 +1909,7 @@ var rule5 = {
|
|
|
1537
1909
|
check(context) {
|
|
1538
1910
|
const numberToPath = /* @__PURE__ */ new Map();
|
|
1539
1911
|
for (const file of context.files) {
|
|
1540
|
-
const match = NUMBER_REGEX2.exec(
|
|
1912
|
+
const match = NUMBER_REGEX2.exec(basename3(file.path));
|
|
1541
1913
|
if (!match?.[1]) continue;
|
|
1542
1914
|
const n = Number.parseInt(match[1], 10);
|
|
1543
1915
|
if (!Number.isNaN(n)) numberToPath.set(n, file.path);
|
|
@@ -1674,6 +2046,43 @@ var schema_default7 = {
|
|
|
1674
2046
|
};
|
|
1675
2047
|
|
|
1676
2048
|
// src/rules/status-enum/index.ts
|
|
2049
|
+
var TYPO_CORRECTIONS = {
|
|
2050
|
+
superceded: "superseded",
|
|
2051
|
+
supercedes: "supersedes",
|
|
2052
|
+
depricated: "deprecated"
|
|
2053
|
+
};
|
|
2054
|
+
function repairTypos(lower) {
|
|
2055
|
+
return lower.split(" ").map((tok) => TYPO_CORRECTIONS[tok] ?? tok).join(" ");
|
|
2056
|
+
}
|
|
2057
|
+
function tailAfterWords(s, n) {
|
|
2058
|
+
let i = 0;
|
|
2059
|
+
for (let w = 0; w < n; w++) {
|
|
2060
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
2061
|
+
while (i < s.length && !/\s/.test(s[i])) i++;
|
|
2062
|
+
}
|
|
2063
|
+
return s.slice(i);
|
|
2064
|
+
}
|
|
2065
|
+
function correctStatus(status, values, prefixValues) {
|
|
2066
|
+
const repaired = repairTypos(status.toLowerCase());
|
|
2067
|
+
const exact = [...new Set(values.filter((v) => v.toLowerCase() === repaired))];
|
|
2068
|
+
if (exact.length === 1) {
|
|
2069
|
+
return exact[0] !== status ? exact[0] : void 0;
|
|
2070
|
+
}
|
|
2071
|
+
if (exact.length > 1) return void 0;
|
|
2072
|
+
const prefixes = [
|
|
2073
|
+
...new Set(
|
|
2074
|
+
prefixValues.filter((p) => {
|
|
2075
|
+
const pl = p.toLowerCase();
|
|
2076
|
+
return repaired === pl || repaired.startsWith(`${pl} `);
|
|
2077
|
+
})
|
|
2078
|
+
)
|
|
2079
|
+
];
|
|
2080
|
+
if (prefixes.length !== 1) return void 0;
|
|
2081
|
+
const prefix = prefixes[0];
|
|
2082
|
+
const tail = tailAfterWords(status, prefix.split(" ").length);
|
|
2083
|
+
const replacement = `${prefix}${tail}`;
|
|
2084
|
+
return replacement !== status ? replacement : void 0;
|
|
2085
|
+
}
|
|
1677
2086
|
var rule7 = {
|
|
1678
2087
|
meta: {
|
|
1679
2088
|
name: "madr/status-enum",
|
|
@@ -1694,6 +2103,11 @@ var rule7 = {
|
|
|
1694
2103
|
suggestions: {
|
|
1695
2104
|
missingStatus: 'add a "status" field to the frontmatter (for MADR v2, a "* Status: ..." list item) \u2014 allowed values: {{allowed}}'
|
|
1696
2105
|
},
|
|
2106
|
+
// Autofix (#28/#29): normalize a v2 list-sourced value to the unique
|
|
2107
|
+
// canonical enum entry — case difference, a curated misspelling, or a prefix
|
|
2108
|
+
// case/typo — preserving any tail (see create()). Ambiguous or
|
|
2109
|
+
// frontmatter-sourced values get no fix.
|
|
2110
|
+
fixable: "code",
|
|
1697
2111
|
defaultOptions: {
|
|
1698
2112
|
values: ["proposed", "rejected", "accepted", "deprecated"],
|
|
1699
2113
|
prefixValues: ["superseded by"],
|
|
@@ -1719,10 +2133,14 @@ var rule7 = {
|
|
|
1719
2133
|
);
|
|
1720
2134
|
if (!exactMatch && !prefixMatch) {
|
|
1721
2135
|
const loc = context.metadataLoc?.status;
|
|
2136
|
+
const canonical = correctStatus(status, values, prefixValues);
|
|
2137
|
+
const valueRange = context.metadataValueLoc?.status;
|
|
2138
|
+
const fix = canonical !== void 0 && canonical !== status && valueRange ? (fixer) => fixer.replaceRange([valueRange.start, valueRange.end], canonical) : void 0;
|
|
1722
2139
|
context.report({
|
|
1723
2140
|
messageId: "invalidStatus",
|
|
1724
2141
|
...loc ? { loc } : {},
|
|
1725
|
-
data: { status, allowed }
|
|
2142
|
+
data: { status, allowed },
|
|
2143
|
+
...fix ? { fix } : {}
|
|
1726
2144
|
});
|
|
1727
2145
|
}
|
|
1728
2146
|
}
|
|
@@ -1730,7 +2148,7 @@ var rule7 = {
|
|
|
1730
2148
|
var status_enum_default = rule7;
|
|
1731
2149
|
|
|
1732
2150
|
// src/rules/supersedes-bidirectional/index.ts
|
|
1733
|
-
import { basename as
|
|
2151
|
+
import { basename as basename4 } from "path";
|
|
1734
2152
|
|
|
1735
2153
|
// src/rules/supersedes-bidirectional/schema.json
|
|
1736
2154
|
var schema_default8 = {
|
|
@@ -1745,7 +2163,7 @@ var ADR_NUMBER_REGEX = /^(\d{4})-/;
|
|
|
1745
2163
|
var FORWARD_FIELD = "supersedes";
|
|
1746
2164
|
var BACKWARD_FIELD = "superseded-by";
|
|
1747
2165
|
function extractAdrNumber(path) {
|
|
1748
|
-
const match = ADR_NUMBER_REGEX.exec(
|
|
2166
|
+
const match = ADR_NUMBER_REGEX.exec(basename4(path));
|
|
1749
2167
|
return match?.[1] ?? null;
|
|
1750
2168
|
}
|
|
1751
2169
|
function normalizeRefs(value) {
|
|
@@ -1755,6 +2173,28 @@ function normalizeRefs(value) {
|
|
|
1755
2173
|
}
|
|
1756
2174
|
return [];
|
|
1757
2175
|
}
|
|
2176
|
+
function escapeRegExp(s) {
|
|
2177
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2178
|
+
}
|
|
2179
|
+
function frontmatterInsertEdit(content, key, value) {
|
|
2180
|
+
const fmEnd = frontmatterOffset(content);
|
|
2181
|
+
if (fmEnd === 0) return null;
|
|
2182
|
+
const fmBlock = content.slice(0, fmEnd);
|
|
2183
|
+
const closeMatch = /\r?\n(---[ \t]*\r?\n)$/.exec(fmBlock);
|
|
2184
|
+
if (!closeMatch) return null;
|
|
2185
|
+
const fenceLine = closeMatch[1];
|
|
2186
|
+
const offset = closeMatch.index + (closeMatch[0].length - fenceLine.length);
|
|
2187
|
+
if (content.slice(offset, offset + 3) !== "---") return null;
|
|
2188
|
+
const keyPattern = new RegExp(`(^|\\r?\\n)${escapeRegExp(key)}[ \\t]*:`, "i");
|
|
2189
|
+
if (keyPattern.test(content.slice(0, offset))) return null;
|
|
2190
|
+
const eol = fenceLine.endsWith("\r\n") ? "\r\n" : "\n";
|
|
2191
|
+
return { offset, text: `${key}: ${value}${eol}` };
|
|
2192
|
+
}
|
|
2193
|
+
function backReferenceFix(target, key, value) {
|
|
2194
|
+
const edit = frontmatterInsertEdit(target.content, key, value);
|
|
2195
|
+
if (!edit) return void 0;
|
|
2196
|
+
return (fixer) => fixer.insertAt(edit.offset, edit.text);
|
|
2197
|
+
}
|
|
1758
2198
|
var rule8 = {
|
|
1759
2199
|
meta: {
|
|
1760
2200
|
name: "madr/supersedes-bidirectional",
|
|
@@ -1777,6 +2217,11 @@ var rule8 = {
|
|
|
1777
2217
|
unknownReference: 'correct "{{ref}}" to an existing ADR number, or remove "{{direction}}: {{ref}}" from the frontmatter',
|
|
1778
2218
|
missingBackReference: 'add "{{direction}}: {{expected}}" to the frontmatter of this file'
|
|
1779
2219
|
},
|
|
2220
|
+
// Cross-file autofix (#29): `missingBackReference` inserts the reciprocal
|
|
2221
|
+
// `<direction>: <expected>` line into the target's EXISTING frontmatter.
|
|
2222
|
+
// Targets without frontmatter, or where the key already exists, decline;
|
|
2223
|
+
// `unknownReference` is contextual and never fixable.
|
|
2224
|
+
fixable: "code",
|
|
1780
2225
|
defaultOptions: {},
|
|
1781
2226
|
schema: schema_default8
|
|
1782
2227
|
},
|
|
@@ -1804,50 +2249,381 @@ var rule8 = {
|
|
|
1804
2249
|
});
|
|
1805
2250
|
continue;
|
|
1806
2251
|
}
|
|
1807
|
-
const targetBack = normalizeRefs(target.frontmatter?.[BACKWARD_FIELD]);
|
|
1808
|
-
if (!targetBack.includes(fileRef)) {
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
2252
|
+
const targetBack = normalizeRefs(target.frontmatter?.[BACKWARD_FIELD]);
|
|
2253
|
+
if (!targetBack.includes(fileRef)) {
|
|
2254
|
+
const fix = backReferenceFix(target, BACKWARD_FIELD, fileRef);
|
|
2255
|
+
context.report({
|
|
2256
|
+
messageId: "missingBackReference",
|
|
2257
|
+
path: target.path,
|
|
2258
|
+
data: {
|
|
2259
|
+
ref,
|
|
2260
|
+
source: file.path,
|
|
2261
|
+
declared: FORWARD_FIELD,
|
|
2262
|
+
direction: BACKWARD_FIELD,
|
|
2263
|
+
expected: fileRef
|
|
2264
|
+
},
|
|
2265
|
+
...fix ? { fix } : {}
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
for (const ref of backwardRefs) {
|
|
2270
|
+
const target = adrIndex.get(ref);
|
|
2271
|
+
if (!target) {
|
|
2272
|
+
context.report({
|
|
2273
|
+
messageId: "unknownReference",
|
|
2274
|
+
path: file.path,
|
|
2275
|
+
data: { ref, direction: BACKWARD_FIELD }
|
|
2276
|
+
});
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
const targetFwd = normalizeRefs(target.frontmatter?.[FORWARD_FIELD]);
|
|
2280
|
+
if (!targetFwd.includes(fileRef)) {
|
|
2281
|
+
const fix = backReferenceFix(target, FORWARD_FIELD, fileRef);
|
|
2282
|
+
context.report({
|
|
2283
|
+
messageId: "missingBackReference",
|
|
2284
|
+
path: target.path,
|
|
2285
|
+
data: {
|
|
2286
|
+
ref,
|
|
2287
|
+
source: file.path,
|
|
2288
|
+
declared: BACKWARD_FIELD,
|
|
2289
|
+
direction: FORWARD_FIELD,
|
|
2290
|
+
expected: fileRef
|
|
2291
|
+
},
|
|
2292
|
+
...fix ? { fix } : {}
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
};
|
|
2299
|
+
var supersedes_bidirectional_default = rule8;
|
|
2300
|
+
|
|
2301
|
+
// src/commands/init.ts
|
|
2302
|
+
var initCommand = defineCommand({
|
|
2303
|
+
meta: {
|
|
2304
|
+
name: "init",
|
|
2305
|
+
description: "Scaffold a madr-lint config file (detects ADR directory, MADR version, and TS vs JSON)"
|
|
2306
|
+
},
|
|
2307
|
+
args: {
|
|
2308
|
+
force: {
|
|
2309
|
+
type: "boolean",
|
|
2310
|
+
description: "Overwrite an existing config file",
|
|
2311
|
+
default: false
|
|
2312
|
+
},
|
|
2313
|
+
dir: {
|
|
2314
|
+
type: "string",
|
|
2315
|
+
description: "ADR directory to write into the config, overriding detection",
|
|
2316
|
+
required: false
|
|
2317
|
+
},
|
|
2318
|
+
json: {
|
|
2319
|
+
type: "boolean",
|
|
2320
|
+
description: "Emit a machine-readable JSON summary instead of text",
|
|
2321
|
+
default: false
|
|
2322
|
+
}
|
|
2323
|
+
},
|
|
2324
|
+
run({ args }) {
|
|
2325
|
+
const cwd = process.cwd();
|
|
2326
|
+
const rawDir = args.dir;
|
|
2327
|
+
if (rawDir !== void 0 && rawDir.trim() === "") {
|
|
2328
|
+
console.error(`Invalid --dir "${rawDir}": must be a non-empty path.`);
|
|
2329
|
+
process.exit(2);
|
|
2330
|
+
}
|
|
2331
|
+
const existing = findExistingConfigFile(cwd);
|
|
2332
|
+
if (existing && args.force !== true) {
|
|
2333
|
+
console.error(
|
|
2334
|
+
`A madr-lint config already exists: ${existing}. Re-run with --force to overwrite.`
|
|
2335
|
+
);
|
|
2336
|
+
process.exit(2);
|
|
2337
|
+
}
|
|
2338
|
+
let adrDir;
|
|
2339
|
+
let adrDirSource;
|
|
2340
|
+
if (rawDir !== void 0) {
|
|
2341
|
+
adrDir = rawDir;
|
|
2342
|
+
adrDirSource = "override";
|
|
2343
|
+
} else {
|
|
2344
|
+
const detected = detectAdrDir(cwd);
|
|
2345
|
+
adrDir = detected.adrDir;
|
|
2346
|
+
adrDirSource = detected.source;
|
|
2347
|
+
}
|
|
2348
|
+
const madrVersion = detectMadrVersion(cwd, adrDir);
|
|
2349
|
+
const configFormat = detectConfigFormat(cwd);
|
|
2350
|
+
const configPath = CONFIG_FILENAME_BY_FORMAT[configFormat];
|
|
2351
|
+
writeFileSync3(
|
|
2352
|
+
resolve4(cwd, configPath),
|
|
2353
|
+
renderConfig(configFormat, { adrDir, madrVersion })
|
|
2354
|
+
);
|
|
2355
|
+
if (existing && existing !== configPath) {
|
|
2356
|
+
const oldWins = CONFIG_FILES.indexOf(existing) < CONFIG_FILES.indexOf(configPath);
|
|
2357
|
+
console.error(
|
|
2358
|
+
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.`
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
const files = findAdrFiles(resolve4(cwd, adrDir));
|
|
2362
|
+
let errors = 0;
|
|
2363
|
+
let warnings = 0;
|
|
2364
|
+
if (files.length > 0) {
|
|
2365
|
+
try {
|
|
2366
|
+
const config = resolveExtends({ extends: ["madr-lint:recommended"] });
|
|
2367
|
+
const allRules = Object.values(rules_exports);
|
|
2368
|
+
const result = lintFiles({
|
|
2369
|
+
rules: allRules,
|
|
2370
|
+
ruleSeverity: config.rules,
|
|
2371
|
+
files,
|
|
2372
|
+
cwd,
|
|
2373
|
+
cache: null,
|
|
2374
|
+
baseline: null
|
|
2375
|
+
});
|
|
2376
|
+
for (const d of result.diagnostics) {
|
|
2377
|
+
if (d.severity === "error") errors++;
|
|
2378
|
+
else warnings++;
|
|
2379
|
+
}
|
|
2380
|
+
} catch (err) {
|
|
2381
|
+
console.error(
|
|
2382
|
+
`Note: the initial lint failed (${err instanceof Error ? err.message : String(err)}); the config was still written.`
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
const filesChecked = files.length;
|
|
2387
|
+
const suggestUpdateBaseline = filesChecked > 0 && errors + warnings > 0;
|
|
2388
|
+
if (args.json === true) {
|
|
2389
|
+
console.log(
|
|
2390
|
+
JSON.stringify(
|
|
2391
|
+
{
|
|
2392
|
+
written: true,
|
|
2393
|
+
configPath,
|
|
2394
|
+
configFormat,
|
|
2395
|
+
adrDir,
|
|
2396
|
+
adrDirSource,
|
|
2397
|
+
madrVersion,
|
|
2398
|
+
filesChecked,
|
|
2399
|
+
errors,
|
|
2400
|
+
warnings,
|
|
2401
|
+
suggestUpdateBaseline,
|
|
2402
|
+
docsUrl: GETTING_STARTED_URL
|
|
2403
|
+
},
|
|
2404
|
+
null,
|
|
2405
|
+
2
|
|
2406
|
+
)
|
|
2407
|
+
);
|
|
2408
|
+
} else {
|
|
2409
|
+
console.log(
|
|
2410
|
+
buildEpilogue({
|
|
2411
|
+
adrDir,
|
|
2412
|
+
adrDirSource,
|
|
2413
|
+
configPath,
|
|
2414
|
+
filesChecked,
|
|
2415
|
+
errors,
|
|
2416
|
+
warnings
|
|
2417
|
+
})
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
process.exit(0);
|
|
2421
|
+
}
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
// src/core/ignore.ts
|
|
2425
|
+
import { basename as basename5 } from "path";
|
|
2426
|
+
import picomatch from "picomatch";
|
|
2427
|
+
function shouldIgnore(relativePath, patterns) {
|
|
2428
|
+
if (patterns.length === 0) return false;
|
|
2429
|
+
const base = basename5(relativePath);
|
|
2430
|
+
for (const pattern of patterns) {
|
|
2431
|
+
if (matchesPattern(relativePath, base, pattern)) return true;
|
|
2432
|
+
}
|
|
2433
|
+
return false;
|
|
2434
|
+
}
|
|
2435
|
+
function matchesPattern(relativePath, base, pattern) {
|
|
2436
|
+
if (relativePath === pattern) return true;
|
|
2437
|
+
const matcher = picomatch(pattern, { dot: true });
|
|
2438
|
+
if (matcher(relativePath)) return true;
|
|
2439
|
+
if (matcher(base)) return true;
|
|
2440
|
+
if (pattern.includes("/") && !pattern.startsWith("**/")) {
|
|
2441
|
+
const suffixMatcher = picomatch(`**/${pattern}`, { dot: true });
|
|
2442
|
+
if (suffixMatcher(relativePath)) return true;
|
|
2443
|
+
}
|
|
2444
|
+
return false;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// src/core/reporter.ts
|
|
2448
|
+
import pc from "picocolors";
|
|
2449
|
+
var textReporter = {
|
|
2450
|
+
// Deliberately 2-param (meta unused): the CLI prints baselineHiddenSummary()
|
|
2451
|
+
// itself for text format, so the footer can outlive a suppressed banner.
|
|
2452
|
+
format(diagnostics, rulesByName) {
|
|
2453
|
+
if (diagnostics.length === 0) {
|
|
2454
|
+
return pc.green("\u2713 All clear.");
|
|
2455
|
+
}
|
|
2456
|
+
const byFile = groupByFile(diagnostics);
|
|
2457
|
+
const lines = [];
|
|
2458
|
+
const gutter = " ".repeat(9 + 28 + 2);
|
|
2459
|
+
for (const [file, fileDiagnostics] of byFile) {
|
|
2460
|
+
lines.push(pc.underline(file));
|
|
2461
|
+
const docsByRule = /* @__PURE__ */ new Map();
|
|
2462
|
+
for (const d of fileDiagnostics) {
|
|
2463
|
+
const sev = d.severity === "error" ? pc.red(" error ") : pc.yellow(" warn ");
|
|
2464
|
+
const ruleId = pc.dim(d.ruleName.padEnd(28));
|
|
2465
|
+
const message = renderMessage(d, rulesByName);
|
|
2466
|
+
const fixTag = d.fixable ? pc.dim(" \u{1F527} fixable") : "";
|
|
2467
|
+
lines.push(`${sev}${ruleId} ${message}${fixTag}`);
|
|
2468
|
+
if (d.suggestion) {
|
|
2469
|
+
lines.push(`${gutter}${pc.cyan(`\u2192 ${d.suggestion}`)}`);
|
|
2470
|
+
}
|
|
2471
|
+
if (d.docsUrl && !docsByRule.has(d.ruleName)) {
|
|
2472
|
+
docsByRule.set(d.ruleName, d.docsUrl);
|
|
1820
2473
|
}
|
|
1821
2474
|
}
|
|
1822
|
-
for (const
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
2475
|
+
for (const [ruleName, url] of docsByRule) {
|
|
2476
|
+
lines.push(pc.dim(` ${ruleName} ${url}`));
|
|
2477
|
+
}
|
|
2478
|
+
lines.push("");
|
|
2479
|
+
}
|
|
2480
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
2481
|
+
const warnings = diagnostics.filter((d) => d.severity === "warn").length;
|
|
2482
|
+
lines.push(pc.bold(formatSummary(errors, warnings)));
|
|
2483
|
+
return lines.join("\n");
|
|
2484
|
+
}
|
|
2485
|
+
};
|
|
2486
|
+
function groupByFile(diagnostics) {
|
|
2487
|
+
const map = /* @__PURE__ */ new Map();
|
|
2488
|
+
for (const d of diagnostics) {
|
|
2489
|
+
if (!map.has(d.path)) map.set(d.path, []);
|
|
2490
|
+
map.get(d.path)?.push(d);
|
|
2491
|
+
}
|
|
2492
|
+
return map;
|
|
2493
|
+
}
|
|
2494
|
+
function renderMessage(d, rulesByName) {
|
|
2495
|
+
const rule9 = rulesByName.get(d.ruleName);
|
|
2496
|
+
const template = rule9?.meta.messages[d.messageId] ?? d.messageId;
|
|
2497
|
+
return interpolate(template, d.data ?? {});
|
|
2498
|
+
}
|
|
2499
|
+
function formatSummary(errors, warnings) {
|
|
2500
|
+
const parts = [];
|
|
2501
|
+
if (errors > 0) parts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
|
|
2502
|
+
if (warnings > 0) parts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
|
|
2503
|
+
return parts.join(", ");
|
|
2504
|
+
}
|
|
2505
|
+
var jsonReporter = {
|
|
2506
|
+
format(diagnostics, rulesByName, meta) {
|
|
2507
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
2508
|
+
const warnings = diagnostics.filter((d) => d.severity === "warn").length;
|
|
2509
|
+
const payload = {
|
|
2510
|
+
version: 1,
|
|
2511
|
+
summary: {
|
|
2512
|
+
total: diagnostics.length,
|
|
2513
|
+
errors,
|
|
2514
|
+
warnings,
|
|
2515
|
+
// Always present (0 when no baseline) so CI consumers can detect an
|
|
2516
|
+
// active baseline without probing for the key. SARIF stays untouched.
|
|
2517
|
+
baselineHidden: meta?.baselineHidden ?? 0,
|
|
2518
|
+
// `fixed` appears ONLY when a fix pass ran (#28) — a plain lint keeps
|
|
2519
|
+
// the summary shape it always had.
|
|
2520
|
+
...meta?.fixed !== void 0 ? { fixed: meta.fixed } : {}
|
|
2521
|
+
},
|
|
2522
|
+
// Dry-run diffs (#28): embedded so `--fix-dry-run --format json` keeps
|
|
2523
|
+
// stdout pure JSON while still delivering the diff. Absent otherwise.
|
|
2524
|
+
...meta?.fixDiffs !== void 0 ? { diffs: meta.fixDiffs } : {},
|
|
2525
|
+
results: diagnostics.map((d) => ({
|
|
2526
|
+
path: d.path,
|
|
2527
|
+
ruleName: d.ruleName,
|
|
2528
|
+
messageId: d.messageId,
|
|
2529
|
+
severity: d.severity,
|
|
2530
|
+
message: renderMessage(d, rulesByName),
|
|
2531
|
+
// Self-contained diagnostics (#67): always present so consumers can
|
|
2532
|
+
// rely on the keys — `suggestion` is null when the rule declares none.
|
|
2533
|
+
suggestion: d.suggestion,
|
|
2534
|
+
docsUrl: d.docsUrl,
|
|
2535
|
+
// Autofix (#28): whether `--fix` can repair this diagnostic.
|
|
2536
|
+
fixable: d.fixable,
|
|
2537
|
+
data: d.data ?? {}
|
|
2538
|
+
}))
|
|
2539
|
+
};
|
|
2540
|
+
return JSON.stringify(payload, null, 2);
|
|
2541
|
+
}
|
|
2542
|
+
};
|
|
2543
|
+
var sarifReporter = {
|
|
2544
|
+
format(diagnostics, rulesByName) {
|
|
2545
|
+
const ruleNames = [...new Set(diagnostics.map((d) => d.ruleName))];
|
|
2546
|
+
const ruleIndex = new Map(ruleNames.map((name, i) => [name, i]));
|
|
2547
|
+
const sarifRules = ruleNames.map((name) => {
|
|
2548
|
+
const rule9 = rulesByName.get(name);
|
|
2549
|
+
return {
|
|
2550
|
+
id: name,
|
|
2551
|
+
name: name.replace(/[^A-Za-z0-9]/g, "_"),
|
|
2552
|
+
shortDescription: { text: rule9?.meta.docs.description ?? name },
|
|
2553
|
+
helpUri: rule9?.meta.docs.url
|
|
2554
|
+
};
|
|
2555
|
+
});
|
|
2556
|
+
const sarifResults = diagnostics.map((d) => ({
|
|
2557
|
+
ruleId: d.ruleName,
|
|
2558
|
+
ruleIndex: ruleIndex.get(d.ruleName) ?? 0,
|
|
2559
|
+
level: d.severity === "error" ? "error" : "warning",
|
|
2560
|
+
message: { text: renderMessage(d, rulesByName) },
|
|
2561
|
+
locations: [
|
|
2562
|
+
{
|
|
2563
|
+
physicalLocation: {
|
|
2564
|
+
artifactLocation: { uri: d.path, uriBaseId: "%SRCROOT%" },
|
|
2565
|
+
region: { startLine: 1 }
|
|
2566
|
+
}
|
|
1831
2567
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
2568
|
+
]
|
|
2569
|
+
}));
|
|
2570
|
+
const sarif = {
|
|
2571
|
+
$schema: "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0.json",
|
|
2572
|
+
version: "2.1.0",
|
|
2573
|
+
runs: [
|
|
2574
|
+
{
|
|
2575
|
+
tool: {
|
|
2576
|
+
driver: {
|
|
2577
|
+
name: "madr-lint",
|
|
2578
|
+
informationUri: "https://github.com/knktkc/madr-lint",
|
|
2579
|
+
rules: sarifRules
|
|
1843
2580
|
}
|
|
1844
|
-
}
|
|
2581
|
+
},
|
|
2582
|
+
results: sarifResults
|
|
1845
2583
|
}
|
|
1846
|
-
|
|
2584
|
+
]
|
|
2585
|
+
};
|
|
2586
|
+
return JSON.stringify(sarif, null, 2);
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
function escapeGhMessage(s) {
|
|
2590
|
+
return s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
2591
|
+
}
|
|
2592
|
+
function escapeGhProperty(s) {
|
|
2593
|
+
return escapeGhMessage(s).replace(/,/g, "%2C").replace(/:/g, "%3A");
|
|
2594
|
+
}
|
|
2595
|
+
var githubReporter = {
|
|
2596
|
+
format(diagnostics, rulesByName, meta) {
|
|
2597
|
+
const lines = [];
|
|
2598
|
+
for (const d of diagnostics) {
|
|
2599
|
+
const level = d.severity === "error" ? "error" : "warning";
|
|
2600
|
+
const file = escapeGhProperty(d.path);
|
|
2601
|
+
const title = escapeGhProperty(d.ruleName);
|
|
2602
|
+
const rendered = renderMessage(d, rulesByName);
|
|
2603
|
+
const message = escapeGhMessage(
|
|
2604
|
+
d.suggestion ? `${rendered} \u2014 ${d.suggestion}` : rendered
|
|
2605
|
+
);
|
|
2606
|
+
const linePart = d.loc ? `,line=${d.loc.line}` : "";
|
|
2607
|
+
lines.push(`::${level} file=${file}${linePart},title=${title}::${message}`);
|
|
1847
2608
|
}
|
|
2609
|
+
const errors = diagnostics.filter((d) => d.severity === "error").length;
|
|
2610
|
+
const warnings = diagnostics.filter((d) => d.severity === "warn").length;
|
|
2611
|
+
const summaryParts = [];
|
|
2612
|
+
if (errors > 0) summaryParts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
|
|
2613
|
+
if (warnings > 0) summaryParts.push(`${warnings} ${warnings === 1 ? "warning" : "warnings"}`);
|
|
2614
|
+
if (summaryParts.length === 0) summaryParts.push("0 errors, 0 warnings");
|
|
2615
|
+
const baselineHidden = meta?.baselineHidden ?? 0;
|
|
2616
|
+
if (baselineHidden > 0) summaryParts.push(`${baselineHidden} hidden by baseline`);
|
|
2617
|
+
lines.push(summaryParts.join(", "));
|
|
2618
|
+
return lines.join("\n");
|
|
1848
2619
|
}
|
|
1849
2620
|
};
|
|
1850
|
-
var
|
|
2621
|
+
var reporters = {
|
|
2622
|
+
text: textReporter,
|
|
2623
|
+
json: jsonReporter,
|
|
2624
|
+
sarif: sarifReporter,
|
|
2625
|
+
github: githubReporter
|
|
2626
|
+
};
|
|
1851
2627
|
|
|
1852
2628
|
// src/cli.ts
|
|
1853
2629
|
function toPosix2(p) {
|
|
@@ -1855,9 +2631,9 @@ function toPosix2(p) {
|
|
|
1855
2631
|
return p.split(sep2).join("/");
|
|
1856
2632
|
}
|
|
1857
2633
|
var pkg = JSON.parse(
|
|
1858
|
-
|
|
2634
|
+
readFileSync6(new URL("../package.json", import.meta.url), "utf8")
|
|
1859
2635
|
);
|
|
1860
|
-
var main =
|
|
2636
|
+
var main = defineCommand2({
|
|
1861
2637
|
meta: {
|
|
1862
2638
|
name: "madr-lint",
|
|
1863
2639
|
version: pkg.version,
|
|
@@ -1908,6 +2684,16 @@ var main = defineCommand({
|
|
|
1908
2684
|
type: "string",
|
|
1909
2685
|
description: "Path to config file, bypassing auto-discovery",
|
|
1910
2686
|
required: false
|
|
2687
|
+
},
|
|
2688
|
+
fix: {
|
|
2689
|
+
type: "boolean",
|
|
2690
|
+
description: "Apply autofixes in place; exit code reflects remaining problems",
|
|
2691
|
+
default: false
|
|
2692
|
+
},
|
|
2693
|
+
"fix-dry-run": {
|
|
2694
|
+
type: "boolean",
|
|
2695
|
+
description: "Show a diff of the fixes that --fix would apply; write nothing",
|
|
2696
|
+
default: false
|
|
1911
2697
|
}
|
|
1912
2698
|
},
|
|
1913
2699
|
run({ args }) {
|
|
@@ -1928,7 +2714,7 @@ var main = defineCommand({
|
|
|
1928
2714
|
let configExplicit = false;
|
|
1929
2715
|
const configArg = args.config;
|
|
1930
2716
|
if (configArg) {
|
|
1931
|
-
const absConfigPath =
|
|
2717
|
+
const absConfigPath = resolve5(cwd, configArg);
|
|
1932
2718
|
try {
|
|
1933
2719
|
config = loadConfigFromPath(absConfigPath);
|
|
1934
2720
|
configExplicit = true;
|
|
@@ -1973,7 +2759,7 @@ var main = defineCommand({
|
|
|
1973
2759
|
const cacheEnabled = (args.cache ?? config.cache) === true;
|
|
1974
2760
|
const cacheDir = args["cache-dir"] ?? config.cacheLocation;
|
|
1975
2761
|
const cacheConfig = cacheEnabled ? {
|
|
1976
|
-
dir:
|
|
2762
|
+
dir: resolve5(cwd, cacheDir),
|
|
1977
2763
|
configHash: computeConfigHash({
|
|
1978
2764
|
rules: config.rules,
|
|
1979
2765
|
ignorePatterns: config.ignorePatterns,
|
|
@@ -1985,10 +2771,107 @@ var main = defineCommand({
|
|
|
1985
2771
|
const updateBaseline = args["update-baseline"] === true;
|
|
1986
2772
|
const useBaseline = !updateBaseline && args.baseline !== false;
|
|
1987
2773
|
const baseline = useBaseline ? loadBaseline(baselineFile) : null;
|
|
1988
|
-
if (useBaseline && baseline === null &&
|
|
2774
|
+
if (useBaseline && baseline === null && existsSync6(baselineFile)) {
|
|
1989
2775
|
console.error(baselineMalformedWarning());
|
|
1990
2776
|
}
|
|
1991
2777
|
const allRulesArray = Object.values(rules_exports);
|
|
2778
|
+
const format = args.format ?? "text";
|
|
2779
|
+
const reportAndExit = (diagnostics, baselineHidden, fixedCount, dryRun, fixDiffs) => {
|
|
2780
|
+
const rulesByName = new Map(
|
|
2781
|
+
allRulesArray.map((r) => [r.meta.name, r])
|
|
2782
|
+
);
|
|
2783
|
+
const reporter = reporters[format];
|
|
2784
|
+
if (!reporter) {
|
|
2785
|
+
console.error(
|
|
2786
|
+
`Unknown --format "${format}". Available: ${Object.keys(reporters).join(", ")}`
|
|
2787
|
+
);
|
|
2788
|
+
process.exit(2);
|
|
2789
|
+
}
|
|
2790
|
+
const warningCount = diagnostics.filter((d) => d.severity === "warn").length;
|
|
2791
|
+
const errorCount = diagnostics.filter((d) => d.severity === "error").length;
|
|
2792
|
+
const overWarningLimit = maxWarnings >= 0 && warningCount > maxWarnings;
|
|
2793
|
+
const reported = args.quiet ? diagnostics.filter((d) => d.severity !== "warn") : diagnostics;
|
|
2794
|
+
const suppressAllClear = format === "text" && reported.length === 0 && overWarningLimit;
|
|
2795
|
+
if (!suppressAllClear) {
|
|
2796
|
+
console.log(
|
|
2797
|
+
reporter.format(reported, rulesByName, {
|
|
2798
|
+
baselineHidden,
|
|
2799
|
+
...fixedCount !== void 0 ? { fixed: fixedCount } : {},
|
|
2800
|
+
...fixDiffs !== void 0 ? { fixDiffs } : {}
|
|
2801
|
+
})
|
|
2802
|
+
);
|
|
2803
|
+
}
|
|
2804
|
+
if (format === "text" && baselineHidden > 0) {
|
|
2805
|
+
console.log(baselineHiddenSummary(baselineHidden));
|
|
2806
|
+
}
|
|
2807
|
+
if (format === "text" && fixedCount !== void 0 && fixedCount > 0) {
|
|
2808
|
+
const noun = fixedCount === 1 ? "problem" : "problems";
|
|
2809
|
+
console.log(
|
|
2810
|
+
dryRun ? `${fixedCount} ${noun} fixable (dry run; no files written)` : `Fixed ${fixedCount} ${noun}`
|
|
2811
|
+
);
|
|
2812
|
+
}
|
|
2813
|
+
if (overWarningLimit) {
|
|
2814
|
+
console.error(
|
|
2815
|
+
`madr-lint: ${warningCount} warning(s) found, exceeds --max-warnings ${maxWarnings}`
|
|
2816
|
+
);
|
|
2817
|
+
}
|
|
2818
|
+
process.exit(errorCount > 0 || overWarningLimit ? 1 : 0);
|
|
2819
|
+
};
|
|
2820
|
+
const fixMode = args.fix === true || args["fix-dry-run"] === true;
|
|
2821
|
+
if (fixMode && updateBaseline) {
|
|
2822
|
+
console.error(
|
|
2823
|
+
"madr-lint: --update-baseline cannot be combined with --fix or --fix-dry-run"
|
|
2824
|
+
);
|
|
2825
|
+
process.exit(2);
|
|
2826
|
+
}
|
|
2827
|
+
if (fixMode) {
|
|
2828
|
+
const dryRun = args["fix-dry-run"] === true;
|
|
2829
|
+
let fixResult;
|
|
2830
|
+
try {
|
|
2831
|
+
fixResult = lintAndFix({
|
|
2832
|
+
rules: allRulesArray,
|
|
2833
|
+
ruleSeverity: config.rules,
|
|
2834
|
+
files,
|
|
2835
|
+
cwd,
|
|
2836
|
+
baseline
|
|
2837
|
+
});
|
|
2838
|
+
} catch (err) {
|
|
2839
|
+
if (err instanceof RuleOptionsError) {
|
|
2840
|
+
console.error(`Invalid rule options in config: ${err.message}`);
|
|
2841
|
+
process.exit(2);
|
|
2842
|
+
}
|
|
2843
|
+
throw err;
|
|
2844
|
+
}
|
|
2845
|
+
let fixDiffs;
|
|
2846
|
+
if (dryRun) {
|
|
2847
|
+
const changed = fixResult.files.filter((f) => f.changed);
|
|
2848
|
+
if (format === "text") {
|
|
2849
|
+
for (const f of changed) {
|
|
2850
|
+
process.stdout.write(unifiedDiff(f.path, f.original, f.fixed));
|
|
2851
|
+
}
|
|
2852
|
+
} else if (format === "json") {
|
|
2853
|
+
fixDiffs = changed.map((f) => ({
|
|
2854
|
+
path: f.path,
|
|
2855
|
+
diff: unifiedDiff(f.path, f.original, f.fixed)
|
|
2856
|
+
}));
|
|
2857
|
+
} else {
|
|
2858
|
+
for (const f of changed) {
|
|
2859
|
+
process.stderr.write(unifiedDiff(f.path, f.original, f.fixed));
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
} else {
|
|
2863
|
+
for (const f of fixResult.files) {
|
|
2864
|
+
if (f.changed) writeFileSync4(f.absPath, f.fixed, "utf8");
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
reportAndExit(
|
|
2868
|
+
fixResult.diagnostics,
|
|
2869
|
+
fixResult.baselineHidden,
|
|
2870
|
+
fixResult.fixed,
|
|
2871
|
+
dryRun,
|
|
2872
|
+
fixDiffs
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
1992
2875
|
let result;
|
|
1993
2876
|
try {
|
|
1994
2877
|
result = lintFiles({
|
|
@@ -2012,40 +2895,13 @@ var main = defineCommand({
|
|
|
2012
2895
|
console.log(baselineWriteSummary(built));
|
|
2013
2896
|
process.exit(0);
|
|
2014
2897
|
}
|
|
2015
|
-
|
|
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);
|
|
2898
|
+
reportAndExit(result.diagnostics, result.baselineHidden, void 0, false);
|
|
2048
2899
|
}
|
|
2049
2900
|
});
|
|
2050
|
-
|
|
2901
|
+
var rawArgs = process.argv.slice(2);
|
|
2902
|
+
if (rawArgs[0] === "init") {
|
|
2903
|
+
await runMain(initCommand, { rawArgs: rawArgs.slice(1) });
|
|
2904
|
+
} else {
|
|
2905
|
+
await runMain(main);
|
|
2906
|
+
}
|
|
2051
2907
|
//# sourceMappingURL=cli.js.map
|