madr-lint 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +2 -0
- package/dist/cli.js +94 -17
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +67 -9
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -33,6 +33,14 @@ interface RuleMeta<TOptions = Record<string, unknown>> {
|
|
|
33
33
|
};
|
|
34
34
|
/** messageId -> template string with {{placeholders}}. */
|
|
35
35
|
messages: Record<string, string>;
|
|
36
|
+
/**
|
|
37
|
+
* messageId -> machine-actionable remediation template, parallel to
|
|
38
|
+
* `messages` and interpolated the same way. Partial: declare an entry only
|
|
39
|
+
* where a concrete, mechanical fix exists; omit where the fix is inherently
|
|
40
|
+
* contextual. The runner resolves the entry for a reported messageId into
|
|
41
|
+
* `Diagnostic.suggestion` (null when absent). See issue #67.
|
|
42
|
+
*/
|
|
43
|
+
suggestions?: Record<string, string>;
|
|
36
44
|
/** Default options merged with user config. */
|
|
37
45
|
defaultOptions: TOptions;
|
|
38
46
|
/** AJV JSON Schema for options. Validated at runRule time. */
|
|
@@ -60,6 +68,19 @@ interface Diagnostic {
|
|
|
60
68
|
};
|
|
61
69
|
/** Data for placeholder interpolation in messages[messageId]. */
|
|
62
70
|
data?: Record<string, unknown>;
|
|
71
|
+
/**
|
|
72
|
+
* Machine-actionable remediation, resolved by the runner at report time from
|
|
73
|
+
* `rule.meta.suggestions[messageId]` (interpolated with `data`, exactly like
|
|
74
|
+
* the message). `null` when the rule declares no suggestion for this
|
|
75
|
+
* messageId. Every runner-produced diagnostic sets this. See issue #67.
|
|
76
|
+
*/
|
|
77
|
+
suggestion: string | null;
|
|
78
|
+
/**
|
|
79
|
+
* The rule's documentation URL (`rule.meta.docs.url`), resolved by the runner
|
|
80
|
+
* so consumers need not reconstruct it from `ruleName`. `core/internal-error`
|
|
81
|
+
* diagnostics point at the repository; a rule without a docs URL yields `''`.
|
|
82
|
+
*/
|
|
83
|
+
docsUrl: string;
|
|
63
84
|
}
|
|
64
85
|
interface RuleContext<TOptions = Record<string, unknown>> {
|
|
65
86
|
/** The file being linted. */
|
|
@@ -88,8 +109,8 @@ interface RuleContext<TOptions = Record<string, unknown>> {
|
|
|
88
109
|
}> | null;
|
|
89
110
|
/** User-merged options for this rule (validated against rule.meta.schema). */
|
|
90
111
|
options: TOptions;
|
|
91
|
-
/** Emit a diagnostic. */
|
|
92
|
-
report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity' | 'path'>): void;
|
|
112
|
+
/** Emit a diagnostic. `suggestion` / `docsUrl` are resolved by the runner. */
|
|
113
|
+
report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity' | 'path' | 'suggestion' | 'docsUrl'>): void;
|
|
93
114
|
}
|
|
94
115
|
/**
|
|
95
116
|
* Visitor map dispatched during single-pass mdast traversal.
|
|
@@ -156,9 +177,9 @@ interface ProjectRuleContext<TOptions = Record<string, unknown>> {
|
|
|
156
177
|
/**
|
|
157
178
|
* Emit a diagnostic. Unlike per-file rules, project rules MUST set
|
|
158
179
|
* `path` explicitly — the runner cannot infer which file the
|
|
159
|
-
* diagnostic relates to.
|
|
180
|
+
* diagnostic relates to. `suggestion` / `docsUrl` are resolved by the runner.
|
|
160
181
|
*/
|
|
161
|
-
report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity'>): void;
|
|
182
|
+
report(diagnostic: Omit<Diagnostic, 'ruleName' | 'severity' | 'suggestion' | 'docsUrl'>): void;
|
|
162
183
|
}
|
|
163
184
|
/**
|
|
164
185
|
* A cross-file rule. Receives all parsed files at once and reports
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,15 @@ function isProjectRule(rule9) {
|
|
|
13
13
|
return "check" in rule9;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// src/core/interpolate.ts
|
|
17
|
+
function interpolate(template, data) {
|
|
18
|
+
if (!template.includes("{{")) return template;
|
|
19
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
20
|
+
const v = data[key];
|
|
21
|
+
return v !== void 0 ? String(v) : `{{${key}}}`;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
// src/core/parser.ts
|
|
17
26
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
18
27
|
import { toString } from "mdast-util-to-string";
|
|
@@ -252,6 +261,7 @@ function getValidator(schema) {
|
|
|
252
261
|
}
|
|
253
262
|
return validator;
|
|
254
263
|
}
|
|
264
|
+
var INTERNAL_ERROR_DOCS_URL = "https://github.com/knktkc/madr-lint";
|
|
255
265
|
var RuleOptionsError = class extends Error {
|
|
256
266
|
constructor(ruleName, ajvErrors) {
|
|
257
267
|
super(`Invalid options for rule ${ruleName}: ${ajvErrors}`);
|
|
@@ -309,15 +319,21 @@ function runRulesOnFile(rules, file, runtime = {}) {
|
|
|
309
319
|
throw new RuleOptionsError(rule9.meta.name, ajv.errorsText(validator.errors));
|
|
310
320
|
}
|
|
311
321
|
}
|
|
322
|
+
const ruleName = rule9.meta.name;
|
|
323
|
+
const suggestions = rule9.meta.suggestions;
|
|
324
|
+
const docsUrl = rule9.meta.docs.url ?? "";
|
|
312
325
|
const context = new PerFileRuleContext(
|
|
313
326
|
file,
|
|
314
327
|
mergedOptions,
|
|
315
328
|
ensureParsed,
|
|
316
329
|
(d) => {
|
|
330
|
+
const template = suggestions?.[d.messageId];
|
|
317
331
|
diagnostics.push({
|
|
318
|
-
ruleName
|
|
332
|
+
ruleName,
|
|
319
333
|
severity,
|
|
320
334
|
path: file.path,
|
|
335
|
+
suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
|
|
336
|
+
docsUrl,
|
|
321
337
|
...d
|
|
322
338
|
});
|
|
323
339
|
}
|
|
@@ -352,6 +368,8 @@ function internalErrorDiagnostic(ruleName, operation, err, path) {
|
|
|
352
368
|
messageId: "ruleThrew",
|
|
353
369
|
severity: "error",
|
|
354
370
|
path,
|
|
371
|
+
suggestion: null,
|
|
372
|
+
docsUrl: INTERNAL_ERROR_DOCS_URL,
|
|
355
373
|
data: {
|
|
356
374
|
rule: ruleName,
|
|
357
375
|
operation,
|
|
@@ -426,14 +444,20 @@ function runRulesOnProject(rules, files, runtime = {}) {
|
|
|
426
444
|
);
|
|
427
445
|
}
|
|
428
446
|
}
|
|
447
|
+
const ruleName = rule9.meta.name;
|
|
448
|
+
const suggestions = rule9.meta.suggestions;
|
|
449
|
+
const docsUrl = rule9.meta.docs.url ?? "";
|
|
429
450
|
const context = {
|
|
430
451
|
files,
|
|
431
452
|
options: mergedOptions,
|
|
432
453
|
fileExists: runtime.fileExists,
|
|
433
454
|
report(d) {
|
|
455
|
+
const template = suggestions?.[d.messageId];
|
|
434
456
|
diagnostics.push({
|
|
435
|
-
ruleName
|
|
457
|
+
ruleName,
|
|
436
458
|
severity,
|
|
459
|
+
suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
|
|
460
|
+
docsUrl,
|
|
437
461
|
...d
|
|
438
462
|
});
|
|
439
463
|
}
|
|
@@ -506,6 +530,10 @@ var rule = {
|
|
|
506
530
|
missingDate: 'Metadata does not contain a "{{field}}" field (checked frontmatter and v2 bold-list)',
|
|
507
531
|
invalidDate: 'Date "{{date}}" is not a valid ISO 8601 calendar date (YYYY-MM-DD)'
|
|
508
532
|
},
|
|
533
|
+
suggestions: {
|
|
534
|
+
missingDate: 'add a "{{field}}" field in YYYY-MM-DD format to the frontmatter (for MADR v2, a "* Date: ..." list item)',
|
|
535
|
+
invalidDate: "use the YYYY-MM-DD calendar-date format, e.g. 2025-03-14"
|
|
536
|
+
},
|
|
509
537
|
defaultOptions: {
|
|
510
538
|
field: "date"
|
|
511
539
|
},
|
|
@@ -598,6 +626,11 @@ var rule2 = {
|
|
|
598
626
|
messages: {
|
|
599
627
|
invalidFilename: 'Filename "{{filename}}" does not match expected pattern "{{expected}}"'
|
|
600
628
|
},
|
|
629
|
+
suggestions: {
|
|
630
|
+
// The message already states the expected pattern; the suggestion adds a
|
|
631
|
+
// concrete, conforming example of the NNNN-kebab-case.md convention.
|
|
632
|
+
invalidFilename: "rename the file to the NNNN-kebab-case.md convention, e.g. 0001-record-architecture-decisions.md"
|
|
633
|
+
},
|
|
601
634
|
defaultOptions: {
|
|
602
635
|
pattern: "^[0-9]{4}-[a-z0-9-]+\\.md$"
|
|
603
636
|
},
|
|
@@ -677,6 +710,9 @@ var rule3 = {
|
|
|
677
710
|
messages: {
|
|
678
711
|
brokenLink: 'Link to "{{url}}" resolves to "{{resolvedPath}}", which does not exist in the project'
|
|
679
712
|
},
|
|
713
|
+
suggestions: {
|
|
714
|
+
brokenLink: "fix the link target or remove the link; relative paths resolve from the ADR file's own directory"
|
|
715
|
+
},
|
|
680
716
|
defaultOptions: {},
|
|
681
717
|
schema: schema_default3
|
|
682
718
|
},
|
|
@@ -737,6 +773,9 @@ var rule4 = {
|
|
|
737
773
|
messages: {
|
|
738
774
|
duplicateNumber: "ADR number {{number}} is used by multiple files: {{paths}}"
|
|
739
775
|
},
|
|
776
|
+
suggestions: {
|
|
777
|
+
duplicateNumber: "renumber all but one of these files so each ADR has a unique NNNN- prefix"
|
|
778
|
+
},
|
|
740
779
|
defaultOptions: {},
|
|
741
780
|
schema: schema_default4
|
|
742
781
|
},
|
|
@@ -796,6 +835,9 @@ var rule5 = {
|
|
|
796
835
|
messages: {
|
|
797
836
|
numberingGap: "Numbering gap: missing {{missing}} between ADR-{{from}} and ADR-{{to}}"
|
|
798
837
|
},
|
|
838
|
+
// No `suggestions`: a gap can be intentional (a rejected or deleted ADR),
|
|
839
|
+
// and closing it may mean either adding the missing ADR OR renumbering —
|
|
840
|
+
// the right remediation is contextual, so we omit a suggestion. See #67.
|
|
799
841
|
defaultOptions: {},
|
|
800
842
|
schema: schema_default5
|
|
801
843
|
},
|
|
@@ -872,6 +914,9 @@ var rule6 = {
|
|
|
872
914
|
messages: {
|
|
873
915
|
missingSection: 'Missing required section: "{{section}}"'
|
|
874
916
|
},
|
|
917
|
+
suggestions: {
|
|
918
|
+
missingSection: 'add a "## {{section}}" heading to the document body'
|
|
919
|
+
},
|
|
875
920
|
defaultOptions: {
|
|
876
921
|
sections: [
|
|
877
922
|
"Context and Problem Statement",
|
|
@@ -950,6 +995,12 @@ var rule7 = {
|
|
|
950
995
|
missingStatus: 'Metadata does not contain a "status" field (checked frontmatter and v2 bold-list)',
|
|
951
996
|
invalidStatus: 'Status "{{status}}" is not one of: {{allowed}}'
|
|
952
997
|
},
|
|
998
|
+
// No suggestion for invalidStatus: the message already lists the allowed
|
|
999
|
+
// values (the expected-vs-actual is self-contained), so a suggestion would
|
|
1000
|
+
// only restate it. See issue #67.
|
|
1001
|
+
suggestions: {
|
|
1002
|
+
missingStatus: 'add a "status" field to the frontmatter (for MADR v2, a "* Status: ..." list item) \u2014 allowed values: {{allowed}}'
|
|
1003
|
+
},
|
|
953
1004
|
defaultOptions: {
|
|
954
1005
|
values: ["proposed", "rejected", "accepted", "deprecated"],
|
|
955
1006
|
prefixValues: ["superseded by"],
|
|
@@ -958,12 +1009,13 @@ var rule7 = {
|
|
|
958
1009
|
schema: schema_default7
|
|
959
1010
|
},
|
|
960
1011
|
create(context) {
|
|
1012
|
+
const { values, prefixValues, caseSensitive } = context.options;
|
|
1013
|
+
const allowed = [...values, ...prefixValues.map((p) => `${p} ...`)];
|
|
961
1014
|
const meta = context.metadata;
|
|
962
1015
|
if (!meta || typeof meta.status !== "string") {
|
|
963
|
-
context.report({ messageId: "missingStatus", data: {} });
|
|
1016
|
+
context.report({ messageId: "missingStatus", data: { allowed } });
|
|
964
1017
|
return;
|
|
965
1018
|
}
|
|
966
|
-
const { values, prefixValues, caseSensitive } = context.options;
|
|
967
1019
|
const status = meta.status;
|
|
968
1020
|
const compareStatus = caseSensitive ? status : status.toLowerCase();
|
|
969
1021
|
const compareValues = caseSensitive ? values : values.map((v) => v.toLowerCase());
|
|
@@ -973,10 +1025,6 @@ var rule7 = {
|
|
|
973
1025
|
(p) => compareStatus.startsWith(p)
|
|
974
1026
|
);
|
|
975
1027
|
if (!exactMatch && !prefixMatch) {
|
|
976
|
-
const allowed = [
|
|
977
|
-
...values,
|
|
978
|
-
...prefixValues.map((p) => `${p} ...`)
|
|
979
|
-
];
|
|
980
1028
|
const loc = context.metadataLoc?.status;
|
|
981
1029
|
context.report({
|
|
982
1030
|
messageId: "invalidStatus",
|
|
@@ -1026,7 +1074,15 @@ var rule8 = {
|
|
|
1026
1074
|
},
|
|
1027
1075
|
messages: {
|
|
1028
1076
|
unknownReference: "Frontmatter `{{direction}}: {{ref}}` references an ADR that does not exist",
|
|
1029
|
-
|
|
1077
|
+
// `declared` is the field the SOURCE file actually declares;
|
|
1078
|
+
// `direction` is the field THIS file (the diagnostic's path) must add.
|
|
1079
|
+
// They are opposites — rendering `direction` as the declared field
|
|
1080
|
+
// would mislabel the source's frontmatter in both directions.
|
|
1081
|
+
missingBackReference: "`{{source}}` declares `{{declared}}: {{ref}}`, but `{{ref}}` (this file) does not back-reference it via `{{direction}}: {{expected}}`"
|
|
1082
|
+
},
|
|
1083
|
+
suggestions: {
|
|
1084
|
+
unknownReference: 'correct "{{ref}}" to an existing ADR number, or remove "{{direction}}: {{ref}}" from the frontmatter',
|
|
1085
|
+
missingBackReference: 'add "{{direction}}: {{expected}}" to the frontmatter of this file'
|
|
1030
1086
|
},
|
|
1031
1087
|
defaultOptions: {},
|
|
1032
1088
|
schema: schema_default8
|
|
@@ -1063,6 +1119,7 @@ var rule8 = {
|
|
|
1063
1119
|
data: {
|
|
1064
1120
|
ref,
|
|
1065
1121
|
source: file.path,
|
|
1122
|
+
declared: FORWARD_FIELD,
|
|
1066
1123
|
direction: BACKWARD_FIELD,
|
|
1067
1124
|
expected: fileRef
|
|
1068
1125
|
}
|
|
@@ -1087,6 +1144,7 @@ var rule8 = {
|
|
|
1087
1144
|
data: {
|
|
1088
1145
|
ref,
|
|
1089
1146
|
source: file.path,
|
|
1147
|
+
declared: BACKWARD_FIELD,
|
|
1090
1148
|
direction: FORWARD_FIELD,
|
|
1091
1149
|
expected: fileRef
|
|
1092
1150
|
}
|