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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#71](https://github.com/knktkc/madr-lint/pull/71) [`55e7b89`](https://github.com/knktkc/madr-lint/commit/55e7b89a5d1dc6c3109e04b02c082aadd713b701) Thanks [@knktkc](https://github.com/knktkc)! - Diagnostics now carry machine-actionable `suggestion` and `docsUrl` in the json/text/github output ([#67](https://github.com/knktkc/madr-lint/issues/67)). Error feedback is self-contained — expected vs actual plus a concrete fix and a docs link — so agents and humans repair violations without a separate doc lookup.
8
+
9
+ - **Rule meta** gains an optional declarative `suggestions` map (messageId → template), interpolated with the diagnostic data exactly like `messages`. Rules stay declarative — no imperative suggestion building. Declare an entry only where a mechanical fix exists; omit where the fix is contextual.
10
+ - **`Diagnostic`** gains `suggestion: string | null` (null when the rule defines none for that message) and `docsUrl: string` (the rule's `docs.url`; the repository for `core/internal-error`), both resolved by the runner at report time.
11
+ - **Reporters**: `text` renders an indented `→ <suggestion>` line under a diagnostic and prints the docs URL once per rule per file group (compact — never per diagnostic); `json` adds `suggestion` and `docsUrl` to every result (keys always present); `github` appends ` — <suggestion>` to the annotation message (escaped). `sarif` is unchanged — its `helpUri` already carries the docs URL.
12
+ - **All 8 rules audited**; suggestions filled where the remediation is mechanical (`required-sections`, `status-enum` missing status, `date-iso8601`, `filename-format`, `no-broken-links`, `no-duplicate-numbering`, `supersedes-bidirectional`) and omitted where contextual (`no-numbering-gap`; `status-enum` invalid status already lists the allowed values).
13
+ - **Fixed** `supersedes-bidirectional`'s `missingBackReference` message, which named the wrong declared field: it now renders the field the source file actually declares (`supersedes:` vs `superseded-by:`) and states the exact `field: value` back-reference to add.
14
+ - **Breaking (pre-1.0)**: `Diagnostic` gains two required fields — external code that **constructs** `Diagnostic` values must now supply `suggestion` (`string | null`) and `docsUrl` (`string`); consumers that only read diagnostics are unaffected (the keys are always present). The on-disk cache manifest also gains a schema version (`schemaVersion: 2`; missing/mismatched ⇒ cold), so the first run after upgrading re-lints once instead of serving stale-shape cached diagnostics.
15
+
16
+ Resolution is report-time only, so it stays zero-cost on clean files and free on the sub-microsecond hot path.
17
+
3
18
  ## 0.2.0
4
19
 
5
20
  ### Minor Changes
package/README.md CHANGED
@@ -15,6 +15,8 @@
15
15
  [Getting started](https://knktkc.github.io/madr-lint/guides/getting-started/) ·
16
16
  [Rules](https://knktkc.github.io/madr-lint/rules/)
17
17
 
18
+ <sub>For AI agents: [llms.txt](https://knktkc.github.io/madr-lint/llms.txt) indexes the docs; [llms-full.txt](https://knktkc.github.io/madr-lint/llms-full.txt) has the full text in one fetch; the [AI agents guide](https://knktkc.github.io/madr-lint/guides/ai-agents/) covers the ready-made [`adopt-madr-lint`](https://github.com/knktkc/madr-lint/blob/main/skills/adopt-madr-lint/SKILL.md) / [`new-adr`](https://github.com/knktkc/madr-lint/blob/main/skills/new-adr/SKILL.md) skills.</sub>
19
+
18
20
  </div>
19
21
 
20
22
  A fast, configurable linter for [MADR](https://adr.github.io/madr/) (Markdown Architectural Decision Records). It validates the things a plain Markdown linter can't: required sections, a valid status, ISO-8601 dates, filename convention, and cross-file integrity like unique numbering and non-broken links — across MADR **v2, v3, and v4**.
package/dist/cli.js CHANGED
@@ -152,6 +152,7 @@ function baselineHiddenSummary(hidden) {
152
152
  import { createHash } from "crypto";
153
153
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
154
154
  import { dirname as dirname2, join as join2 } from "path";
155
+ var CACHE_SCHEMA_VERSION = 2;
155
156
  function computeContentHash(content) {
156
157
  return createHash("sha1").update(content, "utf8").digest("hex");
157
158
  }
@@ -177,7 +178,12 @@ function loadManifest(path) {
177
178
  if (!existsSync2(path)) return null;
178
179
  try {
179
180
  const data = JSON.parse(readFileSync2(path, "utf8"));
180
- if (typeof data.version !== "string" || typeof data.configHash !== "string" || typeof data.files !== "object" || data.files === null) {
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
+ ) {
181
187
  return null;
182
188
  }
183
189
  return data;
@@ -327,6 +333,15 @@ import { isAbsolute, relative, resolve as resolve2, sep } from "path";
327
333
  // src/core/runner.ts
328
334
  import Ajv from "ajv";
329
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
+ }
344
+
330
345
  // src/core/parser.ts
331
346
  import { fromMarkdown } from "mdast-util-from-markdown";
332
347
  import { toString } from "mdast-util-to-string";
@@ -563,6 +578,7 @@ function getValidator(schema) {
563
578
  }
564
579
  return validator;
565
580
  }
581
+ var INTERNAL_ERROR_DOCS_URL = "https://github.com/knktkc/madr-lint";
566
582
  var RuleOptionsError = class extends Error {
567
583
  constructor(ruleName, ajvErrors) {
568
584
  super(`Invalid options for rule ${ruleName}: ${ajvErrors}`);
@@ -617,15 +633,21 @@ function runRulesOnFile(rules, file, runtime = {}) {
617
633
  throw new RuleOptionsError(rule9.meta.name, ajv.errorsText(validator.errors));
618
634
  }
619
635
  }
636
+ const ruleName = rule9.meta.name;
637
+ const suggestions = rule9.meta.suggestions;
638
+ const docsUrl = rule9.meta.docs.url ?? "";
620
639
  const context = new PerFileRuleContext(
621
640
  file,
622
641
  mergedOptions,
623
642
  ensureParsed,
624
643
  (d) => {
644
+ const template = suggestions?.[d.messageId];
625
645
  diagnostics.push({
626
- ruleName: rule9.meta.name,
646
+ ruleName,
627
647
  severity,
628
648
  path: file.path,
649
+ suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
650
+ docsUrl,
629
651
  ...d
630
652
  });
631
653
  }
@@ -660,6 +682,8 @@ function internalErrorDiagnostic(ruleName, operation, err, path) {
660
682
  messageId: "ruleThrew",
661
683
  severity: "error",
662
684
  path,
685
+ suggestion: null,
686
+ docsUrl: INTERNAL_ERROR_DOCS_URL,
663
687
  data: {
664
688
  rule: ruleName,
665
689
  operation,
@@ -734,14 +758,20 @@ function runRulesOnProject(rules, files, runtime = {}) {
734
758
  );
735
759
  }
736
760
  }
761
+ const ruleName = rule9.meta.name;
762
+ const suggestions = rule9.meta.suggestions;
763
+ const docsUrl = rule9.meta.docs.url ?? "";
737
764
  const context = {
738
765
  files,
739
766
  options: mergedOptions,
740
767
  fileExists: runtime.fileExists,
741
768
  report(d) {
769
+ const template = suggestions?.[d.messageId];
742
770
  diagnostics.push({
743
- ruleName: rule9.meta.name,
771
+ ruleName,
744
772
  severity,
773
+ suggestion: template === void 0 ? null : interpolate(template, d.data ?? {}),
774
+ docsUrl,
745
775
  ...d
746
776
  });
747
777
  }
@@ -784,6 +814,7 @@ function lintFiles(opts) {
784
814
  manifest = loaded;
785
815
  } else {
786
816
  manifest = {
817
+ schemaVersion: CACHE_SCHEMA_VERSION,
787
818
  version: opts.cache.pkgVersion,
788
819
  configHash: opts.cache.configHash,
789
820
  files: {}
@@ -974,13 +1005,24 @@ var textReporter = {
974
1005
  }
975
1006
  const byFile = groupByFile(diagnostics);
976
1007
  const lines = [];
1008
+ const gutter = " ".repeat(9 + 28 + 2);
977
1009
  for (const [file, fileDiagnostics] of byFile) {
978
1010
  lines.push(pc.underline(file));
1011
+ const docsByRule = /* @__PURE__ */ new Map();
979
1012
  for (const d of fileDiagnostics) {
980
1013
  const sev = d.severity === "error" ? pc.red(" error ") : pc.yellow(" warn ");
981
1014
  const ruleId = pc.dim(d.ruleName.padEnd(28));
982
1015
  const message = renderMessage(d, rulesByName);
983
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}`));
984
1026
  }
985
1027
  lines.push("");
986
1028
  }
@@ -1003,12 +1045,6 @@ function renderMessage(d, rulesByName) {
1003
1045
  const template = rule9?.meta.messages[d.messageId] ?? d.messageId;
1004
1046
  return interpolate(template, d.data ?? {});
1005
1047
  }
1006
- function interpolate(template, data) {
1007
- return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
1008
- const v = data[key];
1009
- return v !== void 0 ? String(v) : `{{${key}}}`;
1010
- });
1011
- }
1012
1048
  function formatSummary(errors, warnings) {
1013
1049
  const parts = [];
1014
1050
  if (errors > 0) parts.push(`${errors} ${errors === 1 ? "error" : "errors"}`);
@@ -1035,6 +1071,10 @@ var jsonReporter = {
1035
1071
  messageId: d.messageId,
1036
1072
  severity: d.severity,
1037
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,
1038
1078
  data: d.data ?? {}
1039
1079
  }))
1040
1080
  };
@@ -1100,7 +1140,10 @@ var githubReporter = {
1100
1140
  const level = d.severity === "error" ? "error" : "warning";
1101
1141
  const file = escapeGhProperty(d.path);
1102
1142
  const title = escapeGhProperty(d.ruleName);
1103
- const message = escapeGhMessage(renderMessage(d, rulesByName));
1143
+ const rendered = renderMessage(d, rulesByName);
1144
+ const message = escapeGhMessage(
1145
+ d.suggestion ? `${rendered} \u2014 ${d.suggestion}` : rendered
1146
+ );
1104
1147
  const linePart = d.loc ? `,line=${d.loc.line}` : "";
1105
1148
  lines.push(`::${level} file=${file}${linePart},title=${title}::${message}`);
1106
1149
  }
@@ -1180,6 +1223,10 @@ var rule = {
1180
1223
  missingDate: 'Metadata does not contain a "{{field}}" field (checked frontmatter and v2 bold-list)',
1181
1224
  invalidDate: 'Date "{{date}}" is not a valid ISO 8601 calendar date (YYYY-MM-DD)'
1182
1225
  },
1226
+ suggestions: {
1227
+ missingDate: 'add a "{{field}}" field in YYYY-MM-DD format to the frontmatter (for MADR v2, a "* Date: ..." list item)',
1228
+ invalidDate: "use the YYYY-MM-DD calendar-date format, e.g. 2025-03-14"
1229
+ },
1183
1230
  defaultOptions: {
1184
1231
  field: "date"
1185
1232
  },
@@ -1272,6 +1319,11 @@ var rule2 = {
1272
1319
  messages: {
1273
1320
  invalidFilename: 'Filename "{{filename}}" does not match expected pattern "{{expected}}"'
1274
1321
  },
1322
+ suggestions: {
1323
+ // The message already states the expected pattern; the suggestion adds a
1324
+ // concrete, conforming example of the NNNN-kebab-case.md convention.
1325
+ invalidFilename: "rename the file to the NNNN-kebab-case.md convention, e.g. 0001-record-architecture-decisions.md"
1326
+ },
1275
1327
  defaultOptions: {
1276
1328
  pattern: "^[0-9]{4}-[a-z0-9-]+\\.md$"
1277
1329
  },
@@ -1351,6 +1403,9 @@ var rule3 = {
1351
1403
  messages: {
1352
1404
  brokenLink: 'Link to "{{url}}" resolves to "{{resolvedPath}}", which does not exist in the project'
1353
1405
  },
1406
+ suggestions: {
1407
+ brokenLink: "fix the link target or remove the link; relative paths resolve from the ADR file's own directory"
1408
+ },
1354
1409
  defaultOptions: {},
1355
1410
  schema: schema_default3
1356
1411
  },
@@ -1411,6 +1466,9 @@ var rule4 = {
1411
1466
  messages: {
1412
1467
  duplicateNumber: "ADR number {{number}} is used by multiple files: {{paths}}"
1413
1468
  },
1469
+ suggestions: {
1470
+ duplicateNumber: "renumber all but one of these files so each ADR has a unique NNNN- prefix"
1471
+ },
1414
1472
  defaultOptions: {},
1415
1473
  schema: schema_default4
1416
1474
  },
@@ -1470,6 +1528,9 @@ var rule5 = {
1470
1528
  messages: {
1471
1529
  numberingGap: "Numbering gap: missing {{missing}} between ADR-{{from}} and ADR-{{to}}"
1472
1530
  },
1531
+ // No `suggestions`: a gap can be intentional (a rejected or deleted ADR),
1532
+ // and closing it may mean either adding the missing ADR OR renumbering —
1533
+ // the right remediation is contextual, so we omit a suggestion. See #67.
1473
1534
  defaultOptions: {},
1474
1535
  schema: schema_default5
1475
1536
  },
@@ -1546,6 +1607,9 @@ var rule6 = {
1546
1607
  messages: {
1547
1608
  missingSection: 'Missing required section: "{{section}}"'
1548
1609
  },
1610
+ suggestions: {
1611
+ missingSection: 'add a "## {{section}}" heading to the document body'
1612
+ },
1549
1613
  defaultOptions: {
1550
1614
  sections: [
1551
1615
  "Context and Problem Statement",
@@ -1624,6 +1688,12 @@ var rule7 = {
1624
1688
  missingStatus: 'Metadata does not contain a "status" field (checked frontmatter and v2 bold-list)',
1625
1689
  invalidStatus: 'Status "{{status}}" is not one of: {{allowed}}'
1626
1690
  },
1691
+ // No suggestion for invalidStatus: the message already lists the allowed
1692
+ // values (the expected-vs-actual is self-contained), so a suggestion would
1693
+ // only restate it. See issue #67.
1694
+ suggestions: {
1695
+ missingStatus: 'add a "status" field to the frontmatter (for MADR v2, a "* Status: ..." list item) \u2014 allowed values: {{allowed}}'
1696
+ },
1627
1697
  defaultOptions: {
1628
1698
  values: ["proposed", "rejected", "accepted", "deprecated"],
1629
1699
  prefixValues: ["superseded by"],
@@ -1632,12 +1702,13 @@ var rule7 = {
1632
1702
  schema: schema_default7
1633
1703
  },
1634
1704
  create(context) {
1705
+ const { values, prefixValues, caseSensitive } = context.options;
1706
+ const allowed = [...values, ...prefixValues.map((p) => `${p} ...`)];
1635
1707
  const meta = context.metadata;
1636
1708
  if (!meta || typeof meta.status !== "string") {
1637
- context.report({ messageId: "missingStatus", data: {} });
1709
+ context.report({ messageId: "missingStatus", data: { allowed } });
1638
1710
  return;
1639
1711
  }
1640
- const { values, prefixValues, caseSensitive } = context.options;
1641
1712
  const status = meta.status;
1642
1713
  const compareStatus = caseSensitive ? status : status.toLowerCase();
1643
1714
  const compareValues = caseSensitive ? values : values.map((v) => v.toLowerCase());
@@ -1647,10 +1718,6 @@ var rule7 = {
1647
1718
  (p) => compareStatus.startsWith(p)
1648
1719
  );
1649
1720
  if (!exactMatch && !prefixMatch) {
1650
- const allowed = [
1651
- ...values,
1652
- ...prefixValues.map((p) => `${p} ...`)
1653
- ];
1654
1721
  const loc = context.metadataLoc?.status;
1655
1722
  context.report({
1656
1723
  messageId: "invalidStatus",
@@ -1700,7 +1767,15 @@ var rule8 = {
1700
1767
  },
1701
1768
  messages: {
1702
1769
  unknownReference: "Frontmatter `{{direction}}: {{ref}}` references an ADR that does not exist",
1703
- missingBackReference: "`{{source}}` declares `{{direction}}: {{ref}}`, but `{{ref}}` (this file) does not back-reference it via `{{expected}}`"
1770
+ // `declared` is the field the SOURCE file actually declares;
1771
+ // `direction` is the field THIS file (the diagnostic's path) must add.
1772
+ // They are opposites — rendering `direction` as the declared field
1773
+ // would mislabel the source's frontmatter in both directions.
1774
+ missingBackReference: "`{{source}}` declares `{{declared}}: {{ref}}`, but `{{ref}}` (this file) does not back-reference it via `{{direction}}: {{expected}}`"
1775
+ },
1776
+ suggestions: {
1777
+ unknownReference: 'correct "{{ref}}" to an existing ADR number, or remove "{{direction}}: {{ref}}" from the frontmatter',
1778
+ missingBackReference: 'add "{{direction}}: {{expected}}" to the frontmatter of this file'
1704
1779
  },
1705
1780
  defaultOptions: {},
1706
1781
  schema: schema_default8
@@ -1737,6 +1812,7 @@ var rule8 = {
1737
1812
  data: {
1738
1813
  ref,
1739
1814
  source: file.path,
1815
+ declared: FORWARD_FIELD,
1740
1816
  direction: BACKWARD_FIELD,
1741
1817
  expected: fileRef
1742
1818
  }
@@ -1761,6 +1837,7 @@ var rule8 = {
1761
1837
  data: {
1762
1838
  ref,
1763
1839
  source: file.path,
1840
+ declared: BACKWARD_FIELD,
1764
1841
  direction: FORWARD_FIELD,
1765
1842
  expected: fileRef
1766
1843
  }