File size: 4,700 Bytes
a6b96c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
'use strict';

/**
 * Markdown serializer + parser for the changelog IR. The two are inverses
 * over the well-formed subset; tests assert via round-trip (parse(serialize(ir)))
 * rather than by inspecting serialized text — see CONTRIBUTING.md
 * "Prohibited: Raw Text Matching on Test Outputs".
 *
 * Serialized form (Keep a Changelog):
 *
 *   ## [1.42.0] - 2026-05-01
 *
 *   ### Fixed
 *
 *   - body of the bullet (#NNNN)
 *
 *   <priorChangelog appended verbatim>
 */

function serializeChangelog(ir) {
  const lines = [];
  const { version, date } = ir.releaseHeader;
  lines.push(`## [${version}] - ${date}`);
  lines.push('');
  for (const section of ir.sections) {
    lines.push(`### ${section.type}`);
    lines.push('');
    for (const b of section.bullets) {
      lines.push(`- ${b.body} (#${b.pr})`);
    }
    lines.push('');
  }
  let out = lines.join('\n');
  if (ir.priorChangelog) {
    out += '\n' + ir.priorChangelog;
  }
  return out;
}

/**
 * Inverse parser: extracts the structured releases from a CHANGELOG.md
 * text. Returns { releases: [{ version, date, sections: [{ type, bullets:
 * [{ pr, body }] }] }] }. Tolerates the actual repo's CHANGELOG dialect.
 *
 * Multi-line bullets are supported: a bullet opens on a line starting with
 * `- ` and continues on lines starting with two or more spaces (or a tab).
 * The `(#NNNN)` PR trailer may appear on any continuation line.  Single-line
 * bullets (entire entry on one `- ` line) are still handled as before.
 *
 * Fix for #3496: the previous implementation only matched single-line bullets
 * whose `(#NNNN)` suffix was on the same line as the opening `- `.  Long
 * bullets — which wrap onto indented continuation lines — returned 0 entries
 * for their section even when the markdown was well-formed.
 */
function parseChangelog(text) {
  const releases = [];
  const lines = text.split(/\r?\n/);
  let cur = null;
  let curSection = null;
  // Accumulates lines belonging to the current in-flight bullet (may span
  // multiple lines).  Flushed when a new block-level element is encountered.
  let bulletLines = null;

  function flushBullet() {
    if (bulletLines === null || !curSection) return;
    const joined = bulletLines.join(' ').trim();
    // Locate the (# pr) trailer anywhere in the joined text.  The trailer is
    // expected to be at the very end, but we tolerate trailing whitespace.
    const trailMatch = joined.match(/^(.*?)\s*\(#(\d+)\)\s*$/);
    if (trailMatch) {
      curSection.bullets.push({ body: trailMatch[1].trim(), pr: Number(trailMatch[2]) });
    } else {
      // Bullet has no PR trailer — preserve it with pr: null so callers
      // (e.g. cmdExtract) do not silently drop authored content.
      curSection.bullets.push({ body: joined, pr: null });
    }
    bulletLines = null;
  }

  for (const line of lines) {
    // F3: match linked headers: ## [1.42.1](url) - 2026-05-15
    //     The (?:\([^)]*\))? group skips an optional (url) after the closing ]
    //     before looking for the optional date suffix.
    // F6: strip a leading `v` from the captured version so `## [v1.0.0]`
    //     parses as version "1.0.0" instead of "v1.0.0".
    const releaseMatch = line.match(/^##\s+\[([^\]]+)\](?:\([^)]*\))?\s*(?:-\s*(\S+))?/);
    if (releaseMatch) {
      flushBullet();
      const rawVersion = releaseMatch[1];
      const version = rawVersion.replace(/^v/, '');
      cur = { version, date: releaseMatch[2] || null, sections: [] };
      curSection = null;
      releases.push(cur);
      continue;
    }
    if (!cur) continue;
    const sectionMatch = line.match(/^###\s+(.+?)\s*$/);
    if (sectionMatch) {
      flushBullet();
      curSection = { type: sectionMatch[1], bullets: [] };
      cur.sections.push(curSection);
      continue;
    }
    if (!curSection) continue;

    // New bullet: line begins with `- ` (after optional leading spaces that
    // would indicate a nested list — we only handle top-level bullets here).
    if (/^-\s+/.test(line)) {
      flushBullet();
      bulletLines = [line.replace(/^-\s+/, '')];
      continue;
    }

    // Continuation line: any indentation (F7: relaxed from /^[ \t]{2}/ so that
    // 1-space-indented continuations also fold) BUT NOT a nested bullet marker
    // (F4: `  - nested item` terminates the current bullet rather than folding).
    if (bulletLines !== null && /^\s+/.test(line) && !/^\s+-\s/.test(line)) {
      bulletLines.push(line.trim());
      continue;
    }

    // Any other line (blank, heading, nested bullet, etc.) terminates a pending bullet.
    flushBullet();
  }
  flushBullet();

  return { releases };
}

module.exports = { serializeChangelog, parseChangelog };