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/dist/index.d.ts CHANGED
@@ -429,9 +429,10 @@ interface ParsedFile {
429
429
  /** Parsed YAML frontmatter (v3/v4), or null if absent. */
430
430
  frontmatter: Record<string, unknown> | null;
431
431
  /**
432
- * v2 body-list metadata extracted from the leading list, or null if absent.
433
- * Covers both the bold-key (`- **Status**:`) and the canonical MADR v2.1.2
434
- * plain-key (`* Status:`) shapes. See ADR-0006.
432
+ * v2 body-list metadata extracted from the leading metadata block, or null if
433
+ * absent. The block is the leading RUN of lists, joined across HTML comments
434
+ * (which render as nothing). Covers both the bold-key (`- **Status**:`) and
435
+ * the canonical MADR v2.1.2 plain-key (`* Status:`) shapes. See ADR-0006.
435
436
  */
436
437
  listMetadata: Record<string, unknown> | null;
437
438
  /**
@@ -477,25 +478,33 @@ declare function frontmatterOffset(content: string): number;
477
478
  * Parse a Markdown file: extract YAML frontmatter via gray-matter, then
478
479
  * feed the body into mdast-util-from-markdown directly (per ADR-0002,
479
480
  * we skip the unified+remark pipeline overhead). Additionally, extract
480
- * v2-style list metadata from the body's first list — both bold-key and
481
- * canonical plain-key shapes (see ADR-0006).
481
+ * v2-style list metadata from the body's leading metadata block — both
482
+ * bold-key and canonical plain-key shapes (see ADR-0006).
482
483
  */
483
484
  declare function parseFile(content: string): ParsedFile;
484
485
  /**
485
- * Extract v2-style list metadata from an mdast Root. The metadata block is
486
- * the leading list — the first block after the H1 title (only headings may
487
- * precede it; an intervening paragraph means the list is body content, not
488
- * metadata). Each `listItem` is read as one `Key: value` pair in either of
489
- * two shapes:
486
+ * Extract v2-style list metadata from an mdast Root. The metadata block leads
487
+ * the body: only H1 headings and HTML nodes may precede it — an H2-or-deeper
488
+ * heading, or any other block before the first list, means there is no
489
+ * metadata block. The block itself is the leading RUN of lists, joined across
490
+ * HTML comments — a comment ends the CommonMark list yet renders as nothing,
491
+ * so a field below it is still metadata (#73). A second list with nothing
492
+ * between it and the first (a bullet-marker change, or an ordered list) joins
493
+ * the run when EVERY one of its items reads as a `Key: value` pair (#115); a
494
+ * list carrying any other item is prose and ends the block. Once the run has
495
+ * started, visible HTML, a paragraph, a code fence, a thematic break, a
496
+ * blockquote, and a heading of ANY depth all end the block. Each `listItem` is
497
+ * read as one `Key: value` pair in either of two shapes:
490
498
  *
491
499
  * - Bold key: `- **Status**: accepted` (some MADR v2 authors)
492
500
  * - Plain key: `* Status: accepted` (official MADR v2.1.2 template)
493
501
  *
494
502
  * Key normalization: trim → lowercase → spaces become hyphens
495
503
  * (matches v4 frontmatter convention, e.g. "Decision Makers" →
496
- * "decision-makers"). On duplicate keys, first occurrence wins.
504
+ * "decision-makers"). On duplicate keys, first occurrence wins — across
505
+ * segments too, so an earlier segment always shadows a later one.
497
506
  *
498
- * Returns null unless the list carries a recognized MADR key, so ordinary
507
+ * Returns null unless the block carries a recognized MADR key, so ordinary
499
508
  * leading prose lists are not mistaken for metadata. (A leading list keyed by
500
509
  * a recognized field is still treated as metadata even if its value reads
501
510
  * prose-like, e.g. `Status: under discussion` — that position + key is
package/dist/index.js CHANGED
@@ -8,11 +8,19 @@ var __export = (target, all) => {
8
8
  import { fromMarkdown } from "mdast-util-from-markdown";
9
9
  import { toString } from "mdast-util-to-string";
10
10
  import grayMatter from "gray-matter";
11
+
12
+ // src/core/null-proto-map.ts
13
+ function nullProtoMap() {
14
+ return /* @__PURE__ */ Object.create(null);
15
+ }
16
+
17
+ // src/core/parser.ts
18
+ var GRAY_MATTER_OPTIONS = Object.freeze({});
11
19
  function frontmatterOffset(content) {
12
- return content.length - grayMatter(content).content.length;
20
+ return content.length - grayMatter(content, GRAY_MATTER_OPTIONS).content.length;
13
21
  }
