@vibecompass/vibecompass 0.15.0 → 0.16.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.
- package/CHANGELOG.md +118 -0
- package/README.md +71 -0
- package/package.json +1 -1
- package/src/brief-keywords.js +203 -0
- package/src/brief-lifecycle.js +638 -0
- package/src/brief-render.js +341 -0
- package/src/brief-settings.js +160 -0
- package/src/brief.js +1557 -0
- package/src/cli.js +269 -3
- package/src/decision-lineage.js +1717 -0
- package/src/generators/agent-files/template.js +2 -1
- package/src/index.js +34 -0
- package/src/project-memory.js +30 -4
- package/src/read-model.js +92 -2
- package/src/scaffold.js +10 -2
- package/src/session.js +30 -0
|
@@ -0,0 +1,1717 @@
|
|
|
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
|
+
if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
|
|
1480
|
+
ranges.push([fence.start, Math.min(offset - 1, text.length)]);
|
|
1481
|
+
fence = null;
|
|
1482
|
+
}
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
if (marker && !(marker[1][0] === '`' && line.slice(line.indexOf(marker[1]) + marker[1].length).includes('`'))) {
|
|
1486
|
+
fence = { char: marker[1][0], length: marker[1].length, start: lineStart };
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (fence) ranges.push([fence.start, text.length]);
|
|
1490
|
+
return ranges;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function insideRanges(offset, ranges) {
|
|
1494
|
+
return ranges.some(([start, end]) => offset >= start && offset < end);
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function computeParenDepth(text, original) {
|
|
1498
|
+
const depth = new Array(text.length + 1).fill(0);
|
|
1499
|
+
let level = 0;
|
|
1500
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1501
|
+
depth[index] = level;
|
|
1502
|
+
const char = text[index];
|
|
1503
|
+
if (char === '(') level += 1;
|
|
1504
|
+
else if (char === ')') level = Math.max(0, level - 1);
|
|
1505
|
+
else if (char === '\n' && nextLineOf(original, index)?.trim() === '') level = 0;
|
|
1506
|
+
}
|
|
1507
|
+
depth[text.length] = level;
|
|
1508
|
+
return depth;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function splitClauses(masked, depth, original) {
|
|
1512
|
+
const clauses = [];
|
|
1513
|
+
let start = 0;
|
|
1514
|
+
const push = (end) => {
|
|
1515
|
+
let from = start;
|
|
1516
|
+
while (from < end && /\s/.test(masked[from])) from += 1;
|
|
1517
|
+
if (end > from) clauses.push({ start: from, end });
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
for (let index = 0; index < masked.length; index += 1) {
|
|
1521
|
+
const char = masked[index];
|
|
1522
|
+
if (char === '\n') {
|
|
1523
|
+
const nextLine = nextLineOf(original, index) ?? original.slice(index + 1);
|
|
1524
|
+
if (nextLine.trim() === '' || /^[ \t]*(?:[-*+]|\d+[.)])[ \t]+/.test(nextLine)) {
|
|
1525
|
+
push(index);
|
|
1526
|
+
start = index + 1;
|
|
1527
|
+
}
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
if (depth[index] > 0) continue;
|
|
1531
|
+
if (char === ';') {
|
|
1532
|
+
push(index);
|
|
1533
|
+
start = index + 1;
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (char === '.' || char === '!' || char === '?') {
|
|
1537
|
+
const rest = masked.slice(index + 1, index + 8);
|
|
1538
|
+
if (rest.trim() === '' && index + 1 + rest.length >= masked.length) {
|
|
1539
|
+
push(index);
|
|
1540
|
+
start = index + 1;
|
|
1541
|
+
} else if (/^\s+(?:[A-Z0-9(*"“`[]|D-)/.test(rest)) {
|
|
1542
|
+
push(index);
|
|
1543
|
+
start = index + 1;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
push(masked.length);
|
|
1548
|
+
return clauses;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function splitAtDepthZero(text, separator) {
|
|
1552
|
+
const segments = [];
|
|
1553
|
+
let level = 0;
|
|
1554
|
+
let start = 0;
|
|
1555
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1556
|
+
const char = text[index];
|
|
1557
|
+
if (char === '(') level += 1;
|
|
1558
|
+
else if (char === ')') level = Math.max(0, level - 1);
|
|
1559
|
+
else if (char === separator && level === 0) {
|
|
1560
|
+
segments.push({ start, text: text.slice(start, index) });
|
|
1561
|
+
start = index + 1;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
segments.push({ start, text: text.slice(start) });
|
|
1565
|
+
return segments;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function buildSectionIndex(content, bodyStart) {
|
|
1569
|
+
const headings = [];
|
|
1570
|
+
let fence = null;
|
|
1571
|
+
let offset = 0;
|
|
1572
|
+
let lineNumber = 0;
|
|
1573
|
+
|
|
1574
|
+
for (const line of content.split('\n')) {
|
|
1575
|
+
const lineStart = offset;
|
|
1576
|
+
offset += line.length + 1;
|
|
1577
|
+
lineNumber += 1;
|
|
1578
|
+
if (lineStart < bodyStart) continue;
|
|
1579
|
+
|
|
1580
|
+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
1581
|
+
if (fence) {
|
|
1582
|
+
if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
|
|
1583
|
+
fence = null;
|
|
1584
|
+
}
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
if (fenceMatch) {
|
|
1588
|
+
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
1589
|
+
continue;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const heading = line.match(/^(#{2,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*\r?$/);
|
|
1593
|
+
if (heading) {
|
|
1594
|
+
headings.push({ level: heading[1].length, text: heading[2].trim(), start: lineStart, line: lineNumber });
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
const stack = [];
|
|
1599
|
+
for (const [index, heading] of headings.entries()) {
|
|
1600
|
+
while (stack.length && stack[stack.length - 1].level >= heading.level) stack.pop();
|
|
1601
|
+
heading.path = [...stack.map((entry) => entry.text), heading.text].join(' > ');
|
|
1602
|
+
stack.push(heading);
|
|
1603
|
+
heading.end = content.length;
|
|
1604
|
+
for (let next = index + 1; next < headings.length; next += 1) {
|
|
1605
|
+
if (headings[next].level <= heading.level) {
|
|
1606
|
+
heading.end = headings[next].start;
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
heading.hash = sha256Text(content.slice(heading.start, heading.end).trimEnd());
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const preambleEnd = headings[0]?.start ?? content.length;
|
|
1614
|
+
return {
|
|
1615
|
+
headings,
|
|
1616
|
+
preamble: { path: null, hash: sha256Text(content.slice(bodyStart, preambleEnd).trimEnd()) },
|
|
1617
|
+
};
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
function sectionAt(sections, offset) {
|
|
1621
|
+
let low = 0;
|
|
1622
|
+
let high = sections.headings.length - 1;
|
|
1623
|
+
let found = null;
|
|
1624
|
+
while (low <= high) {
|
|
1625
|
+
const middle = (low + high) >> 1;
|
|
1626
|
+
if (sections.headings[middle].start <= offset) {
|
|
1627
|
+
found = sections.headings[middle];
|
|
1628
|
+
low = middle + 1;
|
|
1629
|
+
} else {
|
|
1630
|
+
high = middle - 1;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return found ?? sections.preamble;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function buildLineStarts(text) {
|
|
1637
|
+
const starts = [0];
|
|
1638
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
1639
|
+
if (text[index] === '\n') starts.push(index + 1);
|
|
1640
|
+
}
|
|
1641
|
+
return starts;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function lineAt(starts, offset) {
|
|
1645
|
+
let low = 0;
|
|
1646
|
+
let high = starts.length - 1;
|
|
1647
|
+
while (low < high) {
|
|
1648
|
+
const middle = (low + high + 1) >> 1;
|
|
1649
|
+
if (starts[middle] <= offset) low = middle;
|
|
1650
|
+
else high = middle - 1;
|
|
1651
|
+
}
|
|
1652
|
+
return low + 1;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function createRelation(fields) {
|
|
1656
|
+
return {
|
|
1657
|
+
relation: fields.relation,
|
|
1658
|
+
source_kind: fields.source_kind,
|
|
1659
|
+
source_path: fields.source_path,
|
|
1660
|
+
source_decision_id: fields.source_decision_id ?? null,
|
|
1661
|
+
source_section: fields.source_section ?? null,
|
|
1662
|
+
source_field: fields.source_field ?? null,
|
|
1663
|
+
source_line: fields.source_line,
|
|
1664
|
+
source_hash: fields.source_hash,
|
|
1665
|
+
section_hash: fields.section_hash,
|
|
1666
|
+
target_decision_id: fields.target_decision_id,
|
|
1667
|
+
evidence: fields.evidence,
|
|
1668
|
+
basis: fields.basis,
|
|
1669
|
+
extent: fields.extent ?? null,
|
|
1670
|
+
scope: fields.scope ?? null,
|
|
1671
|
+
cue: fields.cue ?? null,
|
|
1672
|
+
excerpt: fields.excerpt ?? null,
|
|
1673
|
+
occurrences: fields.occurrences ?? 1,
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
export function sortRelations(relations) {
|
|
1678
|
+
return [...relations].sort(
|
|
1679
|
+
(left, right) =>
|
|
1680
|
+
left.source_path.localeCompare(right.source_path) ||
|
|
1681
|
+
(left.source_decision_id ?? 0) - (right.source_decision_id ?? 0) ||
|
|
1682
|
+
left.source_line - right.source_line ||
|
|
1683
|
+
left.target_decision_id - right.target_decision_id ||
|
|
1684
|
+
RELATION_ORDER.get(left.relation) - RELATION_ORDER.get(right.relation) ||
|
|
1685
|
+
String(left.cue ?? '').localeCompare(String(right.cue ?? '')),
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
function sortDiagnostics(diagnostics) {
|
|
1690
|
+
return [...diagnostics].sort(
|
|
1691
|
+
(left, right) =>
|
|
1692
|
+
left.path.localeCompare(right.path) || left.line - right.line || left.code.localeCompare(right.code),
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Canonical decision IDs: three or more digits, no extra leading zero, and a
|
|
1697
|
+
// positive safe integer (so range arithmetic stays exact).
|
|
1698
|
+
function toDecisionId(digits) {
|
|
1699
|
+
if (digits.length < 3 || (digits.length > 3 && digits[0] === '0')) {
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
const id = Number(digits);
|
|
1703
|
+
return Number.isSafeInteger(id) && id > 0 ? id : null;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
function collapse(value) {
|
|
1707
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
function boundText(value, limit) {
|
|
1711
|
+
const points = Array.from(value);
|
|
1712
|
+
return points.length <= limit ? value : `${points.slice(0, limit - 1).join('')}…`;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function pad(id) {
|
|
1716
|
+
return String(id).padStart(3, '0');
|
|
1717
|
+
}
|