@descent-vtt/spec-graph 0.2.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/LICENSE +21 -0
- package/README.md +627 -0
- package/bin/spec-graph.js +24 -0
- package/dist/baseline.d.ts +93 -0
- package/dist/baseline.js +187 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cli.d.ts +66 -0
- package/dist/cli.js +565 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +66 -0
- package/dist/config.js +183 -0
- package/dist/config.js.map +1 -0
- package/dist/directives.d.ts +56 -0
- package/dist/directives.js +125 -0
- package/dist/directives.js.map +1 -0
- package/dist/extract.d.ts +116 -0
- package/dist/extract.js +1043 -0
- package/dist/extract.js.map +1 -0
- package/dist/glob.d.ts +73 -0
- package/dist/glob.js +284 -0
- package/dist/glob.js.map +1 -0
- package/dist/graph.d.ts +55 -0
- package/dist/graph.js +201 -0
- package/dist/graph.js.map +1 -0
- package/dist/identity.d.ts +87 -0
- package/dist/identity.js +294 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +57 -0
- package/dist/lifecycle.js +258 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/markdown.d.ts +152 -0
- package/dist/markdown.js +820 -0
- package/dist/markdown.js.map +1 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.js +58 -0
- package/dist/paths.js.map +1 -0
- package/dist/report.d.ts +89 -0
- package/dist/report.js +326 -0
- package/dist/report.js.map +1 -0
- package/dist/resolve.d.ts +61 -0
- package/dist/resolve.js +340 -0
- package/dist/resolve.js.map +1 -0
- package/dist/rules.d.ts +61 -0
- package/dist/rules.js +557 -0
- package/dist/rules.js.map +1 -0
- package/dist/runner.d.ts +108 -0
- package/dist/runner.js +186 -0
- package/dist/runner.js.map +1 -0
- package/dist/sections.d.ts +94 -0
- package/dist/sections.js +372 -0
- package/dist/sections.js.map +1 -0
- package/dist/select.d.ts +96 -0
- package/dist/select.js +413 -0
- package/dist/select.js.map +1 -0
- package/dist/source.d.ts +43 -0
- package/dist/source.js +109 -0
- package/dist/source.js.map +1 -0
- package/dist/state.d.ts +45 -0
- package/dist/state.js +330 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +294 -0
- package/dist/types.js +52 -0
- package/dist/types.js.map +1 -0
- package/dist/yaml.d.ts +37 -0
- package/dist/yaml.js +240 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +71 -0
package/dist/extract.js
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document extraction: Markdown in, graph fragments out.
|
|
3
|
+
*
|
|
4
|
+
* This module is where domain-agnosticism is won or lost. It never requires a
|
|
5
|
+
* repository to adopt a syntax. It reads what teams already write - front
|
|
6
|
+
* matter, a `## Status` section, a checklist under `## Open Questions`, a link
|
|
7
|
+
* that says "deferred to ADR-0011" - and turns each into a typed relation with a
|
|
8
|
+
* source position.
|
|
9
|
+
*
|
|
10
|
+
* The one rule it holds to everywhere: **an inference that cannot be justified
|
|
11
|
+
* is not made.** An unrecognised status becomes `unknown`, not a guess. A link
|
|
12
|
+
* with no governing verb becomes a neutral `references`, not a dependency. Bare
|
|
13
|
+
* identifiers found in prose are opportunistic and are dropped silently when
|
|
14
|
+
* they do not resolve, because the alternative is reporting `SHA-256` as a
|
|
15
|
+
* broken reference to specification 256.
|
|
16
|
+
*/
|
|
17
|
+
import { attr, attrList, directiveFor, parseDirectives } from './directives.js';
|
|
18
|
+
import { identify, isExternal, normaliseRef, ID_KEYS } from './identity.js';
|
|
19
|
+
import { isStatusHeading, phaseFromPath, phaseOf, STATUS_KEYS, supersessionTargetsIn } from './lifecycle.js';
|
|
20
|
+
import { scanMarkdown, slugify } from './markdown.js';
|
|
21
|
+
import { findSpecificationRegions, regionAt } from './sections.js';
|
|
22
|
+
import { resolveItemState } from './state.js';
|
|
23
|
+
import { refOf } from './source.js';
|
|
24
|
+
import { parseFrontMatter, toRecord, valuesOf } from './yaml.js';
|
|
25
|
+
/* -------------------------------------------------------------------------- */
|
|
26
|
+
/* Vocabulary */
|
|
27
|
+
/* -------------------------------------------------------------------------- */
|
|
28
|
+
/** Front-matter keys that declare a relation, and what they mean. */
|
|
29
|
+
const RELATION_KEYS = {
|
|
30
|
+
supersedes: { kind: 'supersedes', inverted: false },
|
|
31
|
+
supercedes: { kind: 'supersedes', inverted: false },
|
|
32
|
+
replaces: { kind: 'supersedes', inverted: false },
|
|
33
|
+
obsoletes: { kind: 'supersedes', inverted: false },
|
|
34
|
+
'superseded-by': { kind: 'supersedes', inverted: true },
|
|
35
|
+
'superceded-by': { kind: 'supersedes', inverted: true },
|
|
36
|
+
'replaced-by': { kind: 'supersedes', inverted: true },
|
|
37
|
+
'obsoleted-by': { kind: 'supersedes', inverted: true },
|
|
38
|
+
amends: { kind: 'amends', inverted: false },
|
|
39
|
+
extends: { kind: 'amends', inverted: false },
|
|
40
|
+
'amended-by': { kind: 'amends', inverted: true },
|
|
41
|
+
'depends-on': { kind: 'depends-on', inverted: false },
|
|
42
|
+
dependencies: { kind: 'depends-on', inverted: false },
|
|
43
|
+
requires: { kind: 'depends-on', inverted: false },
|
|
44
|
+
assumes: { kind: 'assumes', inverted: false },
|
|
45
|
+
'blocked-by': { kind: 'blocked-by', inverted: false },
|
|
46
|
+
blocks: { kind: 'blocked-by', inverted: true },
|
|
47
|
+
'delegates-to': { kind: 'delegates-to', inverted: false },
|
|
48
|
+
'delegated-to': { kind: 'delegates-to', inverted: false },
|
|
49
|
+
'tracked-in': { kind: 'delegates-to', inverted: false },
|
|
50
|
+
related: { kind: 'relates-to', inverted: false },
|
|
51
|
+
'relates-to': { kind: 'relates-to', inverted: false },
|
|
52
|
+
'see-also': { kind: 'relates-to', inverted: false },
|
|
53
|
+
references: { kind: 'references', inverted: false },
|
|
54
|
+
refs: { kind: 'references', inverted: false },
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Phrases that give a link its meaning, longest-matching-nearest wins.
|
|
58
|
+
*
|
|
59
|
+
* Only phrases that genuinely commit the citing document are here. Vague ones
|
|
60
|
+
* ("see", "covered by") stay out: a `references` edge is harmless, whereas a
|
|
61
|
+
* wrongly inferred `assumes` edge produces a confident stale-premise finding
|
|
62
|
+
* about a document that never depended on anything.
|
|
63
|
+
*/
|
|
64
|
+
const VERB_RULES = [
|
|
65
|
+
{
|
|
66
|
+
kind: 'supersedes',
|
|
67
|
+
inverted: true,
|
|
68
|
+
phrases: ['superseded by', 'superceded by', 'replaced by', 'obsoleted by', 'deprecated by', 'rolled into'],
|
|
69
|
+
},
|
|
70
|
+
{ kind: 'supersedes', inverted: false, phrases: ['supersedes', 'supercedes', 'replaces', 'obsoletes', 'deprecates'] },
|
|
71
|
+
{ kind: 'amends', inverted: true, phrases: ['amended by', 'refined by', 'clarified by', 'revised by'] },
|
|
72
|
+
{ kind: 'amends', inverted: false, phrases: ['amends', 'refines', 'clarifies', 'revises'] },
|
|
73
|
+
{
|
|
74
|
+
kind: 'delegates-to',
|
|
75
|
+
inverted: false,
|
|
76
|
+
phrases: [
|
|
77
|
+
'delegated to',
|
|
78
|
+
'delegate to',
|
|
79
|
+
'deferred to',
|
|
80
|
+
'defer to',
|
|
81
|
+
'moved to',
|
|
82
|
+
'move to',
|
|
83
|
+
'tracked in',
|
|
84
|
+
'tracked by',
|
|
85
|
+
'handed off to',
|
|
86
|
+
'handed to',
|
|
87
|
+
'hand off to',
|
|
88
|
+
'follow up in',
|
|
89
|
+
'follow-up in',
|
|
90
|
+
'followup in',
|
|
91
|
+
'continued in',
|
|
92
|
+
'continues in',
|
|
93
|
+
'punted to',
|
|
94
|
+
'left to',
|
|
95
|
+
'belongs in',
|
|
96
|
+
'owned by',
|
|
97
|
+
'will be decided in',
|
|
98
|
+
'will be resolved in',
|
|
99
|
+
'will be handled in',
|
|
100
|
+
'will be handled by',
|
|
101
|
+
'will be answered in',
|
|
102
|
+
'to be decided in',
|
|
103
|
+
'to be resolved in',
|
|
104
|
+
'to be handled in',
|
|
105
|
+
'to be answered in',
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
kind: 'blocked-by',
|
|
110
|
+
inverted: false,
|
|
111
|
+
phrases: ['blocked by', 'blocked on', 'waiting on', 'waiting for', 'gated on', 'gated by', 'unblocked by'],
|
|
112
|
+
},
|
|
113
|
+
{ kind: 'blocked-by', inverted: true, phrases: ['blocks', 'blocking'] },
|
|
114
|
+
{
|
|
115
|
+
kind: 'depends-on',
|
|
116
|
+
inverted: false,
|
|
117
|
+
phrases: ['depends on', 'depend on', 'dependent on', 'requires', 'builds on', 'built on', 'relies on'],
|
|
118
|
+
},
|
|
119
|
+
{ kind: 'depends-on', inverted: true, phrases: ['depended on by'] },
|
|
120
|
+
{
|
|
121
|
+
kind: 'assumes',
|
|
122
|
+
inverted: false,
|
|
123
|
+
phrases: [
|
|
124
|
+
'assumes',
|
|
125
|
+
'assuming',
|
|
126
|
+
'on the assumption of',
|
|
127
|
+
'as decided in',
|
|
128
|
+
'as established in',
|
|
129
|
+
'as established by',
|
|
130
|
+
'as stated in',
|
|
131
|
+
'as specified in',
|
|
132
|
+
'as required by',
|
|
133
|
+
'as mandated by',
|
|
134
|
+
'in accordance with',
|
|
135
|
+
'constrained by',
|
|
136
|
+
'mandated by',
|
|
137
|
+
'governed by',
|
|
138
|
+
'predicated on',
|
|
139
|
+
'rests on',
|
|
140
|
+
'follows from',
|
|
141
|
+
'justified by',
|
|
142
|
+
// A constraint the citing document designs around. "the row limit imposed
|
|
143
|
+
// by ADR-0002" is the archetypal stale premise: when ADR-0002 is retired,
|
|
144
|
+
// this document is still working around a cap that no longer exists.
|
|
145
|
+
'imposed by',
|
|
146
|
+
'required by',
|
|
147
|
+
'dictated by',
|
|
148
|
+
'determined by',
|
|
149
|
+
'limited by',
|
|
150
|
+
'capped by',
|
|
151
|
+
'bounded by',
|
|
152
|
+
'defined by',
|
|
153
|
+
'defined in',
|
|
154
|
+
'specified by',
|
|
155
|
+
'set by',
|
|
156
|
+
'per',
|
|
157
|
+
'because',
|
|
158
|
+
'since',
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
/**
|
|
163
|
+
* Words that cancel a governing phrase standing before them.
|
|
164
|
+
*
|
|
165
|
+
* The window allows a short noun phrase between a verb and the reference it
|
|
166
|
+
* governs - "deferred to the sharding decision in [ADR-7]" is one statement.
|
|
167
|
+
* It cannot allow a negation. A table entry reading "Owned by nobody today and
|
|
168
|
+
* disclaimed by [138]" was being read as a delegation *to* 138, which is the
|
|
169
|
+
* opposite of what the sentence says, and an inference that inverts its source
|
|
170
|
+
* is worse than no inference at all.
|
|
171
|
+
*/
|
|
172
|
+
const NEGATION = /(?:^|\s)(?:no|not|nobody|none|never|neither|nor|nothing|without)(?:\s|$)/;
|
|
173
|
+
/** How close a governing phrase must sit to the reference it governs. */
|
|
174
|
+
const VERB_WINDOW = 40;
|
|
175
|
+
/**
|
|
176
|
+
* Every governing phrase compiled into one alternation, plus a lookup table.
|
|
177
|
+
*
|
|
178
|
+
* Classification runs on every reference in the corpus, and testing a hundred
|
|
179
|
+
* phrases one at a time made it the most expensive thing extraction did. The
|
|
180
|
+
* alternation is sorted longest-first so that at any given position the regex
|
|
181
|
+
* prefers `partially resolved` over `resolved`, matching the precedence the
|
|
182
|
+
* per-phrase search had. Lookarounds rather than `\b`, because phrases contain
|
|
183
|
+
* hyphens and `\b` does the wrong thing around them.
|
|
184
|
+
*/
|
|
185
|
+
const VERB_LOOKUP = new Map(VERB_RULES.flatMap((rule) => rule.phrases.map((phrase) => [phrase, { kind: rule.kind, inverted: rule.inverted }])));
|
|
186
|
+
const VERB_PATTERN = new RegExp(`(?<![\\p{L}\\p{N}])(?:${[...VERB_LOOKUP.keys()]
|
|
187
|
+
.sort((a, b) => b.length - a.length)
|
|
188
|
+
.map((phrase) => phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
189
|
+
.join('|')})(?![\\p{L}\\p{N}])`, 'gu');
|
|
190
|
+
/** Headings under which a link is bookkeeping rather than a commitment. */
|
|
191
|
+
const WEAK_SECTIONS = new Set([
|
|
192
|
+
'see also',
|
|
193
|
+
'references',
|
|
194
|
+
'reference',
|
|
195
|
+
'related',
|
|
196
|
+
'related work',
|
|
197
|
+
'related decisions',
|
|
198
|
+
'related documents',
|
|
199
|
+
'links',
|
|
200
|
+
'further reading',
|
|
201
|
+
'prior art',
|
|
202
|
+
'history',
|
|
203
|
+
'changelog',
|
|
204
|
+
'change log',
|
|
205
|
+
'revision history',
|
|
206
|
+
'bibliography',
|
|
207
|
+
'sources',
|
|
208
|
+
'appendix',
|
|
209
|
+
'more information',
|
|
210
|
+
'resources',
|
|
211
|
+
'index',
|
|
212
|
+
]);
|
|
213
|
+
/** Headings whose bullets are obligations even without a checkbox. */
|
|
214
|
+
const OBLIGATION_SECTIONS = new Set([
|
|
215
|
+
'open questions',
|
|
216
|
+
'open question',
|
|
217
|
+
'unresolved questions',
|
|
218
|
+
'unanswered questions',
|
|
219
|
+
'questions',
|
|
220
|
+
'open issues',
|
|
221
|
+
'action items',
|
|
222
|
+
'actions',
|
|
223
|
+
'todo',
|
|
224
|
+
'to do',
|
|
225
|
+
'to-do',
|
|
226
|
+
'todos',
|
|
227
|
+
'next steps',
|
|
228
|
+
'follow ups',
|
|
229
|
+
'follow-ups',
|
|
230
|
+
'followups',
|
|
231
|
+
'follow up',
|
|
232
|
+
'follow-up',
|
|
233
|
+
'unresolved',
|
|
234
|
+
'outstanding',
|
|
235
|
+
'outstanding questions',
|
|
236
|
+
'tasks',
|
|
237
|
+
'task list',
|
|
238
|
+
'work items',
|
|
239
|
+
'remaining work',
|
|
240
|
+
'decisions needed',
|
|
241
|
+
'blockers',
|
|
242
|
+
'parking lot',
|
|
243
|
+
'future work',
|
|
244
|
+
'deferred',
|
|
245
|
+
]);
|
|
246
|
+
/**
|
|
247
|
+
* Prefixes that look like specification identifiers but never are.
|
|
248
|
+
*
|
|
249
|
+
* The corpus filter in `resolve.ts` is the real defence; this list only keeps
|
|
250
|
+
* the obvious noise out of the candidate set.
|
|
251
|
+
*/
|
|
252
|
+
const NOT_A_FAMILY = new Set([
|
|
253
|
+
'v',
|
|
254
|
+
'ver',
|
|
255
|
+
'version',
|
|
256
|
+
'p',
|
|
257
|
+
'pp',
|
|
258
|
+
'fig',
|
|
259
|
+
'figure',
|
|
260
|
+
'table',
|
|
261
|
+
'tbl',
|
|
262
|
+
'section',
|
|
263
|
+
'sect',
|
|
264
|
+
'sec',
|
|
265
|
+
'step',
|
|
266
|
+
'item',
|
|
267
|
+
'no',
|
|
268
|
+
'num',
|
|
269
|
+
'line',
|
|
270
|
+
'ln',
|
|
271
|
+
'col',
|
|
272
|
+
'port',
|
|
273
|
+
'pr',
|
|
274
|
+
'issue',
|
|
275
|
+
'gh',
|
|
276
|
+
'utf',
|
|
277
|
+
'ascii',
|
|
278
|
+
'sha',
|
|
279
|
+
'md',
|
|
280
|
+
'http',
|
|
281
|
+
'https',
|
|
282
|
+
'ipv',
|
|
283
|
+
'tls',
|
|
284
|
+
'ssl',
|
|
285
|
+
'es',
|
|
286
|
+
'ecma',
|
|
287
|
+
'x',
|
|
288
|
+
'h',
|
|
289
|
+
'p99',
|
|
290
|
+
'base',
|
|
291
|
+
'sql',
|
|
292
|
+
'ipv4',
|
|
293
|
+
'ipv6',
|
|
294
|
+
'arm',
|
|
295
|
+
'x86',
|
|
296
|
+
'win',
|
|
297
|
+
'node',
|
|
298
|
+
'python',
|
|
299
|
+
'java',
|
|
300
|
+
'go',
|
|
301
|
+
'c',
|
|
302
|
+
'cpp',
|
|
303
|
+
]);
|
|
304
|
+
const BARE_REF = /\b([A-Za-z][A-Za-z0-9]{0,14})[\s._-]?(\d{1,6})\b/g;
|
|
305
|
+
/**
|
|
306
|
+
* Reads one file into graph fragments, one entry per specification in it.
|
|
307
|
+
*
|
|
308
|
+
* A file yields at least itself. It yields more when it is a register: a
|
|
309
|
+
* heading that carries an identifier *and* declares a status is a specification
|
|
310
|
+
* in its own right, with its own lifecycle, its own obligations and its own
|
|
311
|
+
* relations. See ADR-0009.
|
|
312
|
+
*/
|
|
313
|
+
export function extractSpecifications(input) {
|
|
314
|
+
const first = extractDocument(input);
|
|
315
|
+
return first === null ? [] : [first, ...first.subSpecifications];
|
|
316
|
+
}
|
|
317
|
+
/** Reads a file's own specification. Returns `null` when it opts out. */
|
|
318
|
+
export function extractDocument(input) {
|
|
319
|
+
const scanned = scanMarkdown(input.text);
|
|
320
|
+
const directives = parseDirectives(scanned.comments);
|
|
321
|
+
if (directives.some((d) => d.name === 'spec-ignore'))
|
|
322
|
+
return null;
|
|
323
|
+
const index = scanned.index;
|
|
324
|
+
const file = input.path;
|
|
325
|
+
const problems = [];
|
|
326
|
+
const at = (start, end) => refOf(file, index, start, end);
|
|
327
|
+
for (const directive of directives) {
|
|
328
|
+
for (const unknown of directive.unknownAttributes) {
|
|
329
|
+
problems.push({
|
|
330
|
+
message: `unknown attribute "${unknown}" on @${directive.name}`,
|
|
331
|
+
at: at(directive.start, directive.end),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const entries = scanned.frontMatter ? parseFrontMatter(scanned.frontMatter.raw, scanned.frontMatter.start) : [];
|
|
336
|
+
const byKey = new Map();
|
|
337
|
+
for (const entry of entries)
|
|
338
|
+
byKey.set(entry.key, entry);
|
|
339
|
+
const h1 = scanned.headings.find((h) => h.level === 1) ?? null;
|
|
340
|
+
const frontMatterId = firstValue(byKey, ID_KEYS) ?? null;
|
|
341
|
+
const identityOf = (declaredId, directive) => identify({
|
|
342
|
+
path: input.path,
|
|
343
|
+
declaredId,
|
|
344
|
+
declaredAliases: [
|
|
345
|
+
...(directive ? attrList(directive, 'aliases') : []),
|
|
346
|
+
...valuesOf(byKey.get('aliases')),
|
|
347
|
+
...valuesOf(byKey.get('alias')),
|
|
348
|
+
],
|
|
349
|
+
heading: h1?.text ?? null,
|
|
350
|
+
});
|
|
351
|
+
// A `@spec-node` written inside a register's section belongs to that section,
|
|
352
|
+
// not to the file. Finding the file's own therefore needs the regions, and
|
|
353
|
+
// finding the regions needs the file's identity - so the identity is settled
|
|
354
|
+
// provisionally first, then again once the file's own directive is known.
|
|
355
|
+
const provisional = identityOf(frontMatterId, null);
|
|
356
|
+
const provisionalRegions = findSpecificationRegions(scanned, directives, provisional.id);
|
|
357
|
+
const nodeDirective = directives.find((directive) => directive.name === 'spec-node' &&
|
|
358
|
+
!provisionalRegions.some((region) => (region.heading !== null || region.row !== null) &&
|
|
359
|
+
directive.start >= region.start &&
|
|
360
|
+
directive.end <= region.end)) ?? null;
|
|
361
|
+
const declaredId = (nodeDirective ? attr(nodeDirective, 'id')?.value : null) ?? frontMatterId;
|
|
362
|
+
const identity = declaredId === frontMatterId && nodeDirective === null
|
|
363
|
+
? provisional
|
|
364
|
+
: identityOf(declaredId, nodeDirective);
|
|
365
|
+
const status = readStatus(scanned, byKey, nodeDirective, index, file);
|
|
366
|
+
const pathPhase = phaseFromPath(input.path);
|
|
367
|
+
// A record is a categorical statement about what the file *is*, made by
|
|
368
|
+
// configuration or by an explicit directive, so it outranks a status word
|
|
369
|
+
// that happened to be written inside it.
|
|
370
|
+
const record = input.record === true || directives.some((directive) => directive.name === 'spec-history');
|
|
371
|
+
const phase = record ? 'record' : status.phase !== 'unknown' ? status.phase : pathPhase;
|
|
372
|
+
const title = (nodeDirective ? attr(nodeDirective, 'title')?.value : null) ??
|
|
373
|
+
asString(byKey.get('title')) ??
|
|
374
|
+
h1?.text ??
|
|
375
|
+
identity.id;
|
|
376
|
+
const document = {
|
|
377
|
+
id: identity.id,
|
|
378
|
+
kind: 'document',
|
|
379
|
+
title,
|
|
380
|
+
at: at(0, Math.min(scanned.text.length, index.lineEnd(1))),
|
|
381
|
+
path: input.path,
|
|
382
|
+
aliases: identity.aliases,
|
|
383
|
+
phase,
|
|
384
|
+
rawStatus: status.raw,
|
|
385
|
+
statusAt: status.at,
|
|
386
|
+
frontMatter: toRecord(entries),
|
|
387
|
+
};
|
|
388
|
+
// A register's sections are specifications too. The file is always the first
|
|
389
|
+
// region, so a one-decision file behaves exactly as it did before.
|
|
390
|
+
const regions = identity.id === provisional.id ? provisionalRegions : findSpecificationRegions(scanned, directives, identity.id);
|
|
391
|
+
// Every region but the first: a heading section, or a row of a register kept
|
|
392
|
+
// as a table.
|
|
393
|
+
const subSpecifications = regions
|
|
394
|
+
.filter((region) => region.heading !== null || region.row !== null)
|
|
395
|
+
.map((region) => buildRegion({
|
|
396
|
+
region,
|
|
397
|
+
containerId: identity.id,
|
|
398
|
+
input,
|
|
399
|
+
scanned,
|
|
400
|
+
directives,
|
|
401
|
+
index,
|
|
402
|
+
file,
|
|
403
|
+
pathPhase,
|
|
404
|
+
record,
|
|
405
|
+
}));
|
|
406
|
+
const ownerAt = (offset) => {
|
|
407
|
+
const region = regionAt(regions, offset);
|
|
408
|
+
if (region === null)
|
|
409
|
+
return identity.id;
|
|
410
|
+
const owner = subSpecifications.find((spec) => spec.region === region);
|
|
411
|
+
return owner?.document.id ?? identity.id;
|
|
412
|
+
};
|
|
413
|
+
const allItems = extractItems({ scanned, directives, ownerAt, file, index });
|
|
414
|
+
const documentAt = (offset) => {
|
|
415
|
+
const id = ownerAt(offset);
|
|
416
|
+
if (id === identity.id)
|
|
417
|
+
return document;
|
|
418
|
+
return subSpecifications.find((spec) => spec.document.id === id)?.document ?? document;
|
|
419
|
+
};
|
|
420
|
+
const allReferences = extractReferences({
|
|
421
|
+
scanned,
|
|
422
|
+
directives,
|
|
423
|
+
document,
|
|
424
|
+
documentAt,
|
|
425
|
+
items: allItems,
|
|
426
|
+
byKey,
|
|
427
|
+
status,
|
|
428
|
+
claimed: regions.flatMap((region) => [...region.claimed]),
|
|
429
|
+
file,
|
|
430
|
+
index,
|
|
431
|
+
});
|
|
432
|
+
// Hand each region the items and references that fall inside it.
|
|
433
|
+
const ownItems = allItems.filter((item) => item.document === identity.id);
|
|
434
|
+
const ownReferences = allReferences.filter((reference) => belongsTo(reference, identity.id, allItems));
|
|
435
|
+
const anchors = new Set(scanned.headings.map((h) => h.slug));
|
|
436
|
+
const filled = subSpecifications.map((spec) => ({
|
|
437
|
+
...spec.extracted,
|
|
438
|
+
items: allItems.filter((item) => item.document === spec.document.id),
|
|
439
|
+
references: [
|
|
440
|
+
// Column-declared relations first: they are typed by the header rather
|
|
441
|
+
// than guessed from prose.
|
|
442
|
+
...spec.extracted.references,
|
|
443
|
+
...allReferences.filter((reference) => belongsTo(reference, spec.document.id, allItems)),
|
|
444
|
+
],
|
|
445
|
+
}));
|
|
446
|
+
return {
|
|
447
|
+
document,
|
|
448
|
+
items: ownItems,
|
|
449
|
+
references: ownReferences,
|
|
450
|
+
problems,
|
|
451
|
+
anchors,
|
|
452
|
+
identity,
|
|
453
|
+
scanned,
|
|
454
|
+
subSpecifications: filled,
|
|
455
|
+
containerId: null,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** True when a reference was written on a document or on one of its items. */
|
|
459
|
+
function belongsTo(reference, documentId, items) {
|
|
460
|
+
if (reference.from === documentId)
|
|
461
|
+
return true;
|
|
462
|
+
const owner = items.find((item) => item.id === reference.from);
|
|
463
|
+
return owner?.document === documentId;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Turns one region into a specification.
|
|
467
|
+
*
|
|
468
|
+
* It produces exactly the `DocumentNode` a whole file produces, which is the
|
|
469
|
+
* reason every rule, query and reporter works on registers without knowing they
|
|
470
|
+
* exist. The only deliberate differences: the node is anchored at its heading
|
|
471
|
+
* rather than at line one, it carries no front matter of its own, and it does
|
|
472
|
+
* not claim the file's path as an alias - the file already does, and two nodes
|
|
473
|
+
* answering to one path would make every link to that file ambiguous.
|
|
474
|
+
*/
|
|
475
|
+
function buildRegion(context) {
|
|
476
|
+
const { region, input, scanned, index, file, pathPhase, record } = context;
|
|
477
|
+
const at = (start, end) => refOf(file, index, start, end);
|
|
478
|
+
const identity = identify({
|
|
479
|
+
path: input.path,
|
|
480
|
+
declaredId: region.declaredId,
|
|
481
|
+
declaredAliases: region.directive ? attrList(region.directive, 'aliases') : [],
|
|
482
|
+
heading: region.title,
|
|
483
|
+
includePathAliases: false,
|
|
484
|
+
});
|
|
485
|
+
const declaredStatus = region.directive ? attr(region.directive, 'status') : null;
|
|
486
|
+
const rawStatus = declaredStatus?.value ?? region.status?.text ?? null;
|
|
487
|
+
const statusAt = declaredStatus
|
|
488
|
+
? at(declaredStatus.start, declaredStatus.end)
|
|
489
|
+
: region.status
|
|
490
|
+
? at(region.status.start, region.status.end)
|
|
491
|
+
: null;
|
|
492
|
+
const declaredPhase = phaseOf(rawStatus);
|
|
493
|
+
const document = {
|
|
494
|
+
id: identity.id,
|
|
495
|
+
kind: 'document',
|
|
496
|
+
title: (region.directive ? attr(region.directive, 'title')?.value : null) ?? region.title ?? identity.id,
|
|
497
|
+
// Anchored at its heading, or at the row when the register is a table.
|
|
498
|
+
at: at(region.start, Math.min(region.end, region.heading?.end ?? region.row?.end ?? region.end)),
|
|
499
|
+
path: input.path,
|
|
500
|
+
aliases: identity.aliases,
|
|
501
|
+
// A section of a journal is part of the journal, whatever status word it
|
|
502
|
+
// wrote for itself. Being a record is a statement about the file made from
|
|
503
|
+
// outside it, and it does not stop at a heading.
|
|
504
|
+
phase: record ? 'record' : declaredPhase !== 'unknown' ? declaredPhase : pathPhase,
|
|
505
|
+
rawStatus,
|
|
506
|
+
statusAt,
|
|
507
|
+
frontMatter: {},
|
|
508
|
+
};
|
|
509
|
+
const anchors = new Set(scanned.headings.filter((h) => h.start >= region.start && h.start < region.end).map((h) => h.slug));
|
|
510
|
+
// Relations a table declares by column, typed by the header the author wrote.
|
|
511
|
+
const references = region.relations.map((relation) => ({
|
|
512
|
+
kind: relation.kind,
|
|
513
|
+
from: document.id,
|
|
514
|
+
target: relation.target,
|
|
515
|
+
origin: 'front-matter',
|
|
516
|
+
declaredAt: at(relation.start, relation.end),
|
|
517
|
+
raw: `${relation.column}: ${relation.target}`,
|
|
518
|
+
inverted: relation.inverted,
|
|
519
|
+
opportunistic: false,
|
|
520
|
+
}));
|
|
521
|
+
return {
|
|
522
|
+
region,
|
|
523
|
+
document,
|
|
524
|
+
extracted: {
|
|
525
|
+
document,
|
|
526
|
+
items: [],
|
|
527
|
+
references,
|
|
528
|
+
problems: [],
|
|
529
|
+
anchors,
|
|
530
|
+
identity,
|
|
531
|
+
scanned,
|
|
532
|
+
subSpecifications: [],
|
|
533
|
+
containerId: context.containerId,
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Finds the document status.
|
|
539
|
+
*
|
|
540
|
+
* Looks in the three places teams actually put it, in descending order of how
|
|
541
|
+
* deliberate each is: a directive, a front-matter field, then the body of a
|
|
542
|
+
* `## Status` section. A repository using none of them still gets a phase from
|
|
543
|
+
* its directory layout, which is handled by the caller.
|
|
544
|
+
*/
|
|
545
|
+
function readStatus(scanned, byKey, nodeDirective, index, file) {
|
|
546
|
+
if (nodeDirective) {
|
|
547
|
+
const declared = attr(nodeDirective, 'status');
|
|
548
|
+
if (declared) {
|
|
549
|
+
return {
|
|
550
|
+
raw: declared.value,
|
|
551
|
+
at: refOf(file, index, declared.start, declared.end),
|
|
552
|
+
phase: phaseOf(declared.value),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const key of STATUS_KEYS) {
|
|
557
|
+
const entry = byKey.get(key);
|
|
558
|
+
if (!entry)
|
|
559
|
+
continue;
|
|
560
|
+
const raw = typeof entry.value === 'string' ? entry.value : entry.value.join(', ');
|
|
561
|
+
if (raw.trim().length === 0)
|
|
562
|
+
continue;
|
|
563
|
+
return {
|
|
564
|
+
raw,
|
|
565
|
+
at: refOf(file, index, entry.valueStart, entry.end),
|
|
566
|
+
phase: phaseOf(raw),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const heading = scanned.headings.find((h) => isStatusHeading(h.text));
|
|
570
|
+
if (heading) {
|
|
571
|
+
const body = statusSectionBody(scanned, heading.line);
|
|
572
|
+
if (body) {
|
|
573
|
+
return {
|
|
574
|
+
raw: body.text,
|
|
575
|
+
at: refOf(file, index, body.start, body.end),
|
|
576
|
+
phase: phaseOf(body.text),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return { raw: null, at: null, phase: 'unknown' };
|
|
581
|
+
}
|
|
582
|
+
/** The first non-blank line under a `## Status` heading. */
|
|
583
|
+
function statusSectionBody(scanned, headingLine) {
|
|
584
|
+
for (const line of scanned.lines) {
|
|
585
|
+
if (line.line <= headingLine)
|
|
586
|
+
continue;
|
|
587
|
+
if (line.blank)
|
|
588
|
+
continue;
|
|
589
|
+
if (line.code)
|
|
590
|
+
return null;
|
|
591
|
+
const trimmed = line.content.trim();
|
|
592
|
+
// A heading immediately after means the section is empty.
|
|
593
|
+
if (trimmed.startsWith('#'))
|
|
594
|
+
return null;
|
|
595
|
+
const offset = line.contentStart + line.content.indexOf(trimmed);
|
|
596
|
+
// Bullet lists under Status are a status history; take the first entry.
|
|
597
|
+
const cleaned = trimmed.replace(/^[-*+]\s+/, '');
|
|
598
|
+
const start = offset + (trimmed.length - cleaned.length);
|
|
599
|
+
return { text: cleaned, start, end: start + cleaned.length };
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
function extractItems(context) {
|
|
604
|
+
const { scanned, directives, ownerAt, file, index } = context;
|
|
605
|
+
const out = [];
|
|
606
|
+
const ordinals = new Map();
|
|
607
|
+
for (const item of scanned.listItems) {
|
|
608
|
+
const section = sectionPathAt(scanned, item.start);
|
|
609
|
+
const inObligationSection = section.some((heading) => OBLIGATION_SECTIONS.has(normaliseHeading(heading)));
|
|
610
|
+
const directive = directiveFor(directives, 'spec-item', { start: item.start, end: item.end }, 200);
|
|
611
|
+
if (!isObligation(item, inObligationSection, directive))
|
|
612
|
+
continue;
|
|
613
|
+
const documentId = ownerAt(item.start);
|
|
614
|
+
const slug = section.length > 0 ? slugify(section[section.length - 1]) : 'item';
|
|
615
|
+
// Ordinals count within a specification, so two decisions in one register
|
|
616
|
+
// each get their own `#open-questions.1` rather than sharing a sequence.
|
|
617
|
+
const key = `${documentId}#${slug}`;
|
|
618
|
+
const ordinal = (ordinals.get(key) ?? 0) + 1;
|
|
619
|
+
ordinals.set(key, ordinal);
|
|
620
|
+
const declaredId = directive ? attr(directive, 'id')?.value : null;
|
|
621
|
+
const id = `${documentId}#${declaredId ?? `${slug}.${ordinal}`}`;
|
|
622
|
+
const state = resolveItemState({
|
|
623
|
+
file,
|
|
624
|
+
index,
|
|
625
|
+
item,
|
|
626
|
+
section,
|
|
627
|
+
directive,
|
|
628
|
+
inObligationSection,
|
|
629
|
+
});
|
|
630
|
+
out.push({
|
|
631
|
+
id,
|
|
632
|
+
kind: 'item',
|
|
633
|
+
title: (directive ? attr(directive, 'title')?.value : null) ?? summarise(item.firstLine),
|
|
634
|
+
at: refOf(file, index, item.start, item.end),
|
|
635
|
+
document: documentId,
|
|
636
|
+
section,
|
|
637
|
+
text: summarise(item.firstLine),
|
|
638
|
+
body: item.body,
|
|
639
|
+
disposition: state.disposition,
|
|
640
|
+
openness: state.openness,
|
|
641
|
+
evidence: state.evidence,
|
|
642
|
+
conflicts: state.conflicts,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return out;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Decides whether a bullet is an obligation.
|
|
649
|
+
*
|
|
650
|
+
* A checkbox or an explicit directive always makes one. Otherwise only
|
|
651
|
+
* top-level bullets under a heading like "Open Questions" count: promoting
|
|
652
|
+
* every bullet in a specification would bury the real obligations under the
|
|
653
|
+
* document's own prose.
|
|
654
|
+
*/
|
|
655
|
+
function isObligation(item, inObligationSection, directive) {
|
|
656
|
+
if (directive !== null)
|
|
657
|
+
return true;
|
|
658
|
+
if (item.checkbox !== null)
|
|
659
|
+
return true;
|
|
660
|
+
return inObligationSection && item.depth === 0;
|
|
661
|
+
}
|
|
662
|
+
/** The heading path enclosing an offset, outermost first. */
|
|
663
|
+
function sectionPathAt(scanned, offset) {
|
|
664
|
+
const path = [];
|
|
665
|
+
for (const heading of scanned.headings) {
|
|
666
|
+
if (heading.start > offset)
|
|
667
|
+
break;
|
|
668
|
+
while (path.length > 0 && path[path.length - 1].level >= heading.level)
|
|
669
|
+
path.pop();
|
|
670
|
+
path.push({ level: heading.level, text: heading.text });
|
|
671
|
+
}
|
|
672
|
+
return path.map((entry) => entry.text);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Memoised because it is called for every heading above every reference and
|
|
676
|
+
* every item, and a corpus has only a handful of distinct headings - `Context`,
|
|
677
|
+
* `Decision`, `Open Questions`, `See also` - repeated thousands of times.
|
|
678
|
+
*/
|
|
679
|
+
const headingCache = new Map();
|
|
680
|
+
function normaliseHeading(text) {
|
|
681
|
+
const cached = headingCache.get(text);
|
|
682
|
+
if (cached !== undefined)
|
|
683
|
+
return cached;
|
|
684
|
+
const normalised = text
|
|
685
|
+
.toLowerCase()
|
|
686
|
+
.replace(/[`*_~]/g, '')
|
|
687
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
688
|
+
.replace(/\s+/g, ' ')
|
|
689
|
+
.trim();
|
|
690
|
+
// Bounded so a pathological corpus of unique headings cannot grow it forever.
|
|
691
|
+
if (headingCache.size < 4096)
|
|
692
|
+
headingCache.set(text, normalised);
|
|
693
|
+
return normalised;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* One-line form of an item for reports.
|
|
697
|
+
*
|
|
698
|
+
* Link syntax is flattened to its label. A finding that quotes the obligation
|
|
699
|
+
* back at the reader should read like the sentence they wrote, not like the
|
|
700
|
+
* Markdown source of it.
|
|
701
|
+
*/
|
|
702
|
+
function summarise(text) {
|
|
703
|
+
const flat = text
|
|
704
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
705
|
+
// `[[target|display]]` keeps the display half: that is the text a reader
|
|
706
|
+
// of the rendered document actually sees.
|
|
707
|
+
.replace(/\[\[(?:[^\]|]*\|)?([^\]]*)\]\]/g, '$1')
|
|
708
|
+
.replace(/\s+/g, ' ')
|
|
709
|
+
.trim();
|
|
710
|
+
return flat.length <= 120 ? flat : `${flat.slice(0, 117)}...`;
|
|
711
|
+
}
|
|
712
|
+
/** True when an offset falls in a cell a column header already accounted for. */
|
|
713
|
+
function isClaimed(claimed, offset) {
|
|
714
|
+
return claimed.some((range) => offset >= range.start && offset < range.end);
|
|
715
|
+
}
|
|
716
|
+
function extractReferences(context) {
|
|
717
|
+
const { scanned, directives, document, documentAt, items, byKey, status, claimed, file, index } = context;
|
|
718
|
+
const out = [];
|
|
719
|
+
const at = (start, end) => refOf(file, index, start, end);
|
|
720
|
+
// Front matter.
|
|
721
|
+
for (const [key, relation] of Object.entries(RELATION_KEYS)) {
|
|
722
|
+
const entry = byKey.get(key) ?? byKey.get(key.replace(/-/g, '_'));
|
|
723
|
+
if (!entry)
|
|
724
|
+
continue;
|
|
725
|
+
for (const value of valuesOf(entry)) {
|
|
726
|
+
const target = cleanTarget(value);
|
|
727
|
+
if (target.length === 0)
|
|
728
|
+
continue;
|
|
729
|
+
out.push({
|
|
730
|
+
kind: relation.kind,
|
|
731
|
+
from: document.id,
|
|
732
|
+
target,
|
|
733
|
+
origin: 'front-matter',
|
|
734
|
+
declaredAt: at(entry.valueStart, entry.end),
|
|
735
|
+
raw: `${key}: ${value}`,
|
|
736
|
+
inverted: relation.inverted,
|
|
737
|
+
opportunistic: false,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// `status: Superseded by ADR-0009` is the most common supersession record of
|
|
742
|
+
// all, and it never appears as a field of its own.
|
|
743
|
+
if (status.raw && status.at) {
|
|
744
|
+
for (const target of supersessionTargetsIn(status.raw)) {
|
|
745
|
+
const cleaned = cleanTarget(target);
|
|
746
|
+
if (cleaned.length === 0)
|
|
747
|
+
continue;
|
|
748
|
+
out.push({
|
|
749
|
+
kind: 'supersedes',
|
|
750
|
+
from: document.id,
|
|
751
|
+
target: cleaned,
|
|
752
|
+
origin: 'front-matter',
|
|
753
|
+
declaredAt: status.at,
|
|
754
|
+
raw: status.raw,
|
|
755
|
+
inverted: true,
|
|
756
|
+
opportunistic: true,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Explicit edge directives.
|
|
761
|
+
for (const directive of directives) {
|
|
762
|
+
if (directive.name !== 'spec-edge')
|
|
763
|
+
continue;
|
|
764
|
+
const kind = attr(directive, 'kind')?.value;
|
|
765
|
+
const to = attr(directive, 'to');
|
|
766
|
+
const from = attr(directive, 'from');
|
|
767
|
+
if (!kind)
|
|
768
|
+
continue;
|
|
769
|
+
const owner = ownerOf(items, directive.start) ?? documentAt(directive.start).id;
|
|
770
|
+
if (to) {
|
|
771
|
+
out.push({
|
|
772
|
+
kind,
|
|
773
|
+
from: owner,
|
|
774
|
+
target: to.value,
|
|
775
|
+
origin: 'directive',
|
|
776
|
+
declaredAt: at(to.start, to.end),
|
|
777
|
+
raw: `@spec-edge kind="${kind}" to="${to.value}"`,
|
|
778
|
+
inverted: false,
|
|
779
|
+
opportunistic: false,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
if (from) {
|
|
783
|
+
out.push({
|
|
784
|
+
kind,
|
|
785
|
+
from: owner,
|
|
786
|
+
target: from.value,
|
|
787
|
+
origin: 'directive',
|
|
788
|
+
declaredAt: at(from.start, from.end),
|
|
789
|
+
raw: `@spec-edge kind="${kind}" from="${from.value}"`,
|
|
790
|
+
inverted: true,
|
|
791
|
+
opportunistic: false,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
// Prose links. A cell whose meaning a column header already gave is skipped,
|
|
796
|
+
// so `| ADR-0002 | ... | [ADR-0001](0001.md) |` yields one typed edge rather
|
|
797
|
+
// than a typed edge and a neutral citation beside it.
|
|
798
|
+
const linked = new Set();
|
|
799
|
+
for (const link of scanned.links) {
|
|
800
|
+
if (link.form === 'definition')
|
|
801
|
+
continue;
|
|
802
|
+
if (isClaimed(claimed, link.start))
|
|
803
|
+
continue;
|
|
804
|
+
const target = cleanTarget(link.target);
|
|
805
|
+
if (target.length === 0)
|
|
806
|
+
continue;
|
|
807
|
+
if (isExternal(target))
|
|
808
|
+
continue;
|
|
809
|
+
const owner = ownerOf(items, link.start) ?? documentAt(link.start).id;
|
|
810
|
+
const section = sectionPathAt(scanned, link.start);
|
|
811
|
+
const classified = classifyReference(scanned.masked, link.start, link.end, section);
|
|
812
|
+
linked.add(normaliseRef(stripAnchor(target)));
|
|
813
|
+
out.push({
|
|
814
|
+
kind: classified.kind,
|
|
815
|
+
from: owner,
|
|
816
|
+
target,
|
|
817
|
+
origin: 'link',
|
|
818
|
+
declaredAt: at(link.targetStart, link.targetStart + link.target.length),
|
|
819
|
+
raw: renderLink(link),
|
|
820
|
+
inverted: classified.inverted,
|
|
821
|
+
opportunistic: false,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
// Bare identifiers in prose. Everything already covered by a link is skipped
|
|
825
|
+
// so a citation written as `[ADR-7](0007.md)` is not counted twice.
|
|
826
|
+
for (const bare of findBareReferences(scanned)) {
|
|
827
|
+
const key = normaliseRef(bare.text);
|
|
828
|
+
if (linked.has(key))
|
|
829
|
+
continue;
|
|
830
|
+
if (isClaimed(claimed, bare.start))
|
|
831
|
+
continue;
|
|
832
|
+
const owner = ownerOf(items, bare.start) ?? documentAt(bare.start).id;
|
|
833
|
+
const section = sectionPathAt(scanned, bare.start);
|
|
834
|
+
const classified = classifyReference(scanned.masked, bare.start, bare.end, section);
|
|
835
|
+
out.push({
|
|
836
|
+
kind: classified.kind,
|
|
837
|
+
from: owner,
|
|
838
|
+
target: bare.text,
|
|
839
|
+
origin: 'text',
|
|
840
|
+
declaredAt: at(bare.start, bare.end),
|
|
841
|
+
raw: bare.text,
|
|
842
|
+
inverted: classified.inverted,
|
|
843
|
+
opportunistic: true,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
/** The item whose block contains an offset, if any. */
|
|
849
|
+
function ownerOf(items, offset) {
|
|
850
|
+
let best = null;
|
|
851
|
+
for (const item of items) {
|
|
852
|
+
const span = item.at.span;
|
|
853
|
+
if (offset < span.start.offset || offset >= span.end.offset)
|
|
854
|
+
continue;
|
|
855
|
+
// Innermost wins when items nest.
|
|
856
|
+
if (best === null || span.start.offset > best.at.span.start.offset)
|
|
857
|
+
best = item;
|
|
858
|
+
}
|
|
859
|
+
return best?.id ?? null;
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Decides what a reference means from the words around it.
|
|
863
|
+
*
|
|
864
|
+
* The nearest governing phrase within {@link VERB_WINDOW} characters wins, and
|
|
865
|
+
* the search never crosses a sentence boundary. With nothing to go on, a
|
|
866
|
+
* reference under a "See also" heading is bookkeeping and everything else is a
|
|
867
|
+
* neutral citation.
|
|
868
|
+
*/
|
|
869
|
+
export function classifyReference(text, start, end, section) {
|
|
870
|
+
const before = sentenceBefore(text, start);
|
|
871
|
+
// Left to right, so the last match within the window is the nearest one - and
|
|
872
|
+
// the nearest governing phrase is the one that governs.
|
|
873
|
+
let best = null;
|
|
874
|
+
VERB_PATTERN.lastIndex = 0;
|
|
875
|
+
for (let m = VERB_PATTERN.exec(before); m !== null; m = VERB_PATTERN.exec(before)) {
|
|
876
|
+
const phrase = m[0];
|
|
877
|
+
const end = (m.index ?? 0) + phrase.length;
|
|
878
|
+
const distance = before.length - end;
|
|
879
|
+
if (distance > VERB_WINDOW)
|
|
880
|
+
continue;
|
|
881
|
+
// What sits between the phrase and the reference has to be connective. A
|
|
882
|
+
// negation in there reverses the claim the phrase would otherwise make.
|
|
883
|
+
if (NEGATION.test(before.slice(end)))
|
|
884
|
+
continue;
|
|
885
|
+
const rule = VERB_LOOKUP.get(phrase);
|
|
886
|
+
if (rule)
|
|
887
|
+
best = rule;
|
|
888
|
+
}
|
|
889
|
+
if (best)
|
|
890
|
+
return best;
|
|
891
|
+
// "[ADR-0009] supersedes this decision": the reference is the subject, so the
|
|
892
|
+
// relation runs the other way.
|
|
893
|
+
const after = sentenceAfter(text, end);
|
|
894
|
+
for (const rule of VERB_RULES) {
|
|
895
|
+
if (rule.inverted)
|
|
896
|
+
continue;
|
|
897
|
+
for (const phrase of rule.phrases) {
|
|
898
|
+
if (!after.startsWith(phrase))
|
|
899
|
+
continue;
|
|
900
|
+
if (rule.kind === 'assumes' || rule.kind === 'depends-on')
|
|
901
|
+
continue;
|
|
902
|
+
return { kind: rule.kind, inverted: true };
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const innermost = section.length > 0 ? normaliseHeading(section[section.length - 1]) : '';
|
|
906
|
+
if (WEAK_SECTIONS.has(innermost))
|
|
907
|
+
return { kind: 'relates-to', inverted: false };
|
|
908
|
+
for (const heading of section) {
|
|
909
|
+
if (WEAK_SECTIONS.has(normaliseHeading(heading)))
|
|
910
|
+
return { kind: 'relates-to', inverted: false };
|
|
911
|
+
}
|
|
912
|
+
return { kind: 'references', inverted: false };
|
|
913
|
+
}
|
|
914
|
+
/** A sentence end, a blank line, or the start of a new block-level item. */
|
|
915
|
+
/**
|
|
916
|
+
* A table cell edge ends a statement too.
|
|
917
|
+
*
|
|
918
|
+
* The pipe matters more than it looks. A table row is a list of independent
|
|
919
|
+
* fields, and without it the look-behind window reaches back across the row
|
|
920
|
+
* boundary into the previous row's prose. An index of archived documents whose
|
|
921
|
+
* last column ended "...before assuming the drift was fixed" turned every
|
|
922
|
+
* following row's link into an `assumes` edge, and so turned every archived
|
|
923
|
+
* document it listed into a stale premise.
|
|
924
|
+
*/
|
|
925
|
+
const STATEMENT_BREAK = /[.?!;]\s|\n\s*\n|\n\s*[-*+>#]|\|/g;
|
|
926
|
+
/**
|
|
927
|
+
* Lower-cased, whitespace-collapsed text back to the start of the statement.
|
|
928
|
+
*
|
|
929
|
+
* The search stops at the last boundary: a governing phrase in the *previous*
|
|
930
|
+
* sentence does not govern this reference. "We deferred that to ADR-3. See also
|
|
931
|
+
* [ADR-9]." must not read as a delegation to ADR-9.
|
|
932
|
+
*/
|
|
933
|
+
function sentenceBefore(text, start) {
|
|
934
|
+
const window = text.slice(Math.max(0, start - 160), start);
|
|
935
|
+
let cut = 0;
|
|
936
|
+
STATEMENT_BREAK.lastIndex = 0;
|
|
937
|
+
for (let m = STATEMENT_BREAK.exec(window); m !== null; m = STATEMENT_BREAK.exec(window)) {
|
|
938
|
+
cut = (m.index ?? 0) + m[0].length;
|
|
939
|
+
}
|
|
940
|
+
return window
|
|
941
|
+
.slice(cut)
|
|
942
|
+
.toLowerCase()
|
|
943
|
+
.replace(/[`*_~"'()\[\],]/g, ' ')
|
|
944
|
+
.replace(/\s+/g, ' ');
|
|
945
|
+
}
|
|
946
|
+
function sentenceAfter(text, end) {
|
|
947
|
+
const window = text.slice(end, Math.min(text.length, end + 80));
|
|
948
|
+
const stop = /[.?!;\n|]/.exec(window);
|
|
949
|
+
const cut = stop ? window.slice(0, stop.index) : window;
|
|
950
|
+
return cut.toLowerCase().replace(/[`*_~"'()\[\],]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Finds identifiers written as plain prose, such as "as decided in ADR-0007".
|
|
954
|
+
*
|
|
955
|
+
* Link constructs are blanked first. Without that, `[ADR-7](https://x/adr-7)`
|
|
956
|
+
* would yield three references to the same thing - one real and two harvested
|
|
957
|
+
* out of a URL - which is precisely the mis-match that makes hand-rolled regex
|
|
958
|
+
* checks untrustworthy.
|
|
959
|
+
*/
|
|
960
|
+
function findBareReferences(scanned) {
|
|
961
|
+
// Slice-based for the same reason as the scanner's masking: this runs over
|
|
962
|
+
// every document, and a per-character array is the wrong shape for the job.
|
|
963
|
+
const source = scanned.masked;
|
|
964
|
+
let text = '';
|
|
965
|
+
let cursor = 0;
|
|
966
|
+
for (const link of scanned.links) {
|
|
967
|
+
const start = Math.max(link.start, cursor);
|
|
968
|
+
const end = Math.min(link.end, source.length);
|
|
969
|
+
if (end <= start)
|
|
970
|
+
continue;
|
|
971
|
+
text += source.slice(cursor, start);
|
|
972
|
+
text += source.slice(start, end).replace(/[^\n\r]/g, ' ');
|
|
973
|
+
cursor = end;
|
|
974
|
+
}
|
|
975
|
+
text += source.slice(cursor);
|
|
976
|
+
const out = [];
|
|
977
|
+
BARE_REF.lastIndex = 0;
|
|
978
|
+
for (let m = BARE_REF.exec(text); m !== null; m = BARE_REF.exec(text)) {
|
|
979
|
+
const prefix = m[1].toLowerCase();
|
|
980
|
+
if (NOT_A_FAMILY.has(prefix))
|
|
981
|
+
continue;
|
|
982
|
+
if (prefix.length < 2)
|
|
983
|
+
continue;
|
|
984
|
+
const start = m.index ?? 0;
|
|
985
|
+
// A token glued to a path separator or an extension is not a citation.
|
|
986
|
+
const before = text[start - 1] ?? ' ';
|
|
987
|
+
const after = text[start + m[0].length] ?? ' ';
|
|
988
|
+
if (before === '/' || before === '.' || after === '/')
|
|
989
|
+
continue;
|
|
990
|
+
out.push({ text: m[0], start, end: start + m[0].length });
|
|
991
|
+
}
|
|
992
|
+
return out;
|
|
993
|
+
}
|
|
994
|
+
/* -------------------------------------------------------------------------- */
|
|
995
|
+
/* Helpers */
|
|
996
|
+
/* -------------------------------------------------------------------------- */
|
|
997
|
+
function firstValue(byKey, keys) {
|
|
998
|
+
for (const key of keys) {
|
|
999
|
+
const entry = byKey.get(key);
|
|
1000
|
+
if (!entry)
|
|
1001
|
+
continue;
|
|
1002
|
+
const value = asString(entry);
|
|
1003
|
+
if (value !== null && value.length > 0)
|
|
1004
|
+
return value;
|
|
1005
|
+
}
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
function asString(entry) {
|
|
1009
|
+
if (!entry)
|
|
1010
|
+
return null;
|
|
1011
|
+
if (typeof entry.value === 'string')
|
|
1012
|
+
return entry.value.trim().length > 0 ? entry.value.trim() : null;
|
|
1013
|
+
return entry.value.length > 0 ? entry.value[0] : null;
|
|
1014
|
+
}
|
|
1015
|
+
/** Strips Markdown decoration a target may still be wearing. */
|
|
1016
|
+
function cleanTarget(value) {
|
|
1017
|
+
return value
|
|
1018
|
+
.trim()
|
|
1019
|
+
.replace(/^[`'"<(\[]+/, '')
|
|
1020
|
+
.replace(/[`'">)\]]+$/, '')
|
|
1021
|
+
.replace(/[.,;]+$/, '')
|
|
1022
|
+
.trim();
|
|
1023
|
+
}
|
|
1024
|
+
function stripAnchor(target) {
|
|
1025
|
+
const hash = target.indexOf('#');
|
|
1026
|
+
return hash <= 0 ? target : target.slice(0, hash);
|
|
1027
|
+
}
|
|
1028
|
+
function renderLink(link) {
|
|
1029
|
+
switch (link.form) {
|
|
1030
|
+
case 'wiki':
|
|
1031
|
+
return `[[${link.target}]]`;
|
|
1032
|
+
case 'autolink':
|
|
1033
|
+
return `<${link.target}>`;
|
|
1034
|
+
case 'reference':
|
|
1035
|
+
case 'shortcut':
|
|
1036
|
+
return `[${link.text}][${link.target}]`;
|
|
1037
|
+
default:
|
|
1038
|
+
return `[${link.text}](${link.target})`;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/** Exposed for tests and for callers that classify their own sections. */
|
|
1042
|
+
export { OBLIGATION_SECTIONS, sectionPathAt, WEAK_SECTIONS };
|
|
1043
|
+
//# sourceMappingURL=extract.js.map
|