madr-lint 0.4.0 → 0.4.2

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,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#127](https://github.com/knktkc/madr-lint/pull/127) [`ac0883e`](https://github.com/knktkc/madr-lint/commit/ac0883e1b9c015cf328ea2d4e806c564cc42489f) Thanks [@knktkc](https://github.com/knktkc)! - Bypass gray-matter's content-keyed parse cache ([#122](https://github.com/knktkc/madr-lint/issues/122)). gray-matter 4.0.3 memoizes a parse only when it is called with no options, in a plain object keyed by the *whole document* (`matter.cache[content]`). Both parser call sites now pass a shared, frozen options object, which turns that memoization off.
8
+
9
+ - **A document whose entire content is any `Object.prototype` member name (`constructor`, `toString`, `valueOf`, `__proto__`, …) no longer crashes.** Every one of those twelve lookups hit an inherited member, which gray-matter accepted as a cache hit and returned as a file with `content: undefined` — so `parseFile` threw `TypeError: Cannot convert undefined or null to object` and `frontmatterOffset` threw `TypeError: Cannot read properties of undefined (reading 'length')`. The throw escaped `lintFiles`, so `madr-lint docs/adr` on a directory holding such a file exited with a stack trace instead of reporting; it now reports the file's violations like any other. Only those exact bodies were affected — a trailing newline made the key an ordinary miss.
10
+ - **madr-lint no longer keeps every parsed document in memory.** The cache retained each document's full content for the process lifetime, as the key, the stripped body, and an `orig` Buffer. Measured over 1,000 distinct 8.4 KB documents: 16.9 MB retained before (8.9 MB heap + 8.0 MB buffers, about 2.1x the corpus), 0 MB now. Per-file lint throughput is unchanged (-1% to +3% across the rule benches, i.e. noise). `--fix` now performs a real second parse in `frontmatterOffset` where it previously got a memo hit — about +3 µs per file per pass, measured on this repo's own ADRs; correctness is unaffected.
11
+
12
+ - [#134](https://github.com/knktkc/madr-lint/pull/134) [`7512968`](https://github.com/knktkc/madr-lint/commit/7512968fc1420e232bd60cb977b5f7cb091be5aa) Thanks [@knktkc](https://github.com/knktkc)! - Read the fields below a bullet-marker change in a MADR v2 metadata block ([#115](https://github.com/knktkc/madr-lint/issues/115)). `* Status: accepted` followed by `- Date: 2026/07/06` is two CommonMark lists with no node between them, so the HTML-comment bridge added for [#73](https://github.com/knktkc/madr-lint/issues/73) could not reach the second one and every field below the marker change vanished from metadata.
13
+
14
+ - **A marker change no longer drops fields.** An adjacent list now joins the metadata block when *every* one of its items reads as a `Key: value` pair, so the `Date` above is parsed, keeps its own line number, is targetable by `madr-lint-disable-next-line`, and is repaired by `--fix` — instead of degrading a line-suppressible `invalidDate` into an unsuppressible file-level `missingDate`. The gate is shape, not marker: an ordered list (`1. Date: …`) after an unordered one merges on the same terms; a list with any non-`Key: value` item is prose and still ends the block, as do a paragraph, a code fence, a thematic break, a blockquote, a heading of any depth, and visible HTML.
15
+ - **`extractListMetadata` (public export) returns more keys for some documents.** The accepted cost of a shape-only gate is over-merging: a genuine prose list whose every bullet happens to read `Key: value` (`- Option A: do nothing`) is now merged into the block, so its keys appear in `listMetadata` / `metadata`. Merging can only add keys — duplicates still resolve first-wins, so an earlier field always shadows a later one and no document loses a key it had. The recognized-key gate (`status`, `date`, `deciders`, `decision-makers`, `consulted`, `informed`) is unchanged as a mechanism, but its outcome can change: a document whose only recognized key sits in the adjacent list now returns metadata where it previously returned `null` (which also lets `madr-lint init` cast a v2 version vote for it). Absorbed junk keys on their own never flip that gate.
16
+ - **Upgrading with a baseline**: a v2 field below a marker change was reported as a file-level `missingDate` / `missingStatus`; it is now reported as `invalidDate` / `invalidStatus` on the field's own line. If you baselined the old entry, re-run `madr-lint --update-baseline` — the stored entry no longer absorbs the new diagnostic, and the file goes red on an otherwise unchanged tree.
17
+
18
+ - [#135](https://github.com/knktkc/madr-lint/pull/135) [`eba7822`](https://github.com/knktkc/madr-lint/commit/eba78224ea23a018b6d1102c5de4af1a114903ca) Thanks [@knktkc](https://github.com/knktkc)! - Resolve `madr-lint-disable-next-line` from the comment's last line ([#116](https://github.com/knktkc/madr-lint/issues/116)). A directive spelled as a multi-line `<!--` … `-->` block parsed correctly, but the suppression layer counted its target from the html node's **start** line, so it landed on the comment's own interior instead of the content below `-->`. Directives now carry the node's end line and count from there.
19
+
20
+ - **A multi-line directive now reaches the line it points at.** Previously the diagnostic below `-->` was reported despite the directive — for the issue's document (a `disable-next-line madr/date-iso8601` split across three lines between two MADR v2 metadata items), `invalidDate` on the `* Date:` line was reported, not suppressed. A blank line between `-->` and the content is skipped, exactly as it is for a one-line directive.
21
+ - **Single-line directives are unchanged**: a one-line comment's end line *is* its start line, so nothing about the existing spelling moves. `disable` / `enable` ranges still open and close at the comment's **start** line, which is what preserves their pre-existing behavior. That choice is observable: an inline multi-line comment shares its start line with the content beside it — `[gone](./nope.md) <!--` … `-->` spans three lines while `madr/no-broken-links` reports the link on the first — so counting a range from the end line would stop such a `disable` from covering its own line.
22
+ - **The docs no longer ask you to keep a directive on one line.** The suppression guide (en/ja) and the `adopt-madr-lint` / `new-adr` skills previously called the multi-line spelling a miss; they now document it as working.
23
+
24
+ - [#123](https://github.com/knktkc/madr-lint/pull/123) [`657e3ef`](https://github.com/knktkc/madr-lint/commit/657e3ef7ab641fc8055796d22c8303a2b931b4b0) Thanks [@knktkc](https://github.com/knktkc)! - Make filename- and document-derived record keys prototype-safe ([#56](https://github.com/knktkc/madr-lint/issues/56)). Both the cache manifest and the MADR v2 metadata parser accumulated untrusted keys on plain objects, where a key that names an `Object.prototype` member is read and written *through* the prototype instead of stored as data.
25
+
26
+ - **Cache manifest keys are now prototype-safe.** `manifest.files` is a null-prototype map on both the cold path and rehydration, so a file whose relative path is `__proto__` caches, serializes, and cache-hits like any other. Previously the write went through the inherited `__proto__` setter — the entry never reached `manifest.json` and every run was a cold miss. No user-facing bug is fixed: the key is unreachable from the CLI (`findAdrFiles` drops every non-`.md` target, and a `__proto__.md` file keys as `__proto__.md`) and from the published API (`lintFiles` is not exported from `madr-lint`). This is hardening for the internal `lintFiles` seam and any future entry point that passes explicit paths. It was never a global pollution vector either: plain assignment swapped that one record's prototype rather than mutating `Object.prototype`. `saveManifest` output is byte-identical for ordinary keys, so existing manifests stay valid — no `CACHE_SCHEMA_VERSION` bump.
27
+ - **A v2 body-list key named `constructor` is now read.** The leading metadata block de-duplicates with `Object.hasOwn` instead of `in`, so `* Constructor: x` lands in `metadata.constructor` (with its body position and value offset) rather than being silently skipped because `'constructor' in {}` is true through inheritance. `constructor` is the only reachable case: key normalization lowercases, so `toString` arrives as `tostring` (not a member), and `__proto__` never matches the key pattern, which requires a leading letter. No rule reads these fields — the fix is for library consumers of `extractListMetadata` and `ParsedFile`, which previously got `Object` back for such a key. The returned records keep an ordinary prototype, so `hasOwnProperty` still works on them.
28
+ - **A frontmatter key named `__proto__` survives the v2 merge.** When a file carries both YAML frontmatter and a v2 body list, the merge step that drops explicit `null`/`undefined` now builds its record on a null prototype, so `__proto__: x` reaches `metadata.__proto__` as data instead of being swallowed by the inherited setter. The merged `metadata` is still a plain object.
29
+
30
+ ## 0.4.1
31
+
32
+ ### Patch Changes
33
+
34
+ - [#114](https://github.com/knktkc/madr-lint/pull/114) [`e7d3a30`](https://github.com/knktkc/madr-lint/commit/e7d3a30fc84e0cbc4f2f76bcdc50a7681a38b8fc) Thanks [@knktkc](https://github.com/knktkc)! - Fix v2 metadata being silently dropped after an HTML comment ([#73](https://github.com/knktkc/madr-lint/issues/73)). A suppression comment placed _between_ MADR v2 metadata-list items splits the Markdown list in two (CommonMark), and the v2 bridge previously read only the first segment — so a field below the comment vanished from `context.metadata`, turning a line-suppressible `invalidDate` into an unsuppressible file-level `missingDate`. Suppressing was strictly worse than not suppressing.
35
+
36
+ - **The v2 metadata block is now the leading run of lists joined across HTML comments.** A comment renders as nothing, so it can no longer end the block. A key below a comment now behaves exactly like one above it: it keeps its own body line, so `<!-- madr-lint-disable-next-line madr/date-iso8601 -->` between items reaches the field below it, and it carries the same value offsets, so — with no directive above it — `--fix` rewrites that field in place (`madr/status-enum`, `madr/date-iso8601`). Not both: `--fix` never rewrites a suppressed problem. Blank lines around the comment do not matter — `disable-next-line` already targets the next non-blank line.
37
+ - **Only comments bridge.** Visible HTML (`<div>`, `<details>`, or a node like `<!-- a --> visible` that is not purely a comment), a paragraph, a code fence, a thematic break, a blockquote, and a heading of any depth all still end the metadata block — it must lead. Two lists with no comment between them (a bullet-marker change, `* Status:` then `- Date:`) are still two lists, and the second is still not metadata.
38
+ - **Public API**: `extractListMetadata` now reads list segments separated only by HTML comments. The signature is unchanged and the change is additive — merging can only add keys (duplicate keys still resolve first-wins), so it never returns fewer than before. Two consequences worth knowing: a document whose recognized MADR key sits only in a later segment now returns metadata where it previously returned `null` (which also lets `madr-lint init` cast a v2 version vote for it), and a prose list placed after the metadata block with only a comment between them, before the next heading of any depth, can contribute an extra key.
39
+ - **Upgrading with a baseline**: a v2 field hidden by this bug was reported as a file-level `missingDate` / `missingStatus`; it is now reported as `invalidDate` / `invalidStatus` on the field's own line. If you baselined the old entry, re-run `madr-lint --update-baseline` — the stored entry no longer absorbs the new diagnostic, and the file goes red on an otherwise unchanged tree.
40
+ - **Not fixed here**: a directive written as a multi-line `<!--` … `-->` block still resolves to its own second line, so keep `disable-next-line` on a single line. `<div>`-style HTML on the line directly above a metadata item swallows that line into the HTML block, so the item never reaches the AST at all.
41
+
3
42
  ## 0.4.0
4
43
 
5
44
  ### Minor Changes
package/README.md CHANGED
@@ -31,12 +31,12 @@ A fast, configurable linter for [MADR](https://adr.github.io/madr/) (Markdown Ar
31
31
 
32
32
  [MADR](https://github.com/adr/madr) ships no official linter, and the general-purpose tools each cover only part of what an ADR collection needs:
33
33
 
34
- | Tool | Markdown style | Inter-doc links | ADR numbering | Status enum | Date format | Supersedes graph | v2 bold-list |
35
- |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
36
- | [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) | ✅ | — | — | — | — | — | n/a |
37
- | [lychee](https://github.com/lycheeverse/lychee) | — | ✅ | — | — | — | — | n/a |
38
- | [adrs (rust)](https://crates.io/crates/adrs) | — | — | ✅&nbsp;(init) | — | — | ~ | — |
39
- | **madr-lint** | —¹ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
34
+ | Tool | Markdown style | Inter-doc links | ADR numbering | Status enum | Date format | Supersedes graph | v2 bold-list | Autofix |
35
+ |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
36
+ | [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) | ✅ | — | — | — | — | — | n/a | ✅ |
37
+ | [lychee](https://github.com/lycheeverse/lychee) | — | ✅ | — | — | — | — | n/a | — |
38
+ | [adrs (rust)](https://crates.io/crates/adrs) | — | — | ✅&nbsp;(init) | — | — | ~ | — | — |
39
+ | **madr-lint** | —¹ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ~&nbsp;(3/8&nbsp;rules) |
40
40
 
41
41
  ¹ Deliberately *not* Markdown style — pair it with `markdownlint-cli2` if you want both. `madr-lint` owns the ADR-specific semantics.
42
42
 
@@ -44,6 +44,7 @@ A fast, configurable linter for [MADR](https://adr.github.io/madr/) (Markdown Ar
44
44
  - **ESLint-style rules** — named `madr/*` rules with `error` / `warn` / `off` and per-rule options validated by a JSON Schema.
45
45
  - **Per-file & cross-file** — fast single-pass checks plus project rules for unique numbering, the supersedes graph, and link rot.
46
46
  - **CLI, library & CI** — `text` / `json` / `sarif` / `github` reporters, a programmatic API, and a drop-in GitHub Actions step.
47
+ - **Autofix** — `--fix` / `--fix-dry-run` mechanically repair the violations that have a safe, unambiguous correction (3 of 8 rules today); everything else still reports with a `suggestion`.
47
48
  - **Gradual adoption** — [inline suppression comments](https://knktkc.github.io/madr-lint/guides/suppressing-rules/) for one-off exceptions and a [baseline file](https://knktkc.github.io/madr-lint/guides/adopting-existing-repo/) to snapshot legacy debt so only new violations fail the build.
48
49
 
49
50
  ## Install
@@ -57,9 +58,15 @@ Node.js 22+. ESM-only. Ships TypeScript types.
57
58
  ## Quick start
58
59
 
59
60
  ```bash
61
+ # Scaffold a config — detects your ADR directory and MADR version
62
+ npx madr-lint init
63
+
60
64
  # Lint the configured adrDir (default: docs/adr)
61
65
  npx madr-lint
62
66
 
67
+ # Auto-repair what's mechanically fixable, then re-lint
68
+ npx madr-lint --fix
69
+
63
70
  # Explicit paths (files or directories; directories are searched recursively)
64
71
  npx madr-lint docs/adr libs/x/adr
65
72
 
@@ -92,17 +99,17 @@ Rule values are a severity (`'error' | 'warn' | 'off'`) or a `[severity, options
92
99
 
93
100
  ## Rules
94
101
 
95
- 8 rules — 7 enabled by `recommended`, 1 opt-in. Each page documents its options, examples, and MADR-version compatibility.
102
+ 8 rules — 7 enabled by `recommended`, 1 opt-in. 🔧 marks the 3 rules `--fix` can repair. Each page documents its options, examples, and MADR-version compatibility.
96
103
 
97
104
  | Rule | Type | Default | Checks |
98
105
  |---|---|:---:|---|
99
106
  | [`madr/required-sections`](https://knktkc.github.io/madr-lint/rules/required-sections/) | per-file | `error` | Required heading sections are present |
100
- | [`madr/status-enum`](https://knktkc.github.io/madr-lint/rules/status-enum/) | per-file | `error` | `status` is one of the allowed values |
101
- | [`madr/date-iso8601`](https://knktkc.github.io/madr-lint/rules/date-iso8601/) | per-file | `error` | `date` is a valid ISO-8601 calendar date |
107
+ | [`madr/status-enum`](https://knktkc.github.io/madr-lint/rules/status-enum/) 🔧 | per-file | `error` | `status` is one of the allowed values |
108
+ | [`madr/date-iso8601`](https://knktkc.github.io/madr-lint/rules/date-iso8601/) 🔧 | per-file | `error` | `date` is a valid ISO-8601 calendar date |
102
109
  | [`madr/filename-format`](https://knktkc.github.io/madr-lint/rules/filename-format/) | per-file | `error` | Filename matches the ADR convention |
103
110
  | [`madr/no-broken-links`](https://knktkc.github.io/madr-lint/rules/no-broken-links/) | project | `error` | Relative links resolve to existing files |
104
111
  | [`madr/no-duplicate-numbering`](https://knktkc.github.io/madr-lint/rules/no-duplicate-numbering/) | project | `error` | ADR numbers are unique |
105
- | [`madr/supersedes-bidirectional`](https://knktkc.github.io/madr-lint/rules/supersedes-bidirectional/) | project | `error` | `supersedes` / `superseded-by` agree |
112
+ | [`madr/supersedes-bidirectional`](https://knktkc.github.io/madr-lint/rules/supersedes-bidirectional/) 🔧 | project | `error` | `supersedes` / `superseded-by` agree |
106
113
  | [`madr/no-numbering-gap`](https://knktkc.github.io/madr-lint/rules/no-numbering-gap/) | project | `off` | ADR numbers are contiguous (opt-in) |
107
114
 
108
115
  ## Use in CI
@@ -125,7 +132,7 @@ jobs:
125
132
  ```
126
133
 
127
134
  > The floating `v0` tag tracks the latest v0.x release; for stricter
128
- > reproducibility, pin an exact tag like `@v0.3.0`.
135
+ > reproducibility, pin an exact tag like `@v0.4.0`.
129
136
 
130
137
  Or run it via `npx` in any CI provider:
131
138
 
package/dist/cli.js CHANGED
@@ -130,11 +130,19 @@ import { join as join2, resolve as resolve2 } from "path";
130
130
  import { fromMarkdown } from "mdast-util-from-markdown";
131
131
  import { toString } from "mdast-util-to-string";
132
132
  import grayMatter from "gray-matter";
133
+
134
+ // src/core/null-proto-map.ts
135
+ function nullProtoMap() {
136
+ return /* @__PURE__ */ Object.create(null);
137
+ }
138
+
139
+ // src/core/parser.ts
140
+ var GRAY_MATTER_OPTIONS = Object.freeze({});
133
141
  function frontmatterOffset(content) {
134
- return content.length - grayMatter(content).content.length;
142
+ return content.length - grayMatter(content, GRAY_MATTER_OPTIONS).content.length;
135
143
  }
136
144
  function parseFile(content) {
137
- const matter = grayMatter(content);
145
+ const matter = grayMatter(content, GRAY_MATTER_OPTIONS);
138
146
  const data = matter.data;
139
147
  const frontmatter = Object.keys(data).length > 0 ? data : null;
140
148
  const ast = fromMarkdown(matter.content);
@@ -144,7 +152,7 @@ function parseFile(content) {
144
152
  let metadataLoc = listResult && Object.keys(listResult.loc).length > 0 ? { ...listResult.loc } : null;
145
153
  let metadataValueLoc = listResult && Object.keys(listResult.valueOffsets).length > 0 ? { ...listResult.valueOffsets } : null;
146
154
  if (frontmatter && listMetadata) {
147
- const frontmatterDefined = {};
155
+ const frontmatterDefined = nullProtoMap();
148
156
  for (const [k, v] of Object.entries(frontmatter)) {
149
157
  if (v !== null && v !== void 0) frontmatterDefined[k] = v;
150
158
  }
@@ -180,39 +188,65 @@ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
180
188
  "consulted",
181
189
  "informed"
182
190
  ]);
191
+ function isHtmlComment(value) {
192
+ let rest = value.trim();
193
+ if (rest.length === 0) return false;
194
+ while (rest.length > 0) {
195
+ if (!rest.startsWith("<!--")) return false;
196
+ const end = rest.indexOf("-->", 2);
197
+ if (end === -1) return false;
198
+ rest = rest.slice(end + 3).trim();
199
+ }
200
+ return true;
201
+ }
183
202
  function extractListMetadataWithLoc(ast, body) {
184
- let firstList = null;
203
+ const segments = [];
204
+ let bridged = false;
185
205
  for (const child of ast.children) {
186
- if (child.type === "heading") {
187
- if (child.depth >= 2) break;
206
+ if (segments.length === 0) {
207
+ if (child.type === "heading") {
208
+ if (child.depth >= 2) break;
209
+ continue;
210
+ }
211
+ if (child.type === "html") continue;
212
+ if (child.type === "list") {
213
+ segments.push(child);
214
+ continue;
215
+ }
216
+ break;
217
+ }
218
+ if (child.type === "html" && isHtmlComment(child.value)) {
219
+ bridged = true;
188
220
  continue;
189
221
  }
190
- if (child.type === "html") continue;
191
- if (child.type === "list") {
192
- firstList = child;
193
- break;
222
+ if (child.type === "list" && (bridged || child.children.length > 0 && child.children.every((item) => extractListItemKV(item)))) {
223
+ segments.push(child);
224
+ bridged = false;
225
+ continue;
194
226
  }
195
227
  break;
196
228
  }
197
- if (!firstList) return null;
198
- const values = {};
199
- const loc = {};
200
- const valueOffsets = {};
201
- for (const item of firstList.children) {
202
- const pair = extractListItemKV(item);
203
- if (pair && !(pair.key in values)) {
204
- values[pair.key] = pair.value;
205
- const start = item.position?.start;
206
- if (start) loc[pair.key] = { line: start.line, column: start.column };
207
- if (body !== void 0 && pair.valueOffset && body.slice(pair.valueOffset.start, pair.valueOffset.end) === pair.value) {
208
- valueOffsets[pair.key] = pair.valueOffset;
229
+ if (segments.length === 0) return null;
230
+ const values = nullProtoMap();
231
+ const loc = nullProtoMap();
232
+ const valueOffsets = nullProtoMap();
233
+ for (const list of segments) {
234
+ for (const item of list.children) {
235
+ const pair = extractListItemKV(item);
236
+ if (pair && !Object.hasOwn(values, pair.key)) {
237
+ values[pair.key] = pair.value;
238
+ const start = item.position?.start;
239
+ if (start) loc[pair.key] = { line: start.line, column: start.column };
240
+ if (body !== void 0 && pair.valueOffset && body.slice(pair.valueOffset.start, pair.valueOffset.end) === pair.value) {
241
+ valueOffsets[pair.key] = pair.valueOffset;
242
+ }
209
243
  }
210
244
  }
211
245
  }
212
246
  const hasRecognizedKey = Object.keys(values).some(
213
247
  (k) => RECOGNIZED_METADATA_KEYS.has(k)
214
248
  );
215
- return hasRecognizedKey ? { values, loc, valueOffsets } : null;
249
+ return hasRecognizedKey ? { values: { ...values }, loc, valueOffsets } : null;
216
250
  }
217
251
  var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
218
252
  var SEPARATOR_PATTERN = /^\s*:\s*/;
@@ -419,9 +453,6 @@ var BASELINE_DISPLAY_PATH = ".madr-lint/baseline.json";
419
453
  function baselinePath(cwd) {
420
454
  return join3(cwd, ".madr-lint", "baseline.json");
421
455
  }
422
- function nullProtoMap() {
423
- return /* @__PURE__ */ Object.create(null);
424
- }
425
456
  function buildBaseline(diagnostics) {
426
457
  const entries = nullProtoMap();
427
458
  for (const d of diagnostics) {
@@ -579,7 +610,13 @@ function loadManifest(path) {
579
610
  ) {
580
611
  return null;
581
612
  }
582
- return data;
613
+ const files = nullProtoMap();
614
+ for (const [key, entry] of Object.entries(
615
+ data.files
616
+ )) {
617
+ files[key] = entry;
618
+ }
619
+ return { ...data, files };
583
620
  } catch {
584
621
  return null;
585
622
  }
@@ -786,7 +823,7 @@ var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
786
823
  "disable-next-line"
787
824
  ]);
788
825
  var DIRECTIVE_PREFIX = "madr-lint-";
789
- function parseDirective(value, line) {
826
+ function parseDirective(value, line, endLine) {
790
827
  const trimmed = value.trim();
791
828
  if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
792
829
  const inner = trimmed.slice(4, -3).trim();
@@ -797,7 +834,12 @@ function parseDirective(value, line) {
797
834
  const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
798
835
  if (!DIRECTIVE_KINDS.has(keyword)) return null;
799
836
  const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
800
- return { kind: keyword, rules: parseRuleList(rest), line };
837
+ return {
838
+ kind: keyword,
839
+ rules: parseRuleList(rest),
840
+ line,
841
+ endLine
842
+ };
801
843
  }
802
844
  function parseRuleList(rest) {
803
845
  const trimmed = rest.trim();
@@ -808,8 +850,9 @@ function parseRuleList(rest) {
808
850
  function walkHtml(node, out) {
809
851
  if (node.type === "html") {
810
852
  const line = node.position?.start.line;
811
- if (typeof line === "number") {
812
- const directive = parseDirective(node.value, line);
853
+ const endLine = node.position?.end.line;
854
+ if (typeof line === "number" && typeof endLine === "number") {
855
+ const directive = parseDirective(node.value, line, endLine);
813
856
  if (directive) out.push(directive);
814
857
  }
815
858
  }
@@ -848,7 +891,7 @@ function collectDirectives(ast, body) {
848
891
  break;
849
892
  }
850
893
  case "disable-next-line": {
851
- const target = nextNonBlankLine(directive.line);
894
+ const target = nextNonBlankLine(directive.endLine);
852
895
  if (target === null) break;
853
896
  let entry = index.nextLine.get(target);
854
897
  if (!entry) {
@@ -1160,7 +1203,7 @@ function lintFiles(opts) {
1160
1203
  schemaVersion: CACHE_SCHEMA_VERSION,
1161
1204
  version: opts.cache.pkgVersion,
1162
1205
  configHash: opts.cache.configHash,
1163
- files: {}
1206
+ files: nullProtoMap()
1164
1207
  };
1165
1208
  }
1166
1209
  }