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/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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|