@vibecompass/vibecompass 0.15.0 → 0.17.0

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.
@@ -0,0 +1,1718 @@
1
+ import { sha256Text } from './hash.js';
2
+
3
+ /**
4
+ * Typed decision references and declared decision lineage (plan task A2;
5
+ * D-357, D-359, D-363). Contract: `architecture/platform/project-memory/
6
+ * decision-lineage.md`.
7
+ *
8
+ * Relations are typed by evidence strength. Mechanical relations record that
9
+ * a D-number appears in a source (`cites` from architecture docs,
10
+ * `references` from session notes and decision entries, `made` for a note's
11
+ * "Decisions made" listing). Declared relations (`supersedes`, `amends`,
12
+ * `preserves`) come only from a later decision's own wording — its structured
13
+ * lineage fields (D-363) or conservatively parsed prose. Lineage-like wording
14
+ * the parser does not certify becomes `unknown`; a bare mention is only a
15
+ * reference. Nothing here derives "governs" or "currently valid".
16
+ *
17
+ * The extractors are pure (path + content in, relations out) so the app can
18
+ * port them against the shared fixtures in `src/tests/fixtures/decision-lineage/`.
19
+ */
20
+
21
+ export const DECISION_LINEAGE_CONTRACT_VERSION = 1;
22
+ export const DECISION_RELATION_TYPES = Object.freeze([
23
+ 'cites',
24
+ 'references',
25
+ 'made',
26
+ 'supersedes',
27
+ 'amends',
28
+ 'preserves',
29
+ 'unknown',
30
+ ]);
31
+ export const DECLARED_LINEAGE_RELATIONS = Object.freeze(['supersedes', 'amends', 'preserves']);
32
+
33
+ // The claim (field + target span) a prose relation came from; internal,
34
+ // never serialized. Coordinated verbs share one span, so one claim.
35
+ const CLAIM_OF = new WeakMap();
36
+ const RELATION_ORDER = new Map(DECISION_RELATION_TYPES.map((relation, index) => [relation, index]));
37
+ const MAX_RANGE_SPAN = 50;
38
+ const EXCERPT_LIMIT = 280;
39
+ const SCOPE_LIMIT = 200;
40
+
41
+ // A D-number is `D-` plus digits, not glued to a surrounding word, and not the
42
+ // head of a hyphenated identifier such as `D-298-founder-ack`.
43
+ const REFERENCE_PATTERN = /(?<![A-Za-z0-9_])D-(\d+)(?![A-Za-z0-9_])(?!-(?!D-\d)[A-Za-z])/g;
44
+ const RANGE_TAIL_PATTERN = /^(?:[ \t]*–[ \t]*|-|[ \t]+through[ \t]+)D-(\d+)(?![A-Za-z0-9_])/;
45
+ const DECISION_HEADING_PATTERN = /^###[ \t]+D-(\d{3,})\b([^\n]*)$/gm;
46
+ const FIELD_LABEL_PATTERN = /^\*\*([^*\n]+?):\*\*[ \t]*/gm;
47
+ const FRONTMATTER_PATTERN = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
48
+ const STRUCTURED_FIELDS = new Map([
49
+ ['supersedes', { relation: 'supersedes', bare: 'full', partial: false }],
50
+ ['partially supersedes', { relation: 'supersedes', bare: 'partial', partial: true }],
51
+ ['amends', { relation: 'amends', bare: 'unqualified', partial: false }],
52
+ ['preserves', { relation: 'preserves', bare: 'unqualified', partial: false }],
53
+ ]);
54
+ const SKIPPED_FIELDS = new Set(['timestamp']);
55
+
56
+ // Word edges that also refuse hyphen compounds ("structure-preserving",
57
+ // "reverse-proxy") so a compound adjective is never read as a lineage verb.
58
+ const L = '(?<![\\w-])';
59
+ const R = '(?![\\w-])';
60
+ const cue = (body) => new RegExp(`${L}${body}${R}`, 'gi');
61
+
62
+ // Ordered cue table. Overlapping matches resolve to the longer match, so the
63
+ // negated and agent forms win over the bare verbs they contain.
64
+ const CUE_DEFINITIONS = [
65
+ {
66
+ id: 'agent-passive',
67
+ form: 'agent',
68
+ pattern: cue(
69
+ '(?:(?:is|are|was|were|be|been|being|as)\\s+)?(?:(?:now|also|partially|partly|narrowly|fully|later|since|subsequently|thereby|explicitly)\\s+)*(superseded|amended|refined|narrowed|replaced|overridden|withdrawn)\\s+by(?=\\s+D-\\d)',
70
+ ),
71
+ },
72
+ {
73
+ id: 'negated-verb',
74
+ form: 'object',
75
+ family: 'negated',
76
+ pattern: cue(
77
+ '(?:(?:does|do|did|will|shall|should|must|can|could|would)\\s+)?(?:not|never)\\s+(?:(?:explicitly|materially|otherwise)\\s+)?(?:supersede|supersedes|amend|amends|change|changes|alter|alters|replace|replaces|reverse|reverses|override|overrides|weaken|weakens|waive|waives|affect|affects|modify|modifies|disturb|disturbs)',
78
+ ),
79
+ },
80
+ {
81
+ id: 'without-change',
82
+ form: 'object',
83
+ family: 'negated',
84
+ pattern: cue('without\\s+(?:superseding|amending|changing|altering|replacing|reversing|overriding|weakening|modifying|disturbing|affecting)'),
85
+ },
86
+ { id: 'no-change', form: 'object', family: 'negated', pattern: cue('no\\s+(?:[a-z][\\w-]*\\s+){0,2}changes?\\s+(?:to|in)') },
87
+ {
88
+ id: 'negated-passive',
89
+ form: 'subject',
90
+ family: 'negated',
91
+ pattern: cue(
92
+ '(?:is|are|was|were|be|been)\\s+not\\s+(?:(?:being|partially|fully|otherwise)\\s+)?(?:superseded|amended|changed|altered|replaced|reversed|overridden)',
93
+ ),
94
+ },
95
+ {
96
+ id: 'supersede-passive',
97
+ form: 'subject',
98
+ family: 'supersede',
99
+ pattern: cue(
100
+ '(?:is|are|was|were|be|been|being)\\s+(?:(?:now|also|thereby|hereby|explicitly|hence|therefore|effectively|partially|partly|narrowly|fully)\\s+)*superseded',
101
+ ),
102
+ },
103
+ { id: 'supersede-active', form: 'object', family: 'supersede', pattern: cue('(?:(?:partially|partly|narrowly|fully)\\s+)?(?:supersedes|supersede|superseding)') },
104
+ { id: 'supersession-of', form: 'object', family: 'supersede', pattern: cue('(?:([A-Za-z][\\w-]*)\\s+)?supersession\\s+of') },
105
+ {
106
+ id: 'amend-passive',
107
+ form: 'subject',
108
+ family: 'amend',
109
+ pattern: cue(
110
+ '(?:is|are|was|were|be|been|being)\\s+(?:(?:now|also|thereby|hereby|explicitly|narrowly|partially|partly|comprehensively|further)\\s+)*amended',
111
+ ),
112
+ },
113
+ {
114
+ id: 'amend-active',
115
+ form: 'object',
116
+ family: 'amend',
117
+ pattern: cue('(?:(?:narrowly|partially|partly|comprehensively|fully|further|also)\\s+)?(?:amends|amend|amending)'),
118
+ },
119
+ { id: 'amendment-to', form: 'object', family: 'amend', pattern: cue('amendments?\\s+(?:to|of)') },
120
+ { id: 'preserve-active', form: 'object', family: 'preserve', pattern: cue('(?:preserves|preserve|preserving|keeps|keep|keeping|retains|retain|retaining)') },
121
+ { id: 'leaves', form: 'leaves', family: 'preserve', pattern: cue('(?:leaves|leave|leaving)') },
122
+ {
123
+ id: 'remain-predicate',
124
+ form: 'subject',
125
+ family: 'preserve',
126
+ pattern: cue(
127
+ '(?:remains|remain|remained|stays|stay|stayed)\\s+(?:(?:fully|still|also|otherwise|equally)\\s+)?(?:unchanged|intact|valid|in\\s+force|in\\s+effect|in\\s+place|binding|untouched|authoritative|mandatory|required|governing|applicable)',
128
+ ),
129
+ },
130
+ {
131
+ id: 'governs-predicate',
132
+ form: 'subject',
133
+ family: 'preserve',
134
+ pattern: cue('(?:still\\s+(?:governs|govern|applies|apply|holds|hold|stands|stand)|continues?\\s+to\\s+(?:govern|apply|hold|stand))'),
135
+ },
136
+ { id: 'stands', form: 'subject', family: 'preserve', pattern: /(?<![\w-])(?:stands|stand)(?![\w-])(?=\s*(?:[.;,:)]|$))/gi },
137
+ {
138
+ id: 'is-preserved',
139
+ form: 'subject',
140
+ family: 'preserve',
141
+ pattern: cue(
142
+ '(?:is|are|was|were)\\s+(?:(?:still|also|fully|otherwise|explicitly)\\s+)?(?:preserved|retained|kept|unchanged|untouched|intact|left\\s+(?:intact|untouched|unchanged|in\\s+place))',
143
+ ),
144
+ },
145
+ {
146
+ id: 'lineage-verb',
147
+ form: 'verb',
148
+ family: 'unknown',
149
+ pattern: cue(
150
+ '(?:replaces|replace|replacing|replaced|refines|refine|refining|refined|narrows|narrow|narrowing|narrowed|withdraws|withdraw|withdrawing|withdrawn|overrides|override|overriding|overridden|corrects|correcting|corrected|retires|retire|retiring|retired|reverses|reversing|reversed|revokes|revoke|revoking|revoked|obsoletes|obsoleted|rescinds|rescind|rescinded|invalidates|invalidated|deprecates|deprecated)',
151
+ ),
152
+ },
153
+ ];
154
+
155
+ // Object terminators: clause punctuation, subordinating words, a manner
156
+ // "by <verb>", and a comma that opens a new verb phrase (", matches the …",
157
+ // ", and avoids a …") rather than continuing a list.
158
+ const OBJECT_TERMINATOR_PATTERN =
159
+ /(?:[:;]|\s[—–]\s|\s-\s|,?\s+(?:while|whereas|but|except|without|rather\s+than|so\s+that|because|since|although|though|unless|until|with|via|which|whose|instead|in\s+exchange)(?![\w-])|,?\s+by\s+(?!D-\d|this\b)(?=[A-Za-z])|,\s+(?:and\s+)?(?=[a-z][a-z-]*s\s+(?:the|a|an|its|their|this|that|these|those|no|every|all|each|any|[a-z-]+ed|[a-z-]+ing)(?![\w-]))|\s+and\s+(?=[a-z][a-z-]*s\s+(?:(?:the|a|an|its|their|this|that|these|those|no|every|all|each|any)(?![\w-])|D-\d)))/g;
160
+ const SUBJECT_BOUNDARY_PATTERN =
161
+ /(?:[:;]|\s[—–]\s|,?\s+(?:but|while|whereas|although|though|because|since|yet|so\s+that|where|when|if|unless|until)\s+)/gi;
162
+ const LEAVES_TERMINAL_PATTERN = /(?<![\w-])(?:intact|untouched|unchanged|in\s+place|as\s+is|in\s+force)(?![\w-])/gi;
163
+ // Polarity is separate from modality: "cannot preserve" and "D-010 cannot
164
+ // remain unchanged" negate a preservation; they are not a requirement.
165
+ const NEGATION_WINDOW_PATTERN = /(?<![\w-])(?:not|never|no\s+longer|cannot|[a-z]+n['’]t)\s+(?:[\w-]+\s+)?$/i;
166
+ const MODAL_ADVERBS = '(?:(?:not|never|explicitly|also|later|eventually|first|then|each|now|only|still|ever|thereby)\\s+){0,2}';
167
+ const HYPOTHETICAL_AUXILIARY_PATTERN = new RegExp(
168
+ `(?<![\\w-])(?:may|might|could|would|can|should|to)\\s+${MODAL_ADVERBS}(?:be\\s+|have\\s+been\\s+)?$`,
169
+ 'i',
170
+ );
171
+ const REQUIREMENT_AUXILIARY_PATTERN = new RegExp(`(?<![\\w-])(?:must|shall|will)\\s+${MODAL_ADVERBS}(?:be\\s+)?$`, 'i');
172
+ const HYPOTHETICAL_CUE_PATTERN = /^(?:may|might|could|would|can|should)\s/i;
173
+ const REQUIREMENT_CUE_PATTERN = /^(?:must|shall|will)\s/i;
174
+ const CONDITIONAL_BEFORE_PATTERN = /(?<![\w-])(?:if|unless|whether|provided\s+that|assuming|in\s+case)(?![\w-])/i;
175
+ const CONDITIONAL_OPENING_PATTERN = /^\s*(?:when|whenever|once|until|should|were)(?![\w-])/i;
176
+ const CONDITIONAL_AFTER_PATTERN = /(?<![\w-])(?:if|unless|provided\s+that)(?![\w-])/i;
177
+ const CONDITIONAL_TAIL_PATTERN = /^\s*,?\s*(?:unless|if|provided\s+that|only\s+if)(?![\w-])/i;
178
+ // Verbs that open an implicit-subject sentence in decision prose ("Confirms
179
+ // D-246 … and preserves D-004"): IDs after them are their objects and the
180
+ // entry is the actor. Closed on purpose — an actor phrase that names another
181
+ // decision without one of these openings is left uncertified.
182
+ const IMPLICIT_SUBJECT_VERB_PATTERN = new RegExp(
183
+ `^\\s*(?:(?:and|also|then)\\s+)?(?:[a-z]+ly\\s+)?(?:${[
184
+ 'adds', 'aligns', 'amends', 'applies', 'avoids', 'builds', 'carries', 'clarifies', 'closes', 'complements',
185
+ 'completes', 'confirms', 'corrects', 'covers', 'creates', 'defers', 'delivers', 'documents', 'extends',
186
+ 'follows', 'generalizes', 'implements', 'keeps', 'leaves', 'matches', 'moves', 'narrows', 'operationalizes',
187
+ 'preserves', 'qualifies', 'reaffirms', 'records', 'reduces', 'refines', 'replaces', 'resolves', 'retains',
188
+ 'reuses', 'scopes', 'simplifies', 'supersedes', 'supplies', 'supports', 'treats', 'updates', 'uses',
189
+ ].join('|')})(?![\\w-])`,
190
+ 'i',
191
+ );
192
+ // The shared-actor shape behind the implicit-subject exception: the phrase
193
+ // opens with one of those verbs, carries no complement clause of its own
194
+ // ("Documents how D-020 supersedes …", "Confirms that D-020 …"), and the cue
195
+ // is coordinated with the opening predicate ("… and preserves", "… by
196
+ // keeping", "… while preserving", or a "without changing …" phrase).
197
+ const COMPLEMENT_CLAUSE_PATTERN = /(?<![\w-])(?:that|how|whether|why|where|when|which|who|whom|whose|what|if)(?![\w-])/i;
198
+ const COORDINATED_TAIL_PATTERN = /(?:(?<![\w-])(?:and|or|then|also|by|while)|,)\s*$/i;
199
+
200
+ function sharesImplicitActor(actorPhrase, current) {
201
+ if (!IMPLICIT_SUBJECT_VERB_PATTERN.test(actorPhrase)) return false;
202
+ const afterOpening = actorPhrase.replace(IMPLICIT_SUBJECT_VERB_PATTERN, '');
203
+ if (COMPLEMENT_CLAUSE_PATTERN.test(afterOpening)) return false;
204
+ return current.id === 'without-change' || COORDINATED_TAIL_PATTERN.test(actorPhrase);
205
+ }
206
+
207
+ const OBJECT_PRONOUN_PATTERN = /^\s*(?:this|that|it|them|these|those|which|what|itself|themselves|such)(?![\w-])/i;
208
+ const OBJECT_RELATIONAL_PATTERN =
209
+ /(?<![\w-])(?:separate|separately|apart|distinct|independent|independently|different|differently|unlike|like|consistent|aligned|compatible|comparable|similar|relative|than|alongside|versus|vs)(?![\w-])/i;
210
+ const HISTORICAL_RETENTION_PATTERN =
211
+ /(?<![\w-])(?:kept|retained|preserved)\s+(?:only\s+)?(?:for|as)\s+(?:a\s+|the\s+)?historical\s+(?:reference|record|context)(?![\w-])/i;
212
+ const FULL_EXTENT_PATTERN = /^(?:in\s+full|entirely|completely|wholly|comprehensively|in\s+(?:its|their)\s+entirety|in\s+whole)(?![\w-])/i;
213
+ const PARTIAL_EXTENT_PATTERN = /(?<![\w-])(?:partially|partly|in\s+part)(?![\w-])/i;
214
+ const TIGHT_SEPARATOR_PATTERN = /^\s*(?:\/|&|and\/or|and|or|plus)\s*$/i;
215
+ const LIST_SEPARATOR_PATTERN = /(?:;\s*(?:and\/or|and|or|plus)?\s*|,\s*(?:and\/or|and|or|plus)?\s*|\s+(?:and\/or|and|or|plus|&)\s+|\/)/gi;
216
+ const MADE_NEGATION_PATTERN =
217
+ /(?<![\w-])(?:no\s+new\s+decisions?|no\s+decisions?\s+(?:were\s+|was\s+)?(?:appended|logged|made|recorded)|not\s+(?:yet\s+)?(?:appended|logged|made|recorded))(?![\w-])/i;
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Public extractors
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Finds canonical D-number mentions. Ranges (`D-314–D-317`, `D-124 through
225
+ * D-130`, `D-124-D-130`, at most 50 apart) expand: endpoints stay explicit
226
+ * mentions, interior IDs are inferred by range expansion. Malformed tokens
227
+ * (`D-12`, `D-0070`) are returned separately and never become relations.
228
+ */
229
+ export function scanDecisionReferences(text) {
230
+ const refs = [];
231
+ const malformed = [];
232
+ const pattern = new RegExp(REFERENCE_PATTERN.source, 'g');
233
+ let match;
234
+
235
+ while ((match = pattern.exec(text)) !== null) {
236
+ const digits = match[1];
237
+ const start = match.index;
238
+ const end = start + match[0].length;
239
+
240
+ const id = toDecisionId(digits);
241
+ if (id === null) {
242
+ malformed.push({ token: match[0], start, end });
243
+ continue;
244
+ }
245
+
246
+ const tail = RANGE_TAIL_PATTERN.exec(text.slice(end));
247
+ const last = tail ? toDecisionId(tail[1]) : null;
248
+ if (last !== null) {
249
+ const span = last - id;
250
+ if (span > 0 && span <= MAX_RANGE_SPAN) {
251
+ const rangeEnd = end + tail[0].length;
252
+ const range = { start, end: rangeEnd };
253
+ refs.push({ id, start, end, evidence: 'explicit', basis: 'mention', range });
254
+ // Counted loop: bounded by MAX_RANGE_SPAN regardless of ID magnitude.
255
+ for (let offset = 1; offset < span; offset += 1) {
256
+ refs.push({ id: id + offset, start, end: rangeEnd, evidence: 'inferred', basis: 'range-expansion', range });
257
+ }
258
+ refs.push({ id: last, start: rangeEnd - `D-${tail[1]}`.length, end: rangeEnd, evidence: 'explicit', basis: 'mention', range });
259
+ pattern.lastIndex = rangeEnd;
260
+ continue;
261
+ }
262
+ }
263
+
264
+ refs.push({ id, start, end, evidence: 'explicit', basis: 'mention' });
265
+ }
266
+
267
+ return { refs, malformed };
268
+ }
269
+
270
+ /** Architecture doc → `cites` relations, one per (section, decision). */
271
+ export function extractArchitectureDocCitations({ path, content }) {
272
+ return extractMentionRelations({ path, content, sourceKind: 'architecture', relation: 'cites' });
273
+ }
274
+
275
+ /**
276
+ * Session note → `references` for every mention (one per section and
277
+ * decision) plus `made` only for D-numbers that open a top-level bullet under
278
+ * the note's "Decisions made" section.
279
+ */
280
+ export function extractSessionNoteRelations({ path, content }) {
281
+ const base = extractMentionRelations({ path, content, sourceKind: 'session', relation: 'references' });
282
+ const made = extractMadeRelations({ path, content, context: base.context });
283
+ return {
284
+ relations: sortRelations([...base.relations, ...made]),
285
+ diagnostics: base.diagnostics,
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Decision domain file → declared lineage (`supersedes` / `amends` /
291
+ * `preserves`), `unknown` lineage-like wording, and `references` for every
292
+ * other mention, per entry.
293
+ */
294
+ export function extractDecisionFileRelations({ path, content }) {
295
+ const relations = [];
296
+ const diagnostics = [];
297
+ const sourceHash = sha256Text(content);
298
+ const lineStarts = buildLineStarts(content);
299
+
300
+ for (const entry of parseDecisionEntries(content)) {
301
+ const result = extractEntryRelations({ entry, path, content, sourceHash, lineStarts });
302
+ relations.push(...result.relations);
303
+ diagnostics.push(...result.diagnostics);
304
+ }
305
+
306
+ return { relations: sortRelations(relations), diagnostics: sortDiagnostics(diagnostics) };
307
+ }
308
+
309
+ /**
310
+ * Builds the corpus-wide lineage view over scanned canonical documents
311
+ * (`{ path, kind, content }`): every relation plus `target_exists`, the
312
+ * diagnostics, and per-relation counts.
313
+ */
314
+ export function buildDecisionLineageModel(documents) {
315
+ const knownIds = new Set();
316
+ for (const document of documents) {
317
+ if (document.kind !== 'decision') continue;
318
+ for (const entry of parseDecisionEntries(document.content)) knownIds.add(entry.decision_id);
319
+ }
320
+
321
+ const relations = [];
322
+ const diagnostics = [];
323
+ for (const document of documents) {
324
+ const extractor =
325
+ document.kind === 'architecture'
326
+ ? extractArchitectureDocCitations
327
+ : document.kind === 'session'
328
+ ? extractSessionNoteRelations
329
+ : document.kind === 'decision'
330
+ ? extractDecisionFileRelations
331
+ : null;
332
+ if (!extractor) continue;
333
+ const result = extractor({ path: document.path, content: document.content });
334
+ relations.push(...result.relations);
335
+ diagnostics.push(...result.diagnostics);
336
+ }
337
+
338
+ const unresolved = new Set();
339
+ const withExistence = relations.map((relation) => {
340
+ const exists = knownIds.has(relation.target_decision_id);
341
+ const key = `${relation.source_path}\u0000${relation.target_decision_id}`;
342
+ if (!exists && !unresolved.has(key)) {
343
+ unresolved.add(key);
344
+ diagnostics.push({
345
+ code: 'unresolved-decision-reference',
346
+ path: relation.source_path,
347
+ line: relation.source_line,
348
+ message: `D-${pad(relation.target_decision_id)} is referenced but no canonical decision entry defines it.`,
349
+ });
350
+ }
351
+ return { ...relation, target_exists: exists };
352
+ });
353
+
354
+ const relationCounts = Object.fromEntries(DECISION_RELATION_TYPES.map((relation) => [relation, 0]));
355
+ for (const relation of withExistence) relationCounts[relation.relation] += 1;
356
+
357
+ return {
358
+ contract_version: DECISION_LINEAGE_CONTRACT_VERSION,
359
+ decision_count: knownIds.size,
360
+ relation_counts: relationCounts,
361
+ relations: sortRelations(withExistence),
362
+ diagnostics: sortDiagnostics(diagnostics),
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Declared successors of a decision, transitively: later decisions whose own
368
+ * text (or structured fields) declares `supersedes` or `amends` for it, and
369
+ * their declared successors in turn. Newest first. Never inferred.
370
+ */
371
+ export function collectDeclaredSuccessors(relations, decisionId) {
372
+ const incoming = new Map();
373
+ for (const relation of relations) {
374
+ if (relation.source_kind !== 'decision') continue;
375
+ if (relation.relation !== 'supersedes' && relation.relation !== 'amends') continue;
376
+ if (!incoming.has(relation.target_decision_id)) incoming.set(relation.target_decision_id, []);
377
+ incoming.get(relation.target_decision_id).push(relation);
378
+ }
379
+
380
+ const successors = new Map();
381
+ const queue = [{ id: decisionId, via: [] }];
382
+ while (queue.length > 0) {
383
+ const { id, via } = queue.shift();
384
+ for (const relation of incoming.get(id) ?? []) {
385
+ const successorId = relation.source_decision_id;
386
+ if (successorId === decisionId) continue;
387
+ const existing = successors.get(successorId);
388
+ if (existing) {
389
+ if (!existing.relations.includes(relation.relation)) existing.relations.push(relation.relation);
390
+ continue;
391
+ }
392
+ const path = [...via, id];
393
+ successors.set(successorId, { decision_id: successorId, relations: [relation.relation], via: path });
394
+ queue.push({ id: successorId, via: path });
395
+ }
396
+ }
397
+
398
+ return [...successors.values()]
399
+ .map((successor) => ({ ...successor, relations: successor.relations.sort() }))
400
+ .sort((left, right) => right.decision_id - left.decision_id);
401
+ }
402
+
403
+ /** Splits a decision domain file into entries and labeled fields. */
404
+ export function parseDecisionEntries(content) {
405
+ // Headings and field labels inside fenced code are examples, not structure.
406
+ const fenced = findFencedRanges(content);
407
+ const headings = [...content.matchAll(new RegExp(DECISION_HEADING_PATTERN.source, 'gm'))].filter(
408
+ (heading) => !insideRanges(heading.index, fenced) && toDecisionId(heading[1]) !== null,
409
+ );
410
+
411
+ return headings.map((heading, index) => {
412
+ const start = heading.index;
413
+ const headingEnd = start + heading[0].length;
414
+ const rawEnd = headings[index + 1]?.index ?? content.length;
415
+ const text = content
416
+ .slice(start, rawEnd)
417
+ .replace(/\s+$/, '')
418
+ .replace(/\n[ \t]*(?:-{3,}|\*{3,}|_{3,})[ \t]*$/, '')
419
+ .replace(/\s+$/, '');
420
+ const end = start + text.length;
421
+ const titleMatch = heading[2].match(/^\s*(?:[—–-]\s*)?(.*?)\s*$/);
422
+ const title = titleMatch[1];
423
+ const titleStart = headingEnd - heading[2].length + heading[2].indexOf(title);
424
+ const fields = [{ name: 'Title', start: titleStart, text: title }];
425
+
426
+ const labels = [...content.slice(headingEnd, end).matchAll(new RegExp(FIELD_LABEL_PATTERN.source, 'gm'))].filter(
427
+ (label) => !insideRanges(headingEnd + label.index, fenced),
428
+ );
429
+ const bodyLead = labels.length > 0 ? labels[0].index : end - headingEnd;
430
+ const leading = content.slice(headingEnd, headingEnd + bodyLead);
431
+ if (leading.trim()) {
432
+ fields.push({ name: 'Body', start: headingEnd, text: leading.replace(/\s+$/, '') });
433
+ }
434
+
435
+ labels.forEach((label, labelIndex) => {
436
+ const valueStart = headingEnd + label.index + label[0].length;
437
+ const valueEnd = headingEnd + (labels[labelIndex + 1]?.index ?? end - headingEnd);
438
+ fields.push({
439
+ name: label[1].trim(),
440
+ start: valueStart,
441
+ text: content.slice(valueStart, valueEnd).replace(/\s+$/, ''),
442
+ });
443
+ });
444
+
445
+ return {
446
+ decision_id: Number(heading[1]),
447
+ title,
448
+ heading: heading[0].replace(/^###[ \t]+/, '').trim(),
449
+ start,
450
+ end,
451
+ text,
452
+ fields,
453
+ };
454
+ });
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Mentions, sections, and "made"
459
+ // ---------------------------------------------------------------------------
460
+
461
+ function extractMentionRelations({ path, content, sourceKind, relation }) {
462
+ const sourceHash = sha256Text(content);
463
+ const bodyStart = content.match(FRONTMATTER_PATTERN)?.[0].length ?? 0;
464
+ const lineStarts = buildLineStarts(content);
465
+ const sections = buildSectionIndex(content, bodyStart);
466
+ const { refs, malformed } = scanDecisionReferences(content.slice(bodyStart));
467
+ const grouped = new Map();
468
+
469
+ for (const ref of refs) {
470
+ const offset = bodyStart + ref.start;
471
+ const section = sectionAt(sections, offset);
472
+ const key = `${section.start ?? -1}\u0000${ref.id}`; // position, so repeated headings stay distinct
473
+ const existing = grouped.get(key);
474
+ if (existing) {
475
+ existing.occurrences += 1;
476
+ if (ref.evidence === 'explicit' && existing.evidence !== 'explicit') {
477
+ existing.evidence = 'explicit';
478
+ existing.basis = 'mention';
479
+ existing.source_line = lineAt(lineStarts, offset);
480
+ }
481
+ continue;
482
+ }
483
+
484
+ grouped.set(key, createRelation({
485
+ relation,
486
+ source_kind: sourceKind,
487
+ source_path: path,
488
+ source_section: section.path,
489
+ source_line: lineAt(lineStarts, offset),
490
+ source_hash: sourceHash,
491
+ section_hash: section.hash,
492
+ target_decision_id: ref.id,
493
+ evidence: ref.evidence,
494
+ basis: ref.basis,
495
+ }));
496
+ }
497
+
498
+ const diagnostics = malformed.map((token) => ({
499
+ code: 'malformed-decision-reference',
500
+ path,
501
+ line: lineAt(lineStarts, bodyStart + token.start),
502
+ message: `"${token.token}" is not a canonical decision ID (D-NNN, zero-padded to three digits); it was not treated as a reference.`,
503
+ }));
504
+
505
+ return {
506
+ relations: sortRelations([...grouped.values()]),
507
+ diagnostics: sortDiagnostics(diagnostics),
508
+ context: { sourceHash, bodyStart, lineStarts, sections },
509
+ };
510
+ }
511
+
512
+ function extractMadeRelations({ path, content, context }) {
513
+ const section = context.sections.headings.find(
514
+ (heading) => heading.level === 2 && /^decisions(?:\s+made)?$/i.test(heading.text),
515
+ );
516
+ if (!section) {
517
+ return [];
518
+ }
519
+
520
+ const relations = [];
521
+ const seen = new Set();
522
+ const sectionText = content.slice(section.start, section.end);
523
+ const fenced = findFencedRanges(sectionText);
524
+ const lines = sectionText.split('\n');
525
+ let offset = section.start;
526
+ let item = null;
527
+ const flush = () => {
528
+ if (!item) return;
529
+ collectMadeFromItem({ item, path, context, section, relations, seen });
530
+ item = null;
531
+ };
532
+
533
+ for (const [index, line] of lines.entries()) {
534
+ const lineOffset = offset;
535
+ offset += line.length + 1;
536
+ if (index === 0) continue; // the heading line
537
+ if (insideRanges(lineOffset - section.start, fenced)) {
538
+ flush(); // fenced examples never list decisions
539
+ continue;
540
+ }
541
+
542
+ const topLevel = line.match(/^[-*+][ \t]+(.*)$/);
543
+ if (topLevel) {
544
+ flush();
545
+ item = { text: topLevel[1], start: lineOffset + line.indexOf(topLevel[1]), firstLine: line };
546
+ continue;
547
+ }
548
+
549
+ if (item && /^[ \t]+\S/.test(line) && !/^[ \t]+(?:[-*+]|\d+[.)])[ \t]+/.test(line)) {
550
+ item.text += `\n${line}`;
551
+ continue;
552
+ }
553
+
554
+ flush();
555
+ }
556
+ flush();
557
+
558
+ return relations;
559
+ }
560
+
561
+ function collectMadeFromItem({ item, path, context, section, relations, seen }) {
562
+ if (MADE_NEGATION_PATTERN.test(item.text)) {
563
+ return;
564
+ }
565
+
566
+ for (const segment of splitAtDepthZero(item.text, ';')) {
567
+ const leading = segment.text.match(/^\s*(?:and\s+)?(?:\*\*|__|`)?/)[0].length;
568
+ const body = segment.text.slice(leading);
569
+ const { refs } = scanDecisionReferences(body);
570
+ if (refs.length === 0 || refs[0].start !== 0) {
571
+ continue;
572
+ }
573
+
574
+ // Consume the leading ID group: D-refs joined by commas, slashes, "and",
575
+ // "or", or ranges, optionally wrapped in bold/code markers.
576
+ const group = [];
577
+ let cursor = 0;
578
+ for (const ref of refs) {
579
+ if (ref.start < cursor) {
580
+ group.push(ref); // interior or end of a range already consumed
581
+ cursor = Math.max(cursor, ref.end);
582
+ continue;
583
+ }
584
+ const between = body.slice(cursor, ref.start);
585
+ if (group.length > 0 && !/^(?:\*\*|__|`)?\s*(?:,|\/|&|and|or|–|-|through)?\s*(?:\*\*|__|`)?$/i.test(between)) {
586
+ break;
587
+ }
588
+ group.push(ref);
589
+ cursor = ref.end;
590
+ }
591
+
592
+ for (const ref of group) {
593
+ if (seen.has(ref.id)) continue;
594
+ seen.add(ref.id);
595
+ const absolute = item.start + segment.start + leading + ref.start;
596
+ relations.push(createRelation({
597
+ relation: 'made',
598
+ source_kind: 'session',
599
+ source_path: path,
600
+ source_section: section.path,
601
+ source_line: lineAt(context.lineStarts, absolute),
602
+ source_hash: context.sourceHash,
603
+ section_hash: section.hash,
604
+ target_decision_id: ref.id,
605
+ evidence: ref.evidence,
606
+ basis: ref.basis === 'range-expansion' ? 'range-expansion' : 'listing',
607
+ excerpt: boundText(item.firstLine.replace(/^[-*+][ \t]+/, ''), EXCERPT_LIMIT),
608
+ }));
609
+ }
610
+ }
611
+ }
612
+
613
+ // ---------------------------------------------------------------------------
614
+ // Decision entries
615
+ // ---------------------------------------------------------------------------
616
+
617
+ function extractEntryRelations({ entry, path, content, sourceHash, lineStarts }) {
618
+ const diagnostics = [];
619
+ const sourceId = entry.decision_id;
620
+ const entryHash = sha256Text(entry.text);
621
+ const base = {
622
+ source_kind: 'decision',
623
+ source_path: path,
624
+ source_decision_id: sourceId,
625
+ source_section: entry.heading,
626
+ source_hash: sourceHash,
627
+ section_hash: entryHash,
628
+ };
629
+
630
+ const structured = [];
631
+ const prose = [];
632
+
633
+ for (const field of entry.fields) {
634
+ const key = field.name.toLowerCase();
635
+ if (SKIPPED_FIELDS.has(key)) continue;
636
+
637
+ if (STRUCTURED_FIELDS.has(key)) {
638
+ structured.push(...parseStructuredField({ field, spec: STRUCTURED_FIELDS.get(key), base, lineStarts, diagnostics }));
639
+ continue;
640
+ }
641
+
642
+ prose.push(...parseProseField({ field, sourceId, base, lineStarts }));
643
+ }
644
+
645
+ // Forward references cannot be lineage in an append-only log.
646
+ const lineage = [];
647
+ for (const relation of [...structured, ...prose]) {
648
+ if (relation.relation !== 'unknown' && relation.target_decision_id >= sourceId) {
649
+ diagnostics.push({
650
+ code: 'lineage-forward-reference',
651
+ path,
652
+ line: relation.source_line,
653
+ decision_id: sourceId,
654
+ message: `D-${pad(sourceId)} declares "${relation.relation}" for later decision D-${pad(relation.target_decision_id)}; recorded as unknown.`,
655
+ });
656
+ const moved = { ...relation, relation: 'unknown', extent: null, scope: null, cue: `forward-reference:${relation.cue}` };
657
+ if (CLAIM_OF.has(relation)) CLAIM_OF.set(moved, CLAIM_OF.get(relation));
658
+ lineage.push(moved);
659
+ continue;
660
+ }
661
+ lineage.push(relation);
662
+ }
663
+
664
+ // D-363: a structured field is authoritative for the targets it names;
665
+ // prose `unknown` wording about them is still kept as a follow-up read.
666
+ const structuredTargets = new Map();
667
+ for (const relation of lineage.filter((candidate) => candidate.basis === 'structured-field')) {
668
+ if (!structuredTargets.has(relation.target_decision_id)) structuredTargets.set(relation.target_decision_id, new Set());
669
+ structuredTargets.get(relation.target_decision_id).add(relation.relation);
670
+ }
671
+
672
+ const kept = [];
673
+ for (const relation of lineage) {
674
+ const named = structuredTargets.get(relation.target_decision_id);
675
+ if (relation.basis !== 'structured-field' && named && relation.relation !== 'unknown') {
676
+ if (DECLARED_LINEAGE_RELATIONS.includes(relation.relation) && !named.has(relation.relation)) {
677
+ diagnostics.push({
678
+ code: 'lineage-structured-prose-conflict',
679
+ path,
680
+ line: relation.source_line,
681
+ decision_id: sourceId,
682
+ message: `D-${pad(sourceId)} prose reads "${relation.relation}" for D-${pad(relation.target_decision_id)}, which its structured lineage fields do not declare; the structured fields win.`,
683
+ });
684
+ }
685
+ continue;
686
+ }
687
+ kept.push(relation);
688
+ }
689
+
690
+ // An `unknown` is dropped only when a certified relation for the same
691
+ // target comes from the same claim — coordinated verbs sharing one object
692
+ // ("partially supersedes and refines D-015"). A change and a preservation
693
+ // with separate objects ("Refines D-010's X while preserving D-010's Y")
694
+ // are independent claims, so the `unknown` stays as a follow-up read.
695
+ const claimTarget = (relation) => `${CLAIM_OF.get(relation) ?? ''}\u0000${relation.target_decision_id}`;
696
+ const certifiedClaims = new Set(
697
+ kept
698
+ .filter((relation) => DECLARED_LINEAGE_RELATIONS.includes(relation.relation) && CLAIM_OF.has(relation))
699
+ .map(claimTarget),
700
+ );
701
+ const unknownSeen = new Set();
702
+ const relations = [];
703
+ for (const relation of kept) {
704
+ if (relation.relation === 'unknown') {
705
+ if (CLAIM_OF.has(relation) && certifiedClaims.has(claimTarget(relation))) continue;
706
+ const key = `${claimTarget(relation)}\u0000${relation.cue}`;
707
+ if (unknownSeen.has(key)) continue;
708
+ unknownSeen.add(key);
709
+ }
710
+ relations.push(relation);
711
+ }
712
+
713
+ // Every other mention (quoted, code, restated, or plain) is a reference.
714
+ const linked = new Set(relations.map((relation) => relation.target_decision_id));
715
+ const references = new Map();
716
+ for (const field of entry.fields) {
717
+ const { refs, malformed } = scanDecisionReferences(field.text);
718
+ for (const token of malformed) {
719
+ diagnostics.push({
720
+ code: 'malformed-decision-reference',
721
+ path,
722
+ line: lineAt(lineStarts, field.start + token.start),
723
+ decision_id: sourceId,
724
+ message: `"${token.token}" in D-${pad(sourceId)} is not a canonical decision ID; it was not treated as a reference.`,
725
+ });
726
+ }
727
+ for (const ref of refs) {
728
+ if (ref.id === sourceId || linked.has(ref.id)) continue;
729
+ const existing = references.get(ref.id);
730
+ if (existing) {
731
+ existing.occurrences += 1;
732
+ if (ref.evidence === 'explicit') {
733
+ existing.evidence = 'explicit';
734
+ existing.basis = 'mention';
735
+ }
736
+ continue;
737
+ }
738
+ references.set(ref.id, createRelation({
739
+ ...base,
740
+ relation: 'references',
741
+ source_field: field.name,
742
+ source_line: lineAt(lineStarts, field.start + ref.start),
743
+ target_decision_id: ref.id,
744
+ evidence: ref.evidence,
745
+ basis: ref.basis,
746
+ }));
747
+ }
748
+ }
749
+
750
+ return { relations: [...relations, ...references.values()], diagnostics };
751
+ }
752
+
753
+ function parseStructuredField({ field, spec, base, lineStarts, diagnostics }) {
754
+ const value = field.text.trim();
755
+ const relations = [];
756
+ if (/^none\.?$/i.test(value)) {
757
+ return relations;
758
+ }
759
+
760
+ const items = splitAtDepthZero(field.text, ';');
761
+ for (const item of items) {
762
+ const raw = item.text;
763
+ if (!raw.trim()) continue;
764
+ const dash = raw.match(/\s+[—–-]\s+(?!D-\d)/);
765
+ const idPart = dash ? raw.slice(0, dash.index) : raw;
766
+ const part = dash ? collapse(raw.slice(dash.index + dash[0].length)).replace(/[.\s]+$/, '') : '';
767
+ const { refs, malformed } = scanDecisionReferences(idPart);
768
+ const leftover = idPart
769
+ .replace(new RegExp(REFERENCE_PATTERN.source, 'g'), ' ')
770
+ .replace(/[,/&–]|\b(?:and|through)\b/gi, ' ')
771
+ .replace(/[`*_.]/g, ' ')
772
+ .trim();
773
+ const line = lineAt(lineStarts, field.start + item.start);
774
+
775
+ if (refs.length === 0 || malformed.length > 0 || leftover) {
776
+ diagnostics.push({
777
+ code: 'lineage-field-invalid',
778
+ path: base.source_path,
779
+ line,
780
+ decision_id: base.source_decision_id,
781
+ message: `D-${pad(base.source_decision_id)} "**${field.name}:**" item "${collapse(raw)}" must be decision IDs optionally followed by " — <part>"; it was ignored.`,
782
+ });
783
+ continue;
784
+ }
785
+
786
+ const seen = new Set();
787
+ for (const ref of refs) {
788
+ if (seen.has(ref.id) || ref.id === base.source_decision_id) continue;
789
+ seen.add(ref.id);
790
+ relations.push(createRelation({
791
+ ...base,
792
+ relation: spec.relation,
793
+ source_field: field.name,
794
+ source_line: line,
795
+ target_decision_id: ref.id,
796
+ evidence: ref.evidence,
797
+ basis: 'structured-field',
798
+ extent: part ? (spec.partial ? 'partial' : 'scoped') : spec.bare,
799
+ scope: part ? boundText(part, SCOPE_LIMIT) : null,
800
+ cue: field.name.toLowerCase(),
801
+ excerpt: boundText(collapse(raw), EXCERPT_LIMIT),
802
+ }));
803
+ }
804
+ }
805
+
806
+ return relations;
807
+ }
808
+
809
+ // ---------------------------------------------------------------------------
810
+ // Prose lineage parser
811
+ // ---------------------------------------------------------------------------
812
+
813
+ function parseProseField({ field, sourceId, base, lineStarts }) {
814
+ const original = field.text;
815
+ if (!/D-\d/.test(original)) {
816
+ return []; // no possible target
817
+ }
818
+ const masked = maskForLineage(original);
819
+ const depth = computeParenDepth(masked, original);
820
+ const { refs } = scanDecisionReferences(masked);
821
+ const clauses = splitClauses(masked, depth, original);
822
+ const relations = [];
823
+
824
+ for (const [clauseIndex, clause] of clauses.entries()) {
825
+ // A clause without a D-number can only matter as the tail of a
826
+ // semicolon list whose subject is carried back ("…; and X remain intact").
827
+ const clauseText = masked.slice(clause.start, clause.end);
828
+ if (!/D-\d/.test(clauseText) && !/^\s*and\s/i.test(clauseText)) continue;
829
+ const cues = detectCues(masked, clause, depth, sourceId);
830
+ if (cues.length === 0) continue;
831
+
832
+ const restatements = cues.filter((entry) => entry.restatement);
833
+ const excludedRefs = new Set();
834
+ for (const restatement of restatements) {
835
+ for (const ref of refs) {
836
+ if (ref.start >= restatement.start && ref.end <= restatement.end) excludedRefs.add(ref);
837
+ }
838
+ }
839
+ const refsIn = (start, end) =>
840
+ refs.filter((ref) => ref.start >= start && ref.end <= end && !excludedRefs.has(ref) && ref.id !== sourceId);
841
+
842
+ const spans = new Map();
843
+ for (const current of cues) {
844
+ if (current.restatement) continue;
845
+ const qualifier = objectSpan(current, clause, masked, depth, cues);
846
+ spans.set(current, qualifier);
847
+ }
848
+
849
+ // Coordinated verbs ("partially supersedes and refines D-015") share the
850
+ // next verb's object when their own object is only a conjunction.
851
+ const ordered = cues.filter((entry) => !entry.restatement);
852
+ for (let index = ordered.length - 1; index >= 0; index -= 1) {
853
+ const current = ordered[index];
854
+ if (current.form === 'subject') continue;
855
+ const span = spans.get(current);
856
+ const next = ordered[index + 1];
857
+ // Judge by the text between the two verbs, not by where this verb's own
858
+ // object stopped (a terminator may already have cut it at " and").
859
+ const between = next ? masked.slice(span.start, next.start) : null;
860
+ if (next && next.form !== 'subject' && next.start >= span.start && /^\s*(?:and\/or|and|or|\/|,)?\s*$/i.test(between)) {
861
+ spans.set(current, { ...spans.get(next), shared: true });
862
+ }
863
+ }
864
+
865
+ let previousEnd = clause.start;
866
+ for (const current of ordered) {
867
+ const span = spans.get(current);
868
+ let targetSpan;
869
+ let qualifier = null;
870
+
871
+ if (current.form === 'subject' || (current.form === 'verb' && current.passive) || current.form === 'agent-self') {
872
+ const subjectStart = subjectBoundary(current, clause, masked, depth, previousEnd, clauses, clauseIndex, cues, sourceId);
873
+ targetSpan = { start: subjectStart, end: current.start };
874
+ qualifier = span;
875
+ } else if (current.form === 'leaves') {
876
+ const terminal = findLeavesTerminal(current, span, masked, depth);
877
+ if (!terminal) {
878
+ previousEnd = Math.max(previousEnd, current.end);
879
+ continue;
880
+ }
881
+ targetSpan = { start: current.end, end: terminal };
882
+ } else {
883
+ targetSpan = span;
884
+ }
885
+
886
+ const actorStart = previousEnd;
887
+ previousEnd = Math.max(previousEnd, span.shared ? current.end : span.end);
888
+ const targets = refsIn(targetSpan.start, targetSpan.end);
889
+ if (targets.length === 0) continue;
890
+
891
+ const objectForm = !(current.form === 'subject' || (current.form === 'verb' && current.passive) || current.form === 'agent-self');
892
+ if (objectForm) {
893
+ // The actor is the phrase before the verb, after any introductory
894
+ // prose ending in a comma ("According to D-020, D-020 supersedes …").
895
+ // If it names another decision ("D-020 supersedes D-010", "As D-020
896
+ // supersedes D-010, …"), the claim is that decision's, restated here,
897
+ // or its actor is uncertain: never certified. Targets stay references.
898
+ const actorFrom = subjectBoundary(current, clause, masked, depth, actorStart, clauses, clauseIndex, cues, sourceId);
899
+ let phraseFrom = actorFrom;
900
+ for (let index = current.start - 1; index >= actorFrom; index -= 1) {
901
+ if (masked[index] === ',' && depth[index] === current.depth) {
902
+ phraseFrom = index + 1;
903
+ break;
904
+ }
905
+ }
906
+ const actorPhrase = masked.slice(phraseFrom, current.start);
907
+ if (scanDecisionReferences(actorPhrase).refs.some((ref) => ref.id !== sourceId) && !sharesImplicitActor(actorPhrase, current)) {
908
+ continue;
909
+ }
910
+ // The object must name the decision or a part of it, not a contrast or
911
+ // comparison around it ("Keeping this separate from D-169 …").
912
+ const lead = masked.slice(targetSpan.start, Math.min(...targets.map((target) => target.start)));
913
+ if (OBJECT_PRONOUN_PATTERN.test(lead) || OBJECT_RELATIONAL_PATTERN.test(lead)) continue;
914
+ }
915
+
916
+ const classification = classifyCue(current, masked, clause, Math.max(span.end, targetSpan.end));
917
+ if (!classification) continue;
918
+
919
+ const excerptStart = Math.min(clause.start, targetSpan.start);
920
+ const excerpt = boundText(collapse(original.slice(excerptStart, clause.end)), EXCERPT_LIMIT);
921
+ const scopes = computeScopes({
922
+ targets,
923
+ span: targetSpan,
924
+ masked,
925
+ original,
926
+ restatements,
927
+ qualifier: qualifier ? { start: qualifier.start, end: qualifier.end } : null,
928
+ });
929
+ const clauseAfterCue = masked.slice(current.end, clause.end);
930
+
931
+ const emitted = new Set();
932
+ for (const target of targets) {
933
+ if (emitted.has(target.id)) continue;
934
+ emitted.add(target.id);
935
+ const scopeInfo = scopes.get(target);
936
+ const extent =
937
+ classification.relation === 'unknown'
938
+ ? null
939
+ : resolveExtent({ classification, scopeInfo, clauseAfterCue, current });
940
+ const relation = createRelation({
941
+ ...base,
942
+ relation: classification.relation,
943
+ source_field: field.name,
944
+ source_line: lineAt(lineStarts, field.start + target.start),
945
+ target_decision_id: target.id,
946
+ evidence: target.evidence,
947
+ basis: 'prose',
948
+ extent,
949
+ scope: extent === 'scoped' || extent === 'partial' ? scopeInfo.text : null,
950
+ cue: classification.cue,
951
+ excerpt,
952
+ });
953
+ CLAIM_OF.set(relation, `${field.name}\u0000${targetSpan.start}:${targetSpan.end}`);
954
+ relations.push(relation);
955
+ }
956
+ }
957
+ }
958
+
959
+ return relations;
960
+ }
961
+
962
+ function detectCues(masked, clause, depth, sourceId) {
963
+ const text = masked.slice(clause.start, clause.end);
964
+ const found = [];
965
+
966
+ for (const definition of CUE_DEFINITIONS) {
967
+ const pattern = definition.pattern; // compiled once; global, so reset
968
+ pattern.lastIndex = 0;
969
+ let match;
970
+ while ((match = pattern.exec(text)) !== null) {
971
+ if (match[0].length === 0) {
972
+ pattern.lastIndex += 1;
973
+ continue;
974
+ }
975
+ found.push({
976
+ definition,
977
+ id: definition.id,
978
+ form: definition.form,
979
+ family: definition.family,
980
+ start: clause.start + match.index,
981
+ end: clause.start + match.index + match[0].length,
982
+ text: match[0],
983
+ modifier: match[1] ?? null,
984
+ });
985
+ }
986
+ }
987
+
988
+ found.sort((left, right) => left.start - right.start || right.end - right.start - (left.end - left.start));
989
+ const resolved = [];
990
+ for (const candidate of found) {
991
+ const previous = resolved[resolved.length - 1];
992
+ if (previous && candidate.start < previous.end) {
993
+ if (candidate.end - candidate.start > previous.end - previous.start) resolved[resolved.length - 1] = candidate;
994
+ continue;
995
+ }
996
+ resolved.push(candidate);
997
+ }
998
+
999
+ for (const current of resolved) {
1000
+ current.depth = depth[current.start];
1001
+ if (current.form === 'agent') {
1002
+ const agent = readAgentRefs(masked, current.end);
1003
+ current.agentIds = agent.ids;
1004
+ current.agentEnd = agent.end;
1005
+ const verb = current.text.match(/(superseded|amended|refined|narrowed|replaced|overridden|withdrawn)\s+by$/i)?.[1].toLowerCase();
1006
+ current.verb = verb;
1007
+ current.end = agent.end;
1008
+ if (agent.ids.length > 0 && agent.ids.every((id) => id === sourceId)) {
1009
+ current.form = 'agent-self';
1010
+ } else {
1011
+ current.restatement = true;
1012
+ }
1013
+ }
1014
+ if (current.form === 'verb') {
1015
+ const before = masked.slice(clause.start, current.start);
1016
+ current.passive = /(?:ed|en|drawn)$/i.test(current.text) && /(?<![\w-])(?:is|are|was|were|be|been|being)\s+(?:[\w-]+ly\s+)?$/i.test(before);
1017
+ }
1018
+ }
1019
+
1020
+ return resolved;
1021
+ }
1022
+
1023
+ function readAgentRefs(masked, from) {
1024
+ const tail = masked.slice(from);
1025
+ // Agents joined by "/" or a bare "and"; ", and D-…" continues the enclosing list.
1026
+ const match = tail.match(/^\s+(D-\d+(?:(?:\s*\/\s*|\s+and\s+)D-\d+)*)/);
1027
+ if (!match) return { ids: [], end: from };
1028
+ const ids = [...match[1].matchAll(/D-(\d+)/g)].map((entry) => Number(entry[1]));
1029
+ return { ids, end: from + match[0].length };
1030
+ }
1031
+
1032
+ // Modality decides what a cue may certify. Hypothetical wording (may, might,
1033
+ // could, would, can, should, to, or a conditional) certifies nothing. A
1034
+ // requirement (must, shall, will) certifies preservation — the entry requires
1035
+ // the earlier rule to hold — but not a supersession or amendment, which would
1036
+ // be a future act. Negation (not, never, cannot, …) is polarity, handled in
1037
+ // classifyCue.
1038
+ function cueModality(current, masked, clause, spanEnd) {
1039
+ const before = masked.slice(clause.start, current.start);
1040
+ // A trailing condition only counts inside the cue's own object or qualifier
1041
+ // ("supersedes D-010 if approved"), not in a later coordinated verb phrase.
1042
+ const after = masked.slice(current.end, spanEnd ?? clause.end);
1043
+ // …or opening the text right after it, where the object boundary stopped
1044
+ // ("supersedes D-010 unless the proposal is rejected").
1045
+ const tail = masked.slice(spanEnd ?? clause.end, clause.end);
1046
+ if (
1047
+ CONDITIONAL_TAIL_PATTERN.test(tail) ||
1048
+ CONDITIONAL_BEFORE_PATTERN.test(before) ||
1049
+ CONDITIONAL_OPENING_PATTERN.test(before) ||
1050
+ CONDITIONAL_AFTER_PATTERN.test(after) ||
1051
+ HYPOTHETICAL_AUXILIARY_PATTERN.test(before) ||
1052
+ HYPOTHETICAL_CUE_PATTERN.test(current.text)
1053
+ ) {
1054
+ return 'hypothetical';
1055
+ }
1056
+ if (REQUIREMENT_AUXILIARY_PATTERN.test(before) || REQUIREMENT_CUE_PATTERN.test(current.text)) {
1057
+ return 'requirement';
1058
+ }
1059
+ return 'asserted';
1060
+ }
1061
+
1062
+ function classifyCue(current, masked, clause, spanEnd) {
1063
+ const before = masked.slice(clause.start, current.start);
1064
+ const negatedWindow = NEGATION_WINDOW_PATTERN.test(before);
1065
+ const modality = cueModality(current, masked, clause, spanEnd);
1066
+ const verb = normalizeCueVerb(current);
1067
+ const uncertified = (prefix) => ({ relation: 'unknown', cue: `${prefix}:${verb}` });
1068
+
1069
+ if (current.family === 'negated') {
1070
+ return modality === 'hypothetical' ? uncertified('modal') : { relation: 'preserves', cue: verb };
1071
+ }
1072
+
1073
+ if (current.form === 'agent-self') {
1074
+ const relation = current.verb === 'superseded' ? 'supersedes' : current.verb === 'amended' ? 'amends' : 'unknown';
1075
+ if (relation === 'unknown') return { relation, cue: verb };
1076
+ // Same polarity as the active form: "D-010 cannot be superseded by
1077
+ // D-030" is prohibited change, so preservation, never a successor.
1078
+ if (negatedWindow) return modality === 'hypothetical' ? uncertified('modal') : { relation: 'preserves', cue: `negated:${verb}` };
1079
+ if (modality !== 'asserted') return uncertified('modal');
1080
+ return { relation, cue: verb };
1081
+ }
1082
+
1083
+ if (current.family === 'supersede' || current.family === 'amend') {
1084
+ const relation = current.family === 'supersede' ? 'supersedes' : 'amends';
1085
+ if (negatedWindow) return modality === 'hypothetical' ? uncertified('modal') : { relation: 'preserves', cue: `negated:${verb}` };
1086
+ if (modality !== 'asserted') return uncertified('modal');
1087
+ return { relation, cue: verb };
1088
+ }
1089
+
1090
+ if (current.family === 'preserve') {
1091
+ if (negatedWindow) return uncertified('negated');
1092
+ if (modality === 'hypothetical') return uncertified('modal');
1093
+ return { relation: 'preserves', cue: verb };
1094
+ }
1095
+
1096
+ if (current.family === 'unknown') {
1097
+ return modality === 'asserted' ? { relation: 'unknown', cue: verb } : uncertified('modal');
1098
+ }
1099
+
1100
+ return null;
1101
+ }
1102
+
1103
+ function normalizeCueVerb(current) {
1104
+ return collapse(current.text).toLowerCase();
1105
+ }
1106
+
1107
+ function objectSpan(current, clause, masked, depth, cues) {
1108
+ const level = current.depth;
1109
+ let stop = clause.end;
1110
+
1111
+ for (const other of cues) {
1112
+ if (other === current || other.restatement) continue;
1113
+ if (other.start >= current.end && other.start < stop && depth[other.start] === level) {
1114
+ stop = other.start;
1115
+ break;
1116
+ }
1117
+ }
1118
+
1119
+ for (let index = current.end; index < stop; index += 1) {
1120
+ if (depth[index] < level) {
1121
+ stop = index > current.end && masked[index - 1] === ')' ? index - 1 : index;
1122
+ break;
1123
+ }
1124
+ }
1125
+
1126
+ // Match against the rest of the clause so a terminator's lookahead can see
1127
+ // past the next cue ("… and D-125 by keeping …"); accept only matches that
1128
+ // start before the stop.
1129
+ const terminator = new RegExp(OBJECT_TERMINATOR_PATTERN.source, 'gi');
1130
+ const window = masked.slice(current.end, clause.end);
1131
+ let match;
1132
+ while ((match = terminator.exec(window)) !== null) {
1133
+ const at = current.end + match.index;
1134
+ if (at >= stop) break;
1135
+ if (depth[at] !== level) continue;
1136
+ if (insideRestatement(at, cues)) continue;
1137
+ stop = at;
1138
+ break;
1139
+ }
1140
+
1141
+ return { start: current.end, end: stop };
1142
+ }
1143
+
1144
+ function insideRestatement(offset, cues) {
1145
+ return cues.some((entry) => entry.restatement && offset >= entry.start && offset < entry.end);
1146
+ }
1147
+
1148
+ function subjectBoundary(current, clause, masked, depth, previousEnd, clauses, clauseIndex, cues, sourceId) {
1149
+ const level = current.depth;
1150
+ let start = Math.max(clause.start, previousEnd);
1151
+
1152
+ for (let index = current.start - 1; index >= start; index -= 1) {
1153
+ if (depth[index] < level) {
1154
+ start = index + 1;
1155
+ break;
1156
+ }
1157
+ }
1158
+
1159
+ const boundary = new RegExp(SUBJECT_BOUNDARY_PATTERN.source, 'gi');
1160
+ const window = masked.slice(start, current.start);
1161
+ let match;
1162
+ let cut = start;
1163
+ while ((match = boundary.exec(window)) !== null) {
1164
+ const at = start + match.index;
1165
+ if (depth[at] !== level || insideRestatement(at, cues)) continue;
1166
+ cut = at + match[0].length;
1167
+ }
1168
+
1169
+ // A list whose last item opens with "and" after semicolons ("D-300's …;
1170
+ // D-301's …; and D-305's … remain intact") carries its subject back across
1171
+ // the earlier cue-free list items.
1172
+ if (cut === clause.start && /^\s*and\s/i.test(masked.slice(clause.start, clause.end))) {
1173
+ let walk = clauseIndex - 1;
1174
+ while (walk >= 0) {
1175
+ const previous = clauses[walk];
1176
+ const separator = masked.slice(previous.end, previous.end + 1);
1177
+ if (separator !== ';' || detectCues(masked, previous, depth, sourceId).length > 0) break;
1178
+ cut = previous.start;
1179
+ walk -= 1;
1180
+ }
1181
+ }
1182
+
1183
+ return cut;
1184
+ }
1185
+
1186
+ function findLeavesTerminal(current, span, masked, depth) {
1187
+ const pattern = new RegExp(LEAVES_TERMINAL_PATTERN.source, 'gi');
1188
+ const window = masked.slice(current.end, span.end + 40);
1189
+ let match;
1190
+ while ((match = pattern.exec(window)) !== null) {
1191
+ const at = current.end + match.index;
1192
+ if (at > span.end + 1) break;
1193
+ if (depth[at] === current.depth) return at;
1194
+ }
1195
+ return null;
1196
+ }
1197
+
1198
+ // Scope: each target's own prefix/suffix inside the target span, with tightly
1199
+ // coordinated targets ("D-138's and D-194's …", "D-225/D-226", "D-191 and
1200
+ // D-193 …") sharing one scope. Parenthetical glosses are not scope.
1201
+ function computeScopes({ targets, span, masked, original, restatements, qualifier }) {
1202
+ // One unit per distinct target; a range (D-314–D-317) is a single unit
1203
+ // whose members share its scope.
1204
+ const unique = [];
1205
+ const seen = new Set();
1206
+ const members = new Map();
1207
+ for (const target of targets) {
1208
+ if (seen.has(target.id)) continue;
1209
+ seen.add(target.id);
1210
+ const unitKey = target.range ? `range:${target.range.start}` : `id:${target.id}`;
1211
+ if (!members.has(unitKey)) {
1212
+ members.set(unitKey, []);
1213
+ unique.push(target.range ? { ...target, id: unitKey, start: target.range.start, end: target.range.end } : { ...target, id: unitKey });
1214
+ }
1215
+ members.get(unitKey).push(target);
1216
+ }
1217
+
1218
+ const cutAtRestatement = (start, end) => {
1219
+ for (const restatement of restatements) {
1220
+ if (restatement.start >= start && restatement.start < end) {
1221
+ return { head: { start, end: restatement.start }, tail: { start: Math.min(restatement.end, end), end } };
1222
+ }
1223
+ }
1224
+ return { head: { start, end }, tail: null };
1225
+ };
1226
+
1227
+ const pieces = unique.map((target) => ({ target, prefix: null, suffix: null, tightNext: false }));
1228
+ const firstGap = { start: span.start, end: unique[0].start };
1229
+ pieces[0].prefix = lastListItem(firstGap, masked);
1230
+
1231
+ for (let index = 0; index < unique.length; index += 1) {
1232
+ const current = unique[index];
1233
+ const next = unique[index + 1];
1234
+ const gapEnd = next ? next.start : span.end;
1235
+ // Skip over repeated mentions of the same target ("D-315 only where D-315 …").
1236
+ const { head, tail } = cutAtRestatement(current.end, gapEnd);
1237
+
1238
+ if (!next) {
1239
+ pieces[index].suffix = head;
1240
+ continue;
1241
+ }
1242
+
1243
+ const region = tail ?? head;
1244
+ const separator = lastSeparator(region, masked);
1245
+ if (tail) {
1246
+ pieces[index].suffix = head;
1247
+ pieces[index + 1].prefix = separator ? { start: separator.end, end: region.end } : region;
1248
+ continue;
1249
+ }
1250
+
1251
+ if (!separator) {
1252
+ pieces[index].suffix = head;
1253
+ pieces[index + 1].prefix = { start: head.end, end: head.end };
1254
+ continue;
1255
+ }
1256
+
1257
+ pieces[index].suffix = { start: head.start, end: separator.start };
1258
+ pieces[index + 1].prefix = { start: separator.end, end: region.end };
1259
+ const ownSuffix = masked.slice(head.start, separator.start);
1260
+ const separatorText = masked.slice(separator.start, separator.end);
1261
+ pieces[index].tightNext =
1262
+ TIGHT_SEPARATOR_PATTERN.test(separatorText) && /^\s*(?:['’]s)?\s*$/.test(ownSuffix);
1263
+ }
1264
+
1265
+ // A bare ID list ("D-300, D-301, and D-306 controls") shares the words
1266
+ // around the whole list when no member carries its own prefix or suffix.
1267
+ // A trailing possessive ("…, and D-015's X") binds only its own ID.
1268
+ const lastSuffix = pieces[pieces.length - 1].suffix;
1269
+ const bareList =
1270
+ pieces.length > 1 &&
1271
+ !/^\s*['’]s(?![\w-])/.test(masked.slice(lastSuffix.start, lastSuffix.end)) &&
1272
+ pieces.slice(0, -1).every((piece) => /^\s*$/.test(masked.slice(piece.suffix.start, piece.suffix.end))) &&
1273
+ pieces.slice(1).every((piece) => /^\s*$/.test(masked.slice(piece.prefix.start, piece.prefix.end)));
1274
+ if (bareList) {
1275
+ for (const piece of pieces.slice(0, -1)) piece.tightNext = true;
1276
+ }
1277
+
1278
+ // Group tightly coordinated targets.
1279
+ const groups = [];
1280
+ let group = [];
1281
+ for (const piece of pieces) {
1282
+ group.push(piece);
1283
+ if (!piece.tightNext) {
1284
+ groups.push(group);
1285
+ group = [];
1286
+ }
1287
+ }
1288
+ if (group.length) groups.push(group);
1289
+
1290
+ const result = new Map();
1291
+ for (const grouped of groups) {
1292
+ const prefix = grouped[0].prefix;
1293
+ const suffix = grouped[grouped.length - 1].suffix;
1294
+ const scope = describeScope({ prefix, suffix, masked, original, qualifier });
1295
+ for (const piece of grouped) {
1296
+ const ids = new Set(members.get(piece.target.id).map((target) => target.id));
1297
+ for (const target of targets) {
1298
+ if (ids.has(target.id)) result.set(target, scope);
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ return result;
1304
+ }
1305
+
1306
+ function lastListItem(range, masked) {
1307
+ const separator = lastSeparator(range, masked);
1308
+ return separator ? { start: separator.end, end: range.end } : range;
1309
+ }
1310
+
1311
+ function lastSeparator(range, masked) {
1312
+ const text = masked.slice(range.start, range.end);
1313
+ const pattern = new RegExp(LIST_SEPARATOR_PATTERN.source, 'gi');
1314
+ let match;
1315
+ let last = null;
1316
+ let depthLevel = 0;
1317
+ const depthAt = [];
1318
+ for (let index = 0; index < text.length; index += 1) {
1319
+ depthAt.push(depthLevel);
1320
+ if (text[index] === '(') depthLevel += 1;
1321
+ else if (text[index] === ')') depthLevel = Math.max(0, depthLevel - 1);
1322
+ }
1323
+ while ((match = pattern.exec(text)) !== null) {
1324
+ if (depthAt[match.index] !== 0) continue;
1325
+ last = { start: range.start + match.index, end: range.start + match.index + match[0].length };
1326
+ }
1327
+ return last;
1328
+ }
1329
+
1330
+ function describeScope({ prefix, suffix, masked, original, qualifier }) {
1331
+ const prefixRange = trimScopeRange(prefix, masked, 'prefix');
1332
+ const suffixRange = trimScopeRange(suffix, masked, 'suffix');
1333
+ const possessive = suffix ? /^\s*['’]s\b/.test(masked.slice(suffix.start, suffix.end)) : false;
1334
+ const qualifierRange = qualifier ? trimScopeRange(qualifier, masked, 'qualifier') : null;
1335
+ const parts = [prefixRange, suffixRange.range, qualifierRange?.range]
1336
+ .filter((range) => range && range.end > range.start)
1337
+ .map((range) => collapse(original.slice(range.start, range.end)))
1338
+ .filter(Boolean);
1339
+ const text = parts.length ? boundText(parts.join(' '), SCOPE_LIMIT) : null;
1340
+ const suffixText = suffixRange.range ? masked.slice(suffixRange.range.start, suffixRange.range.end) : '';
1341
+ const qualifierText = qualifierRange?.range ? masked.slice(qualifierRange.range.start, qualifierRange.range.end) : '';
1342
+
1343
+ return {
1344
+ text,
1345
+ possessive,
1346
+ fullWording: FULL_EXTENT_PATTERN.test(suffixText.trim()) || FULL_EXTENT_PATTERN.test(qualifierText.trim()),
1347
+ partialWording:
1348
+ PARTIAL_EXTENT_PATTERN.test(suffixText) ||
1349
+ PARTIAL_EXTENT_PATTERN.test(qualifierText) ||
1350
+ (prefixRange ? PARTIAL_EXTENT_PATTERN.test(masked.slice(prefixRange.start, prefixRange.end)) : false),
1351
+ };
1352
+ }
1353
+
1354
+ function trimScopeRange(range, masked, kind) {
1355
+ const empty = kind === 'prefix' ? null : { range: null };
1356
+ if (!range || range.end <= range.start) {
1357
+ return empty;
1358
+ }
1359
+
1360
+ let { start, end } = range;
1361
+ const text = () => masked.slice(start, end);
1362
+ const skipLeading = (pattern) => {
1363
+ const match = text().match(pattern);
1364
+ if (match) start += match[0].length;
1365
+ };
1366
+ const skipTrailing = (pattern) => {
1367
+ const match = text().match(pattern);
1368
+ if (match) end -= match[0].length;
1369
+ };
1370
+ const trimEdges = () => {
1371
+ skipLeading(/^[\s,.:;)]+/);
1372
+ skipTrailing(/[\s,.:;(]+$/);
1373
+ };
1374
+
1375
+ if (kind !== 'prefix') {
1376
+ // A leading possessive and a parenthetical gloss right after the ID are
1377
+ // not scope ("D-291 (in-transaction projection)" names the decision).
1378
+ skipLeading(/^\s*['’]s(?![\w-])/);
1379
+ skipLeading(/^\s*\([^)]*\)/);
1380
+ }
1381
+ trimEdges();
1382
+
1383
+ if (kind === 'prefix') {
1384
+ skipLeading(/^(?:the|a|an|its|this|these|those|only)(?:\s+|$)/i);
1385
+ skipTrailing(/(?:^|\s+)(?:of|in|from|under|for|to)$/i);
1386
+ } else {
1387
+ // Keep "only where …" qualifiers; drop a bare "only" and a manner "by
1388
+ // this decision".
1389
+ if (!/^only\s+(?:where|insofar|to|for|in|as|when)(?![\w-])/i.test(text())) skipLeading(/^only(?:\s+|$)/i);
1390
+ skipLeading(/^by\s+this\s+(?:decision|entry)(?![\w-])/i);
1391
+ skipLeading(/^(?:(?:and|but)\s+)?(?:(?:is|are)\s+)?(?:kept|retained|preserved)\s+(?:only\s+)?(?:for|as)\s+(?:a\s+|the\s+)?historical\s+(?:reference|record|context)(?![\w-])/i);
1392
+ // "keeps D-321 and D-322 intact": the state word is the verb's complement, not a part.
1393
+ skipTrailing(/(?:^|\s+)(?:intact|untouched|unchanged|in\s+place|as\s+is)$/i);
1394
+ // "D-040 cannot be superseded": auxiliaries and negators before a passive
1395
+ // or predicate verb belong to the verb, not to the subject's scope.
1396
+ let previousEnd;
1397
+ do {
1398
+ previousEnd = end;
1399
+ skipTrailing(/(?:^|\s+)(?:cannot|[a-z]+n['’]t|not|never|longer|no|may|might|must|will|shall|should|would|could|can|also|still|now|is|are|was|were|be|been|being)$/i);
1400
+ } while (end !== previousEnd && end > start);
1401
+ skipTrailing(/(?:^|\s+)(?:and|or|plus|also)$/i);
1402
+ }
1403
+ trimEdges();
1404
+
1405
+ if (end <= start) return empty;
1406
+ return kind === 'prefix' ? { start, end } : { range: { start, end } };
1407
+ }
1408
+
1409
+ // Explicit wording wins: partial, then full, then a named part. Keeping a
1410
+ // superseded entry "for historical reference" only upgrades an otherwise
1411
+ // unqualified supersession to full; it never overrides a partial or scoped
1412
+ // qualifier (retention is the append-only norm, not an extent claim).
1413
+ function resolveExtent({ classification, scopeInfo, clauseAfterCue, current }) {
1414
+ const modifier = current.text.toLowerCase();
1415
+ if (/(?<![\w-])(?:partially|partly)(?![\w-])/.test(modifier) || scopeInfo.partialWording) {
1416
+ return 'partial';
1417
+ }
1418
+ if (/(?<![\w-])(?:fully|comprehensively)(?![\w-])/.test(modifier) || scopeInfo.fullWording) {
1419
+ return 'full';
1420
+ }
1421
+ if (current.id === 'supersession-of' && current.modifier && !/^(?:a|an|the|its|this|full)$/i.test(current.modifier)) {
1422
+ scopeInfo.text = scopeInfo.text ?? current.modifier;
1423
+ return 'scoped';
1424
+ }
1425
+ if (scopeInfo.text) {
1426
+ return 'scoped';
1427
+ }
1428
+ if (classification.relation === 'supersedes' && HISTORICAL_RETENTION_PATTERN.test(clauseAfterCue)) {
1429
+ return 'full';
1430
+ }
1431
+ return 'unqualified';
1432
+ }
1433
+
1434
+ // ---------------------------------------------------------------------------
1435
+ // Text helpers
1436
+ // ---------------------------------------------------------------------------
1437
+
1438
+ /**
1439
+ * Blanks (same length, newlines kept) text whose wording must never become a
1440
+ * lineage declaration: fenced code, blockquotes, code spans, double-quoted
1441
+ * prose, and link targets.
1442
+ */
1443
+ export function maskForLineage(text) {
1444
+ const chars = text.split(''); // UTF-16 units, so offsets stay aligned with the original
1445
+ const blank = (start, end) => {
1446
+ for (let index = start; index < end; index += 1) {
1447
+ if (chars[index] !== '\n') chars[index] = ' ';
1448
+ }
1449
+ };
1450
+ const current = () => chars.join('');
1451
+
1452
+ for (const [start, end] of findFencedRanges(text)) blank(start, end);
1453
+ for (const match of current().matchAll(/^[ \t]*>.*$/gm)) blank(match.index, match.index + match[0].length);
1454
+ for (const match of current().matchAll(/``[^\n]*?``|`[^`\n]*`/g)) blank(match.index, match.index + match[0].length);
1455
+ for (const match of current().matchAll(/\]\(([^)\s]*)\)/g)) blank(match.index + 2, match.index + match[0].length - 1);
1456
+ for (const match of current().matchAll(/"[^"\n]{0,400}"|“[^”\n]{0,400}”/g)) blank(match.index, match.index + match[0].length);
1457
+
1458
+ return current();
1459
+ }
1460
+
1461
+ // Blank-line checks read the original text: a line that is blank only after
1462
+ // masking (a line-leading code span) is not a paragraph break.
1463
+ function nextLineOf(text, newlineIndex) {
1464
+ const end = text.indexOf('\n', newlineIndex + 1);
1465
+ return end === -1 ? null : text.slice(newlineIndex + 1, end);
1466
+ }
1467
+
1468
+ // [start, end) offsets of fenced code blocks (``` or ~~~, CommonMark-style
1469
+ // closing rule), fence lines included; an unclosed fence runs to the end.
1470
+ export function findFencedRanges(text) {
1471
+ const ranges = [];
1472
+ let fence = null;
1473
+ let offset = 0;
1474
+ for (const line of text.split('\n')) {
1475
+ const lineStart = offset;
1476
+ offset += line.length + 1;
1477
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
1478
+ if (fence) {
1479
+ // A closing fence holds only the opener's character, so ```~~~ never closes.
1480
+ if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && (fence.char === '`' ? /^ {0,3}`+\s*$/ : /^ {0,3}~+\s*$/).test(line)) {
1481
+ ranges.push([fence.start, Math.min(offset - 1, text.length)]);
1482
+ fence = null;
1483
+ }
1484
+ continue;
1485
+ }
1486
+ if (marker && !(marker[1][0] === '`' && line.slice(line.indexOf(marker[1]) + marker[1].length).includes('`'))) {
1487
+ fence = { char: marker[1][0], length: marker[1].length, start: lineStart };
1488
+ }
1489
+ }
1490
+ if (fence) ranges.push([fence.start, text.length]);
1491
+ return ranges;
1492
+ }
1493
+
1494
+ function insideRanges(offset, ranges) {
1495
+ return ranges.some(([start, end]) => offset >= start && offset < end);
1496
+ }
1497
+
1498
+ function computeParenDepth(text, original) {
1499
+ const depth = new Array(text.length + 1).fill(0);
1500
+ let level = 0;
1501
+ for (let index = 0; index < text.length; index += 1) {
1502
+ depth[index] = level;
1503
+ const char = text[index];
1504
+ if (char === '(') level += 1;
1505
+ else if (char === ')') level = Math.max(0, level - 1);
1506
+ else if (char === '\n' && nextLineOf(original, index)?.trim() === '') level = 0;
1507
+ }
1508
+ depth[text.length] = level;
1509
+ return depth;
1510
+ }
1511
+
1512
+ function splitClauses(masked, depth, original) {
1513
+ const clauses = [];
1514
+ let start = 0;
1515
+ const push = (end) => {
1516
+ let from = start;
1517
+ while (from < end && /\s/.test(masked[from])) from += 1;
1518
+ if (end > from) clauses.push({ start: from, end });
1519
+ };
1520
+
1521
+ for (let index = 0; index < masked.length; index += 1) {
1522
+ const char = masked[index];
1523
+ if (char === '\n') {
1524
+ const nextLine = nextLineOf(original, index) ?? original.slice(index + 1);
1525
+ if (nextLine.trim() === '' || /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/.test(nextLine)) {
1526
+ push(index);
1527
+ start = index + 1;
1528
+ }
1529
+ continue;
1530
+ }
1531
+ if (depth[index] > 0) continue;
1532
+ if (char === ';') {
1533
+ push(index);
1534
+ start = index + 1;
1535
+ continue;
1536
+ }
1537
+ if (char === '.' || char === '!' || char === '?') {
1538
+ const rest = masked.slice(index + 1, index + 8);
1539
+ if (rest.trim() === '' && index + 1 + rest.length >= masked.length) {
1540
+ push(index);
1541
+ start = index + 1;
1542
+ } else if (/^\s+(?:[A-Z0-9(*"“`[]|D-)/.test(rest)) {
1543
+ push(index);
1544
+ start = index + 1;
1545
+ }
1546
+ }
1547
+ }
1548
+ push(masked.length);
1549
+ return clauses;
1550
+ }
1551
+
1552
+ function splitAtDepthZero(text, separator) {
1553
+ const segments = [];
1554
+ let level = 0;
1555
+ let start = 0;
1556
+ for (let index = 0; index < text.length; index += 1) {
1557
+ const char = text[index];
1558
+ if (char === '(') level += 1;
1559
+ else if (char === ')') level = Math.max(0, level - 1);
1560
+ else if (char === separator && level === 0) {
1561
+ segments.push({ start, text: text.slice(start, index) });
1562
+ start = index + 1;
1563
+ }
1564
+ }
1565
+ segments.push({ start, text: text.slice(start) });
1566
+ return segments;
1567
+ }
1568
+
1569
+ function buildSectionIndex(content, bodyStart) {
1570
+ const headings = [];
1571
+ let fence = null;
1572
+ let offset = 0;
1573
+ let lineNumber = 0;
1574
+
1575
+ for (const line of content.split('\n')) {
1576
+ const lineStart = offset;
1577
+ offset += line.length + 1;
1578
+ lineNumber += 1;
1579
+ if (lineStart < bodyStart) continue;
1580
+
1581
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
1582
+ if (fence) {
1583
+ if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
1584
+ fence = null;
1585
+ }
1586
+ continue;
1587
+ }
1588
+ if (fenceMatch) {
1589
+ fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
1590
+ continue;
1591
+ }
1592
+
1593
+ const heading = line.match(/^(#{2,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*\r?$/);
1594
+ if (heading) {
1595
+ headings.push({ level: heading[1].length, text: heading[2].trim(), start: lineStart, line: lineNumber });
1596
+ }
1597
+ }
1598
+
1599
+ const stack = [];
1600
+ for (const [index, heading] of headings.entries()) {
1601
+ while (stack.length && stack[stack.length - 1].level >= heading.level) stack.pop();
1602
+ heading.path = [...stack.map((entry) => entry.text), heading.text].join(' > ');
1603
+ stack.push(heading);
1604
+ heading.end = content.length;
1605
+ for (let next = index + 1; next < headings.length; next += 1) {
1606
+ if (headings[next].level <= heading.level) {
1607
+ heading.end = headings[next].start;
1608
+ break;
1609
+ }
1610
+ }
1611
+ heading.hash = sha256Text(content.slice(heading.start, heading.end).trimEnd());
1612
+ }
1613
+
1614
+ const preambleEnd = headings[0]?.start ?? content.length;
1615
+ return {
1616
+ headings,
1617
+ preamble: { path: null, hash: sha256Text(content.slice(bodyStart, preambleEnd).trimEnd()) },
1618
+ };
1619
+ }
1620
+
1621
+ function sectionAt(sections, offset) {
1622
+ let low = 0;
1623
+ let high = sections.headings.length - 1;
1624
+ let found = null;
1625
+ while (low <= high) {
1626
+ const middle = (low + high) >> 1;
1627
+ if (sections.headings[middle].start <= offset) {
1628
+ found = sections.headings[middle];
1629
+ low = middle + 1;
1630
+ } else {
1631
+ high = middle - 1;
1632
+ }
1633
+ }
1634
+ return found ?? sections.preamble;
1635
+ }
1636
+
1637
+ function buildLineStarts(text) {
1638
+ const starts = [0];
1639
+ for (let index = 0; index < text.length; index += 1) {
1640
+ if (text[index] === '\n') starts.push(index + 1);
1641
+ }
1642
+ return starts;
1643
+ }
1644
+
1645
+ function lineAt(starts, offset) {
1646
+ let low = 0;
1647
+ let high = starts.length - 1;
1648
+ while (low < high) {
1649
+ const middle = (low + high + 1) >> 1;
1650
+ if (starts[middle] <= offset) low = middle;
1651
+ else high = middle - 1;
1652
+ }
1653
+ return low + 1;
1654
+ }
1655
+
1656
+ function createRelation(fields) {
1657
+ return {
1658
+ relation: fields.relation,
1659
+ source_kind: fields.source_kind,
1660
+ source_path: fields.source_path,
1661
+ source_decision_id: fields.source_decision_id ?? null,
1662
+ source_section: fields.source_section ?? null,
1663
+ source_field: fields.source_field ?? null,
1664
+ source_line: fields.source_line,
1665
+ source_hash: fields.source_hash,
1666
+ section_hash: fields.section_hash,
1667
+ target_decision_id: fields.target_decision_id,
1668
+ evidence: fields.evidence,
1669
+ basis: fields.basis,
1670
+ extent: fields.extent ?? null,
1671
+ scope: fields.scope ?? null,
1672
+ cue: fields.cue ?? null,
1673
+ excerpt: fields.excerpt ?? null,
1674
+ occurrences: fields.occurrences ?? 1,
1675
+ };
1676
+ }
1677
+
1678
+ export function sortRelations(relations) {
1679
+ return [...relations].sort(
1680
+ (left, right) =>
1681
+ left.source_path.localeCompare(right.source_path) ||
1682
+ (left.source_decision_id ?? 0) - (right.source_decision_id ?? 0) ||
1683
+ left.source_line - right.source_line ||
1684
+ left.target_decision_id - right.target_decision_id ||
1685
+ RELATION_ORDER.get(left.relation) - RELATION_ORDER.get(right.relation) ||
1686
+ String(left.cue ?? '').localeCompare(String(right.cue ?? '')),
1687
+ );
1688
+ }
1689
+
1690
+ function sortDiagnostics(diagnostics) {
1691
+ return [...diagnostics].sort(
1692
+ (left, right) =>
1693
+ left.path.localeCompare(right.path) || left.line - right.line || left.code.localeCompare(right.code),
1694
+ );
1695
+ }
1696
+
1697
+ // Canonical decision IDs: three or more digits, no extra leading zero, and a
1698
+ // positive safe integer (so range arithmetic stays exact).
1699
+ function toDecisionId(digits) {
1700
+ if (digits.length < 3 || (digits.length > 3 && digits[0] === '0')) {
1701
+ return null;
1702
+ }
1703
+ const id = Number(digits);
1704
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
1705
+ }
1706
+
1707
+ function collapse(value) {
1708
+ return value.replace(/\s+/g, ' ').trim();
1709
+ }
1710
+
1711
+ function boundText(value, limit) {
1712
+ const points = Array.from(value);
1713
+ return points.length <= limit ? value : `${points.slice(0, limit - 1).join('')}…`;
1714
+ }
1715
+
1716
+ function pad(id) {
1717
+ return String(id).padStart(3, '0');
1718
+ }