14
22
  function parseFile(content) {
15
- const matter = grayMatter(content);
23
+ const matter = grayMatter(content, GRAY_MATTER_OPTIONS);
16
24
  const data = matter.data;
17
25
  const frontmatter = Object.keys(data).length > 0 ? data : null;
18
26
  const ast = fromMarkdown(matter.content);
@@ -22,7 +30,7 @@ function parseFile(content) {
22
30
  let metadataLoc = listResult && Object.keys(listResult.loc).length > 0 ? { ...listResult.loc } : null;
23
31
  let metadataValueLoc = listResult && Object.keys(listResult.valueOffsets).length > 0 ? { ...listResult.valueOffsets } : null;
24
32
  if (frontmatter && listMetadata) {
25
- const frontmatterDefined = {};
33
+ const frontmatterDefined = nullProtoMap();
26
34
  for (const [k, v] of Object.entries(frontmatter)) {
27
35
  if (v !== null && v !== void 0) frontmatterDefined[k] = v;
28
36
  }
@@ -61,39 +69,65 @@ var RECOGNIZED_METADATA_KEYS = /* @__PURE__ */ new Set([
61
69
  function extractListMetadata(ast) {
62
70
  return extractListMetadataWithLoc(ast)?.values ?? null;
63
71
  }
72
+ function isHtmlComment(value) {
73
+ let rest = value.trim();
74
+ if (rest.length === 0) return false;
75
+ while (rest.length > 0) {
76
+ if (!rest.startsWith("<!--")) return false;
77
+ const end = rest.indexOf("-->", 2);
78
+ if (end === -1) return false;
79
+ rest = rest.slice(end + 3).trim();
80
+ }
81
+ return true;
82
+ }
64
83
  function extractListMetadataWithLoc(ast, body) {
65
- let firstList = null;
84
+ const segments = [];
85
+ let bridged = false;
66
86
  for (const child of ast.children) {
67
- if (child.type === "heading") {
68
- if (child.depth >= 2) break;
87
+ if (segments.length === 0) {
88
+ if (child.type === "heading") {
89
+ if (child.depth >= 2) break;
90
+ continue;
91
+ }
92
+ if (child.type === "html") continue;
93
+ if (child.type === "list") {
94
+ segments.push(child);
95
+ continue;
96
+ }
97
+ break;
98
+ }
99
+ if (child.type === "html" && isHtmlComment(child.value)) {
100
+ bridged = true;
69
101
  continue;
70
102
  }
71
- if (child.type === "html") continue;
72
- if (child.type === "list") {
73
- firstList = child;
74
- break;
103
+ if (child.type === "list" && (bridged || child.children.length > 0 && child.children.every((item) => extractListItemKV(item)))) {
104
+ segments.push(child);
105
+ bridged = false;
106
+ continue;
75
107
  }
76
108
  break;
77
109
  }
78
- if (!firstList) return null;
79
- const values = {};
80
- const loc = {};
81
- const valueOffsets = {};
82
- for (const item of firstList.children) {
83
- const pair = extractListItemKV(item);
84
- if (pair && !(pair.key in values)) {
85
- values[pair.key] = pair.value;
86
- const start = item.position?.start;
87
- if (start) loc[pair.key] = { line: start.line, column: start.column };
88
- if (body !== void 0 && pair.valueOffset && body.slice(pair.valueOffset.start, pair.valueOffset.end) === pair.value) {
89
- valueOffsets[pair.key] = pair.valueOffset;
110
+ if (segments.length === 0) return null;
111
+ const values = nullProtoMap();
112
+ const loc = nullProtoMap();
113
+ const valueOffsets = nullProtoMap();
114
+ for (const list of segments) {
115
+ for (const item of list.children) {
116
+ const pair = extractListItemKV(item);
117
+ if (pair && !Object.hasOwn(values, pair.key)) {
118
+ values[pair.key] = pair.value;
119
+ const start = item.position?.start;
120
+ if (start) loc[pair.key] = { line: start.line, column: start.column };
121
+ if (body !== void 0 && pair.valueOffset && body.slice(pair.valueOffset.start, pair.valueOffset.end) === pair.value) {
122
+ valueOffsets[pair.key] = pair.valueOffset;
123
+ }
90
124
  }
91
125
  }
92
126
  }
93
127
  const hasRecognizedKey = Object.keys(values).some(
94
128
  (k) => RECOGNIZED_METADATA_KEYS.has(k)
95
129
  );
96
- return hasRecognizedKey ? { values, loc, valueOffsets } : null;
130
+ return hasRecognizedKey ? { values: { ...values }, loc, valueOffsets } : null;
97
131
  }
98
132
  var KEY_PATTERN = /^[A-Za-z][A-Za-z0-9 \-_]*$/;
99
133
  var SEPARATOR_PATTERN = /^\s*:\s*/;
@@ -354,7 +388,7 @@ var DIRECTIVE_KINDS = /* @__PURE__ */ new Set([
354
388
  "disable-next-line"
355
389
  ]);
356
390
  var DIRECTIVE_PREFIX = "madr-lint-";
357
- function parseDirective(value, line) {
391
+ function parseDirective(value, line, endLine) {
358
392
  const trimmed = value.trim();
359
393
  if (!trimmed.startsWith("<!--") || !trimmed.endsWith("-->")) return null;
360
394
  const inner = trimmed.slice(4, -3).trim();
@@ -365,7 +399,12 @@ function parseDirective(value, line) {
365
399
  const keyword = wsIndex === -1 ? afterPrefix : afterPrefix.slice(0, wsIndex);
366
400
  if (!DIRECTIVE_KINDS.has(keyword)) return null;
367
401
  const rest = wsIndex === -1 ? "" : afterPrefix.slice(wsIndex);
368
- return { kind: keyword, rules: parseRuleList(rest), line };
402
+ return {
403
+ kind: keyword,
404
+ rules: parseRuleList(rest),
405
+ line,
406
+ endLine
407
+ };
369
408
  }
370
409
  function parseRuleList(rest) {
371
410
  const trimmed = rest.trim();
@@ -376,8 +415,9 @@ function parseRuleList(rest) {
376
415
  function walkHtml(node, out) {
377
416
  if (node.type === "html") {
378
417
  const line = node.position?.start.line;
379
- if (typeof line === "number") {
380
- const directive = parseDirective(node.value, line);
418
+ const endLine = node.position?.end.line;
419
+ if (typeof line === "number" && typeof endLine === "number") {
420
+ const directive = parseDirective(node.value, line, endLine);
381
421
  if (directive) out.push(directive);
382
422
  }
383
423
  }
@@ -416,7 +456,7 @@ function collectDirectives(ast, body) {
416
456
  break;
417
457
  }
418
458
  case "disable-next-line": {
419
- const target = nextNonBlankLine(directive.line);
459
+ const target = nextNonBlankLine(directive.endLine);
420
460
  if (target === null) break;
421
461
  let entry = index.nextLine.get(target);
422
462
  if (!entry) {
@@ -1542,9 +1582,6 @@ var BASELINE_VERSION = 1;
1542
1582
  function baselinePath(cwd) {
1543
1583
  return join(cwd, ".madr-lint", "baseline.json");
1544
1584
  }
1545
- function nullProtoMap() {
1546
- return /* @__PURE__ */ Object.create(null);
1547
- }
1548
1585
  function buildBaseline(diagnostics) {
1549
1586
  const entries = nullProtoMap();
1550
1587
  for (const d of diagnostics) {