diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30937cd..28f07d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,24 +42,23 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
…) accept `module::symbol` (Rust / C++ / Ruby), `Module.symbol`
(TS / JS / Python), and `module/symbol` (path-style) — multi-level
forms (`crate::configurator::stage_apply::run`) and Rust path
- prefixes (`crate`, `super`, `self`) are handled. Two underlying
- fixes:
+ prefixes (`crate`, `super`, `self`) are handled. Closes
+ [#173](https://github.com/colbymchenry/codegraph/issues/173). Thanks
+ to [@joselhurtado](https://github.com/joselhurtado) for the detailed
+ reproduction. Three underlying fixes:
- The FTS5 query builder now treats `::` as a token separator
instead of stripping it to nothing, so `stage_apply::run` no
longer collapses to the unsearchable `stage_applyrun`.
- `matchesSymbol` falls back to a file-path containment check when
- `qualifiedName` doesn't carry the module hierarchy (Rust file-
- level functions, Python free functions in a package): a `run`
- in `src/configurator/stage_apply.rs` now matches
+ `qualifiedName` doesn't carry the module hierarchy (Rust
+ file-level functions, Python free functions in a package): a
+ `run` in `src/configurator/stage_apply.rs` now matches
`stage_apply::run` because `stage_apply` appears as a path
segment.
- Qualified lookups that don't match the qualifier no longer fall
through to fuzzy text matches — `stage_apply::nonexistent_fn`
returns `null` instead of resolving to an unrelated `rollback`
in the same file.
- Closes [#173](https://github.com/colbymchenry/codegraph/issues/173).
- Thanks to [@joselhurtado](https://github.com/joselhurtado) for the
- detailed reproduction.
[0.7.10]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.10
diff --git a/scripts/extract-release-notes.mjs b/scripts/extract-release-notes.mjs
new file mode 100755
index 0000000..3bcf7f3
--- /dev/null
+++ b/scripts/extract-release-notes.mjs
@@ -0,0 +1,116 @@
+#!/usr/bin/env node
+/**
+ * Extract a release-notes block from CHANGELOG.md for a given version,
+ * then unwrap hard-wrapped paragraphs.
+ *
+ * Why: GitHub renders release-note Markdown with GFM hard breaks, so
+ * every `\n` becomes `
`. The CHANGELOG is hard-wrapped at ~75
+ * chars for readable diffs, which then renders as awkward visible
+ * line breaks on the release page. This script joins indented
+ * continuation lines into a single line per bullet so the GFM
+ * renderer produces clean paragraphs.
+ *
+ * Repo-level CHANGELOG.md viewing is unaffected (CommonMark treats
+ * newlines as spaces there).
+ *
+ * Usage: extract-release-notes.mjs
+ * e.g. extract-release-notes.mjs 0.7.10
+ */
+
+import { readFileSync } from 'fs';
+
+const version = process.argv[2];
+if (!version) {
+ console.error('usage: extract-release-notes.mjs ');
+ process.exit(1);
+}
+
+const escaped = version.replace(/\./g, '\\.');
+const headerRe = new RegExp(`^## \\[${escaped}\\]`);
+const anyHeaderRe = /^## \[/;
+
+const lines = readFileSync('CHANGELOG.md', 'utf8').split('\n');
+const start = lines.findIndex((l) => headerRe.test(l));
+if (start === -1) {
+ console.error(`no '## [${version}]' entry found in CHANGELOG.md`);
+ process.exit(1);
+}
+const after = lines.findIndex((l, i) => i > start && anyHeaderRe.test(l));
+const block = lines.slice(start, after === -1 ? lines.length : after);
+
+// Find the indent of the most recent list item; a continuation line
+// whose indent is GREATER than that belongs to that item, otherwise
+// it might belong to an ancestor item further up the stack.
+//
+// Track a stack of `{ indent: number }` frames so we can attach a
+// continuation to the right ancestor. This correctly handles the
+// post-nested-list continuation pattern:
+//
+// - top-level
+// - nested
+// back to top-level <- 2-space indent, joins the top-level bullet
+const out = [];
+let buf = ''; // pending list-item text being built
+let stack = []; // [{ indent: number }] open list items
+
+function flushBuf() {
+ if (buf !== '') {
+ out.push(buf);
+ buf = '';
+ }
+}
+
+function leadingSpaces(s) {
+ const m = s.match(/^(\s*)/);
+ return m ? m[1].length : 0;
+}
+
+const listItemRe = /^(\s*)([-*+]|\d+\.)\s+/;
+
+for (const line of block) {
+ if (/^\s*$/.test(line)) {
+ flushBuf();
+ out.push('');
+ continue;
+ }
+ if (/^#/.test(line)) {
+ flushBuf();
+ stack = [];
+ out.push(line);
+ continue;
+ }
+ const itemMatch = line.match(listItemRe);
+ if (itemMatch) {
+ flushBuf();
+ const indent = itemMatch[1].length;
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
+ stack.pop();
+ }
+ stack.push({ indent });
+ buf = line;
+ continue;
+ }
+ if (/^\s/.test(line)) {
+ // Continuation. Pop any list frames deeper than this indent — the
+ // continuation belongs to the nearest enclosing list item.
+ const indent = leadingSpaces(line);
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
+ // Closes the deeper item — its buffered text is already in `buf`
+ // belonging to the most recent flush. We need to flush before
+ // re-buffering for the ancestor item.
+ flushBuf();
+ stack.pop();
+ }
+ const trimmed = line.replace(/^\s+/, '');
+ buf = buf === '' ? trimmed : `${buf} ${trimmed}`;
+ continue;
+ }
+ // Top-level non-list, non-heading (e.g. `[0.7.10]: https://...`)
+ flushBuf();
+ stack = [];
+ out.push(line);
+}
+flushBuf();
+
+process.stdout.write(out.join('\n'));
+if (!out[out.length - 1]?.endsWith('\n')) process.stdout.write('\n');
diff --git a/scripts/release.sh b/scripts/release.sh
index da6bdae..9edf846 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -30,13 +30,11 @@ if ! grep -q "^## \[${VERSION}\]" CHANGELOG.md; then
exit 1
fi
-NOTES=$(awk -v v="${VERSION}" '
- /^## \[/ {
- if (p) exit
- if ($0 ~ "^## \\[" v "\\]") p = 1
- }
- p
-' CHANGELOG.md)
+# Extract notes with paragraph unwrapping — GitHub Releases render with
+# GFM hard-breaks, so the CHANGELOG's hard-wrapped lines would show as
+# visible `
` breaks otherwise. The helper joins continuation lines
+# into a single line per bullet.
+NOTES=$(node scripts/extract-release-notes.mjs "${VERSION}")
if [ -z "${NOTES}" ]; then
echo "error: failed to extract changelog notes for ${VERSION}" >&2