From d6fbe172dc6ec9935e877e71c8a3ef71d2c84a75 Mon Sep 17 00:00:00 2001 From: Rudy Celekli Date: Sat, 26 Sep 2026 19:32:43 -0400 Subject: [PATCH] fix(adr): retain qualified relations without parsing narrative as edges --- plugins/ruflo-adr/README.md | 6 +++ .../__tests__/parser-relations-3096.test.mjs | 33 +++++++++++++ plugins/ruflo-adr/scripts/lib/parse-adrs.mjs | 48 +++++++++---------- 3 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 plugins/ruflo-adr/scripts/__tests__/parser-relations-3096.test.mjs diff --git a/plugins/ruflo-adr/README.md b/plugins/ruflo-adr/README.md index 040e30028e..870c269385 100644 --- a/plugins/ruflo-adr/README.md +++ b/plugins/ruflo-adr/README.md @@ -52,6 +52,12 @@ proposed --> accepted --> deprecated Relationships tracked as causal edges: `supersedes`, `amends`, `depends-on`, `related`. +Body relationship fields accept qualifiers such as `**Amends by scope**:` or +`**Depends-on / confirms**:`, and `Relates` is an alias for `Related`. Wrapped +lists may continue on lines containing only ADR references (including Markdown +links) and separators. Narrative continuation is not scanned for relationships; +declare additional relationships in another bold field. + ## Compatibility - **CLI:** pinned to `@claude-flow/cli` v3.6 major+minor. diff --git a/plugins/ruflo-adr/scripts/__tests__/parser-relations-3096.test.mjs b/plugins/ruflo-adr/scripts/__tests__/parser-relations-3096.test.mjs new file mode 100644 index 0000000000..c007ff2b2e --- /dev/null +++ b/plugins/ruflo-adr/scripts/__tests__/parser-relations-3096.test.mjs @@ -0,0 +1,33 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseAdr } from '../lib/parse-adrs.mjs'; + +function links(body) { + const dir = mkdtempSync(join(tmpdir(), 'adr-relations-')); + try { + const file = join(dir, 'ADR-032-example.md'); + writeFileSync(file, `# ADR-032: Example\n\n**Status**: accepted\n\n${body}\n`); + return parseAdr(file, dir).links; + } finally { rmSync(dir, { recursive: true, force: true }); } +} + +for (const label of ['Supersedes in part', 'Amends by scope', 'Relates', 'Depends-on / confirms']) { + test(`retains a relation with the ${label} label`, () => { + assert.equal(links(`**${label}**: ADR-031`).length, 1); + }); +} +test('narrative continuation cannot invent an ADR edge', () => { + const edges = links('**Amends (by scope)**: ADR-031 surveyed two sites\nand missed a third.\nADR-031 itself applied a correction to ADR-030.'); + assert.deepEqual(edges, [{ from: 'ADR-032', to: 'ADR-031', relation: 'amends' }]); +}); +test('wrapped reference-only lists still retain every reference', () => { + const edges = links('**Related**: ADR-030,\nADR-031, [ADR-029](ADR-029.md)\n\n## Context\nADR-028 is background.'); + assert.deepEqual(edges.map(e => e.to), ['ADR-030', 'ADR-031', 'ADR-029']); +}); +test('supports repeated fields without treating ordinary prose as metadata', () => { + const edges = links('**Related**: ADR-030\n\n**Related (implementation)**: ADR-031\n\nRelated: ADR-029'); + assert.deepEqual(edges.map(e => e.to), ['ADR-030', 'ADR-031']); +}); diff --git a/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs b/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs index 09499f2ace..c4d3040433 100644 --- a/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs +++ b/plugins/ruflo-adr/scripts/lib/parse-adrs.mjs @@ -163,32 +163,28 @@ function parseLinks(text, selfId) { } } } - // Body relationship lines. #2474 Bug 3: loosened to accept both colon - // placements (`**Supersedes:**` and `**Supersedes**:`) and an optional - // parenthetical qualifier like `**Supersedes (partial):**` — same - // tolerance as parseStatus. - // - // #2781 (Jordi-Izquierdo-DDS): a wrapped relation like - // **Related**: ADR-124, - // ADR-125 - // silently dropped ADR-125 because `(.+)$` under /m only captures one - // physical line. Now capture the first line plus any continuation lines - // that don't look like the start of a new field (bold **Label**:), a - // heading (`##`), a horizontal rule (`---`), or a new list bullet. - // Safe because extractAdrRefs strips anything that isn't an ADR-NNN - // token, so over-capture into a plain-text continuation is harmless. - const REL = (label) => new RegExp( - `^[-*+]?\\s*\\*\\*${label}(?:\\s*\\([^)]*\\))?:?\\*\\*:?\\s*(.+(?:\\n(?!\\s*(?:\\*\\*[A-Za-z]|##|---|[-*+]\\s|\\d+\\.\\s))[^\\n]+)*)`, - 'mi', - ); - const supersedes = REL('Supersedes').exec(text); - if (supersedes) for (const ref of extractAdrRefs(supersedes[1])) out.push({ from: ref, to: selfId, relation: 'supersedes' }); - const amended = REL('(?:Amended[ -]by|Amends)').exec(text); - if (amended) for (const ref of extractAdrRefs(amended[1])) out.push({ from: selfId, to: ref, relation: 'amends' }); - const related = REL('Related').exec(text); - if (related) for (const ref of extractAdrRefs(related[1])) out.push({ from: selfId, to: ref, relation: 'related' }); - const dependsOn = REL('Depends[ -]on').exec(text); - if (dependsOn) for (const ref of extractAdrRefs(dependsOn[1])) out.push({ from: selfId, to: ref, relation: 'depends-on' }); + // Accept qualified labels, either colon placement, and repeated fields. + // Wrapped reference lists (#2781) remain supported, but narrative prose is + // not a continuation: incidental ADR mentions must not invent edges (#3096). + const values = (label) => { + const re = new RegExp( + `^[ \\t]*(?:[-*+]\\s+)?\\*\\*${label}(?=\\s|:|\\*\\*|/|\\()[^*\\r\\n]*\\*\\*:?[^\\S\\r\\n]*(.*)$`, 'gmi', + ); + return [...text.matchAll(re)].map((match) => { + let value = match[1]; + const rest = text.slice(match.index + match[0].length).split(/\r?\n/).slice(1); + for (const line of rest) { + const plain = line.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1'); + if (!/\bADR-?\d+\b/i.test(plain) || !/^[\s,+;&]*$/.test(plain.replace(/\bADR-?\d+\b/gi, ''))) break; + value += `\n${line}`; + } + return value; + }); + }; + for (const value of values('Supersedes')) for (const ref of extractAdrRefs(value)) out.push({ from: ref, to: selfId, relation: 'supersedes' }); + for (const value of values('(?:Amended[ -]by|Amends)')) for (const ref of extractAdrRefs(value)) out.push({ from: selfId, to: ref, relation: 'amends' }); + for (const value of values('(?:Related|Relates)')) for (const ref of extractAdrRefs(value)) out.push({ from: selfId, to: ref, relation: 'related' }); + for (const value of values('Depends[ -]on')) for (const ref of extractAdrRefs(value)) out.push({ from: selfId, to: ref, relation: 'depends-on' }); return out; }