fix(adr): retain qualified relations without parsing narrative as edges

This commit is contained in:
Rudy Celekli committed 2026-09-26 19:32:43 -04:00
1 parent 39ea61fda9
commit d6fbe172dc
3 files changed
+61 -26

No files matched your search

+6
View File
@@ -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.
@@ -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']);
});
+22 -26
View File
@@ -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;
}