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/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: rule9.meta.name,
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: rule9.meta.name,
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
- missingBackReference: "`{{source}}` declares `{{direction}}: {{ref}}`, but `{{ref}}` (this file) does not back-reference it via `{{expected}}`"
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
  }