dotmd-cli 0.86.0 → 0.87.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/bin/dotmd.mjs +19 -0
- package/package.json +1 -1
- package/src/commands.mjs +1 -0
- package/src/decisions.mjs +540 -0
- package/src/fix-refs.mjs +12 -14
- package/src/render.mjs +1 -9
- package/src/validate.mjs +32 -39
package/bin/dotmd.mjs
CHANGED
|
@@ -248,6 +248,7 @@ Analyze:
|
|
|
248
248
|
diff [file] [--summarize] Show changes since last updated date
|
|
249
249
|
summary <file> [--json] AI summary of a document
|
|
250
250
|
glossary <term> [--list] [--json] Look up domain terms + related docs
|
|
251
|
+
decisions [doc|id] [--all|--check] Open and held decisions, read from the corpus
|
|
251
252
|
|
|
252
253
|
Validate & Fix:
|
|
253
254
|
doctor [--apply] Auto-fix everything: refs, membership, lint, long fields, dates, index (preview by default)
|
|
@@ -1329,6 +1330,23 @@ plans, and checklist progress. Plans-only view.
|
|
|
1329
1330
|
Options:
|
|
1330
1331
|
--json Output as JSON`,
|
|
1331
1332
|
|
|
1333
|
+
decisions: `runlist decisions — the open and held decisions, read from the corpus
|
|
1334
|
+
|
|
1335
|
+
runlist decisions every open and held decision, one line each
|
|
1336
|
+
runlist decisions <doc> one document, by path, filename or slug
|
|
1337
|
+
runlist decisions <ID> one record, whole, as it is written
|
|
1338
|
+
runlist decisions --all every disposition, not only the pending ones
|
|
1339
|
+
runlist decisions --json the same rows as data
|
|
1340
|
+
runlist decisions --check every item it could not read or that is incomplete; exit 1 if any
|
|
1341
|
+
|
|
1342
|
+
A decision is an item in a decisions section (a heading naming the section word
|
|
1343
|
+
in its first 4 words): a heading, bold lead, bullet or table row whose first
|
|
1344
|
+
token is an id. A register is a fenced block whose first line carries
|
|
1345
|
+
decisions.register.statusLine; its rows are \`ID text\`. A disposition is read
|
|
1346
|
+
from a \`Disposition:\` line or a row's opening word, then from prose when
|
|
1347
|
+
decisions.prose is on, then from the block or bold lead above it. Configure it
|
|
1348
|
+
with \`export const decisions = { ... }\` (see src/decisions.mjs).`,
|
|
1349
|
+
|
|
1332
1350
|
glossary: `runlist glossary <term> — look up domain terms and related docs
|
|
1333
1351
|
|
|
1334
1352
|
Searches the glossary table in your docs for matching terms.
|
|
@@ -1898,6 +1916,7 @@ async function main() {
|
|
|
1898
1916
|
if (command === 'flags') { const { runFlags } = await import('../src/flags.mjs'); runFlags(restArgs, config); return; }
|
|
1899
1917
|
if (command === 'flag') { const { runFlag } = await import('../src/flags.mjs'); runFlag(restArgs, config); return; }
|
|
1900
1918
|
if (command === 'glossary') { const { runGlossary } = await import('../src/glossary.mjs'); runGlossary(restArgs, config); return; }
|
|
1919
|
+
if (command === 'decisions') { const { runDecisions } = await import('../src/decisions.mjs'); runDecisions(restArgs, config); return; }
|
|
1901
1920
|
if (command === 'export') { const { runExport } = await import('../src/export.mjs'); runExport(restArgs, config, { dryRun, root: rootArg, type: typeArg }); return; }
|
|
1902
1921
|
|
|
1903
1922
|
// Lifecycle commands
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -88,6 +88,7 @@ const definitions = [
|
|
|
88
88
|
form('sync <check-name> [findings]', { subcommands: ['sync'], args: positionals(1, 2), dashPositionalsAfter: 1 }),
|
|
89
89
|
]),
|
|
90
90
|
command('glossary', none, 'read', [form('[term]', { args: positionals(0, 1), options: [flag('--list'), flag('--json')] })]),
|
|
91
|
+
command('decisions', none, 'read', [form('[doc-or-id]', { args: positionals(0, 1), options: [flag('--all'), flag('--json'), flag('--check')] })]),
|
|
91
92
|
command('modules', none, 'read', [form('', { options: [value('--sort'), value('--limit'), flag('--all'), flag('--json')] })]),
|
|
92
93
|
command('module', none, 'read', [form('<name>', { args: positionals(1, 1), options: [value('--sort'), flag('--json')] })]),
|
|
93
94
|
command('surfaces', none, 'read', [form('', { options: [flag('--json')] })]),
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { buildIndex, resolveDocArg } from './index.mjs';
|
|
4
|
+
import { die } from './util.mjs';
|
|
5
|
+
|
|
6
|
+
// `runlist decisions` — the open decisions, read from the corpus, never typed.
|
|
7
|
+
//
|
|
8
|
+
// A decision is an item inside a decisions section of a document: a heading,
|
|
9
|
+
// a bold lead, a list bullet or a table row whose first token is an id. A
|
|
10
|
+
// register is a fenced block whose first line carries the configured status
|
|
11
|
+
// line; its rows are `ID text` and index the records the documents hold.
|
|
12
|
+
//
|
|
13
|
+
// Config (`runlist.config.mjs`), every key optional:
|
|
14
|
+
// export const decisions = {
|
|
15
|
+
// section: 'Decisions', // the word a decisions heading names
|
|
16
|
+
// types: ['plan'], // which document types carry decisions
|
|
17
|
+
// paths: ['docs/plans'], // only documents under these, when set
|
|
18
|
+
// id: '[A-Z]{1,3}-?\\d{1,3}[a-z]?',
|
|
19
|
+
// prose: false, // also read a disposition out of prose
|
|
20
|
+
// vocabulary: { open: [...], held: [...], ruled: [...], closed: [...] },
|
|
21
|
+
// patterns: { ruled: ['regex source', ...] }, // extra prose markers per disposition
|
|
22
|
+
// requires: { open: ['prose', 'citation', 'answers'] },
|
|
23
|
+
// answers: ['regex source', ...], // extra phrases the answers detector accepts
|
|
24
|
+
// register: { file, statusLine },
|
|
25
|
+
// listHeading: 'Waiting on a decision:',
|
|
26
|
+
// };
|
|
27
|
+
|
|
28
|
+
export const DEFAULTS = Object.freeze({
|
|
29
|
+
section: 'Decisions',
|
|
30
|
+
types: ['plan'],
|
|
31
|
+
paths: null,
|
|
32
|
+
id: '[A-Z]{1,3}-?[A-Z]?\\d{1,3}[a-z]?',
|
|
33
|
+
prose: false,
|
|
34
|
+
vocabulary: {
|
|
35
|
+
ruled: ['ruled', 'ratified', 'resolved', 'approved', 'decided', 'answered', 'settled', 'locked'],
|
|
36
|
+
closed: ['closed', 'withdrawn', 'superseded', 'overtaken', 'retired', 'cancelled', 'canceled', 'not a decision'],
|
|
37
|
+
held: ['held', 'on hold', 'deferred', 'paused', 'parked', 'blocked'],
|
|
38
|
+
open: ['open', 'unruled', 'unratified', 'pending', 'awaiting', 'undecided', 'unanswered', 'tbd'],
|
|
39
|
+
},
|
|
40
|
+
patterns: {},
|
|
41
|
+
requires: {},
|
|
42
|
+
answers: [],
|
|
43
|
+
register: null,
|
|
44
|
+
listHeading: 'Waiting on a decision:',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const PENDING = new Set(['open', 'held']);
|
|
48
|
+
const KINDS = ['ruled', 'closed', 'held', 'open'];
|
|
49
|
+
const MAX_BODY_LINES = 24;
|
|
50
|
+
|
|
51
|
+
const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
52
|
+
|
|
53
|
+
export function decisionSettings(raw = {}) {
|
|
54
|
+
const s = { ...DEFAULTS, ...raw };
|
|
55
|
+
s.vocabulary = { ...DEFAULTS.vocabulary, ...(raw.vocabulary ?? {}) };
|
|
56
|
+
s.types = [].concat(s.types ?? DEFAULTS.types);
|
|
57
|
+
return s;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Grammar ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
function wordsRe(words) {
|
|
63
|
+
return words.map(w => escapeRe(w).replace(/\s+/g, '\\s+')).join('|');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A heading opens a decisions scope when the section word is one of its first
|
|
67
|
+
// 4 words, after any leading enumerator or marker. A heading that only
|
|
68
|
+
// mentions a decision further along is about something else.
|
|
69
|
+
export function isDecisionHeading(text, section = DEFAULTS.section) {
|
|
70
|
+
const stem = section.toLowerCase().replace(/s$/, '');
|
|
71
|
+
const stripped = text
|
|
72
|
+
.replace(/^[^\p{L}\p{N}]+/u, '')
|
|
73
|
+
.replace(/^(?:[A-Z]|[A-Z]?\d+[A-Za-z]?(?:\.\d+)*)[.)]\s+(?:[—–-]\s+)?/, '')
|
|
74
|
+
.replace(/^[^\p{L}\p{N}]+/u, '');
|
|
75
|
+
const words = stripped.split(/\s+/)
|
|
76
|
+
.filter(w => /[\p{L}\p{N}]/u.test(w))
|
|
77
|
+
.slice(0, 4)
|
|
78
|
+
.map(w => w.toLowerCase().replace(/[^\p{L}\p{N}-]+$/u, ''));
|
|
79
|
+
return words.some(w => w === stem || w === `${stem}s`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function itemPatterns(id) {
|
|
83
|
+
return [
|
|
84
|
+
{ kind: 'register', re: new RegExp(`^(${id})\\s{1,3}(\\S.*)$`), registerOnly: true },
|
|
85
|
+
{ kind: 'table', re: new RegExp(`^\\|\\s*\\**(${id})\\**\\s*\\|(.*)$`) },
|
|
86
|
+
{ kind: 'heading', re: new RegExp(`^(#{2,6})\\s+\\**(${id})\\b[\\s,.:—-]*(.*)$`) },
|
|
87
|
+
{ kind: 'record', re: new RegExp(`^\\*\\*(${id})\\b[\\s,.:—-]+(.*)$`) },
|
|
88
|
+
{ kind: 'list', re: new RegExp(`^[-*]\\s+(?:\\[[ xX]\\]\\s+)?\\**(${id})\\b[\\s,.:—-]+(.*)$`) },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// An item that presents as a decision and carries no id: a bold `Decision`
|
|
93
|
+
// lead inside a decisions section.
|
|
94
|
+
const UNNAMED = /^(?:[-*]\s+(?:\[[ xX]\]\s+)?)?\*\*Decisions?\b/i;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every decision item in one document's text, in file order. Pure.
|
|
98
|
+
* Returns `{id, line, kind, level, context, lines, text}`; `id` is null for an
|
|
99
|
+
* unnamed item.
|
|
100
|
+
*/
|
|
101
|
+
export function parseDecisionItems(text, settings = DEFAULTS) {
|
|
102
|
+
const s = decisionSettings(settings);
|
|
103
|
+
const lines = text.split('\n');
|
|
104
|
+
// Frontmatter is blanked so line numbers stay file coordinates.
|
|
105
|
+
if (lines[0] === '---') {
|
|
106
|
+
const end = lines.indexOf('---', 1);
|
|
107
|
+
if (end > 0) for (let i = 0; i <= end; i++) lines[i] = '';
|
|
108
|
+
}
|
|
109
|
+
const patterns = itemPatterns(s.id);
|
|
110
|
+
const statusLine = s.register?.statusLine?.trim().toLowerCase() ?? null;
|
|
111
|
+
const vocab = s.vocabulary;
|
|
112
|
+
const items = [];
|
|
113
|
+
|
|
114
|
+
let fence = null;
|
|
115
|
+
let inRegister = false;
|
|
116
|
+
let scope = 0; // the level of the decisions heading in force, 0 for none
|
|
117
|
+
let context = null;
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < lines.length; i++) {
|
|
120
|
+
const line = lines[i].trimEnd();
|
|
121
|
+
const f = line.match(/^\s*(`{3,}|~{3,})/);
|
|
122
|
+
if (f) {
|
|
123
|
+
if (fence === null) {
|
|
124
|
+
fence = f[1][0];
|
|
125
|
+
const first = (lines[i + 1] ?? '').trim();
|
|
126
|
+
inRegister = !!statusLine && first.toLowerCase().includes(statusLine);
|
|
127
|
+
context = inRegister ? leadingDisposition(first, vocab) : null;
|
|
128
|
+
if (inRegister) i++; // the status line is not a row
|
|
129
|
+
} else if (f[1][0] === fence) {
|
|
130
|
+
fence = null;
|
|
131
|
+
inRegister = false;
|
|
132
|
+
context = null;
|
|
133
|
+
}
|
|
134
|
+
closeOpen(items);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (fence !== null && !inRegister) continue;
|
|
138
|
+
|
|
139
|
+
if (fence === null) {
|
|
140
|
+
const h = line.match(/^(#{1,6})\s+(.+)$/);
|
|
141
|
+
if (h) {
|
|
142
|
+
const level = h[1].length;
|
|
143
|
+
// A heading that shouts a disposition (`Decisions, both RESOLVED
|
|
144
|
+
// 2025-01-01`) marks the items under it, as a bold lead does.
|
|
145
|
+
context = s.prose ? proseDisposition(h[2], vocab, s.patterns, { shoutedOnly: true }) : null;
|
|
146
|
+
const open = items.at(-1);
|
|
147
|
+
if (open && !open.closed && open.kind === 'heading' && level > open.level) {
|
|
148
|
+
// A subheading inside a heading record is part of it.
|
|
149
|
+
} else {
|
|
150
|
+
closeOpen(items);
|
|
151
|
+
}
|
|
152
|
+
if (isDecisionHeading(h[2], s.section)) scope = level;
|
|
153
|
+
else if (scope && level <= scope) scope = 0;
|
|
154
|
+
}
|
|
155
|
+
const lead = line.match(/^\*\*([^*]{1,120})\*\*/);
|
|
156
|
+
if (lead && !patterns.some(p => p.re.test(line))) {
|
|
157
|
+
const kind = leadingDisposition(lead[1], vocab);
|
|
158
|
+
if (kind) context = kind;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const inScope = inRegister || scope > 0;
|
|
163
|
+
let matched = false;
|
|
164
|
+
if (inScope) {
|
|
165
|
+
for (const p of patterns) {
|
|
166
|
+
if (Boolean(p.registerOnly) !== inRegister) continue;
|
|
167
|
+
const m = line.match(p.re);
|
|
168
|
+
if (!m) continue;
|
|
169
|
+
closeOpen(items);
|
|
170
|
+
const heading = p.kind === 'heading';
|
|
171
|
+
items.push({
|
|
172
|
+
id: heading ? m[2] : m[1],
|
|
173
|
+
line: i + 1,
|
|
174
|
+
kind: p.kind,
|
|
175
|
+
level: heading ? m[1].length : 0,
|
|
176
|
+
context,
|
|
177
|
+
lines: [heading ? m[3] : m[2]],
|
|
178
|
+
});
|
|
179
|
+
matched = true;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
if (!matched && !inRegister && UNNAMED.test(line)) {
|
|
183
|
+
closeOpen(items);
|
|
184
|
+
items.push({ id: null, line: i + 1, kind: 'unnamed', level: 0, context, lines: [line] });
|
|
185
|
+
matched = true;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (matched) continue;
|
|
189
|
+
|
|
190
|
+
const open = items.at(-1);
|
|
191
|
+
if (!open || open.closed) continue;
|
|
192
|
+
if (open.kind === 'register') { open.closed = true; continue; }
|
|
193
|
+
if (open.kind === 'heading') {
|
|
194
|
+
if (/^#{1,6}\s/.test(line) && line.match(/^(#{1,6})/)[1].length <= open.level) { open.closed = true; continue; }
|
|
195
|
+
open.lines.push(line);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (line.trim() === '' || /^#{1,6}\s/.test(line) || open.lines.length >= MAX_BODY_LINES) {
|
|
199
|
+
if (line.trim() === '' && open.lines.length < MAX_BODY_LINES && !open.sawBlank) { open.sawBlank = true; continue; }
|
|
200
|
+
open.closed = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
open.lines.push(line);
|
|
204
|
+
open.sawBlank = false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return items.map(({ id, line, kind, level, context: ctx, lines: body }) => ({
|
|
208
|
+
id, line, kind, level, context: ctx, text: body.join('\n').trim(),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function closeOpen(items) {
|
|
213
|
+
const open = items.at(-1);
|
|
214
|
+
if (open) open.closed = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Disposition ──────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/** The disposition a line opens with (`RULED 2026-09-21: …`, `Open, waiting…`). */
|
|
220
|
+
export function leadingDisposition(text, vocab = DEFAULTS.vocabulary) {
|
|
221
|
+
const t = text.replace(/^[\s*_`]+/, '');
|
|
222
|
+
for (const kind of KINDS) {
|
|
223
|
+
if (new RegExp(`^(?:${wordsRe(vocab[kind] ?? [])})\\b`, 'i').test(t)) return kind;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** An explicit `Disposition: RULED 2026-09-21` line, or a shouted opening word. */
|
|
229
|
+
export function explicitDisposition(text, vocab = DEFAULTS.vocabulary) {
|
|
230
|
+
const line = text.match(/\bDisposition\**\s*:\s*\**\s*(\S[^\n]*)$/im);
|
|
231
|
+
if (line) return leadingDisposition(line[1], vocab);
|
|
232
|
+
const first = text.trimStart().match(/^[A-Z][A-Z ]*\b/);
|
|
233
|
+
if (first) {
|
|
234
|
+
const kind = leadingDisposition(first[0], vocab);
|
|
235
|
+
if (kind) return kind;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const DATE = '20\\d{2}-\\d{2}-\\d{2}';
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every disposition word in prose, in order. A ruling word counts only with a
|
|
244
|
+
* date right against it; a closing word only with a reason after it.
|
|
245
|
+
*/
|
|
246
|
+
export function proseMarkers(text, vocab = DEFAULTS.vocabulary, patterns = {}) {
|
|
247
|
+
const found = [];
|
|
248
|
+
const add = (kind, re, test = () => true) => {
|
|
249
|
+
for (const m of text.matchAll(re)) {
|
|
250
|
+
if (!test(m)) continue;
|
|
251
|
+
found.push({ kind, index: m.index, caps: /[A-Z]/.test(m[1]) && m[1] === m[1].toUpperCase() });
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
add('ruled', new RegExp(`\\b(${wordsRe(vocab.ruled ?? [])})\\b[,:;—-]?\\s*(?:\\([^)]{0,48}?)?(?:on\\s+|at\\s+|the\\s+)?${DATE}`, 'gi'));
|
|
255
|
+
add('closed', new RegExp(`\\b(${wordsRe(vocab.closed ?? [])})\\b`, 'gi'),
|
|
256
|
+
m => text.slice(m.index + m[0].length).replace(/[^a-z]/gi, '').length >= 12);
|
|
257
|
+
add('held', new RegExp(`\\b(${wordsRe(vocab.held ?? [])})\\b`, 'gi'));
|
|
258
|
+
add('open', new RegExp(`\\b(${wordsRe(vocab.open ?? [])})\\b`, 'gi'));
|
|
259
|
+
for (const kind of KINDS) {
|
|
260
|
+
for (const src of patterns[kind] ?? []) add(kind, new RegExp(`(${src})`, 'gi'));
|
|
261
|
+
}
|
|
262
|
+
return found.sort((a, b) => a.index - b.index);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Prose disposition: a shouted word is the row's own line and the last one
|
|
267
|
+
* wins, because rows are edited in place; with nothing shouted, an answer
|
|
268
|
+
* outranks a question.
|
|
269
|
+
*/
|
|
270
|
+
export function proseDisposition(text, vocab = DEFAULTS.vocabulary, patterns = {}, { shoutedOnly = false } = {}) {
|
|
271
|
+
const markers = proseMarkers(text, vocab, patterns);
|
|
272
|
+
if (!markers.length) return null;
|
|
273
|
+
const caps = markers.filter(m => m.caps);
|
|
274
|
+
if (caps.length) return caps.at(-1).kind;
|
|
275
|
+
if (shoutedOnly) return null;
|
|
276
|
+
for (const kind of KINDS) if (markers.some(m => m.kind === kind)) return kind;
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Explicit line first, then a shouted word in the item, then the block or bold
|
|
281
|
+
// lead the item sits under, then any word in its prose. A lead such as
|
|
282
|
+
// `**Ruled 2026-07-02:**` is a deliberate mark over the rows below it; a
|
|
283
|
+
// lowercase `open` inside a row is as often about something else.
|
|
284
|
+
export function dispositionOf(item, settings = DEFAULTS) {
|
|
285
|
+
const s = decisionSettings(settings);
|
|
286
|
+
const prose = opts => (s.prose ? proseDisposition(item.text, s.vocabulary, s.patterns, opts) : null);
|
|
287
|
+
return explicitDisposition(item.text, s.vocabulary)
|
|
288
|
+
?? prose({ shoutedOnly: true })
|
|
289
|
+
?? item.context
|
|
290
|
+
?? prose()
|
|
291
|
+
?? null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── The record's parts ───────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const CITATION = [
|
|
297
|
+
/`[^`]*\.(?:ts|tsx|swift|mjs|js|sql|json|sh|py|yaml|yml|kt|java|rs|css|graphql|go|rb)(?::\d+)?[^`]*`/,
|
|
298
|
+
/\b[\w@./-]+\.(?:ts|tsx|swift|mjs|sql|graphql|go|rb|py)(?::\d+(?:-\d+)?)?\b/,
|
|
299
|
+
/`[^`]*:\d+(?:-\d+)?`/,
|
|
300
|
+
/\[[^\]]+\]\([^)]*\.md[^)]*\)/,
|
|
301
|
+
/\b[\w-]+\.md\b/,
|
|
302
|
+
];
|
|
303
|
+
const ANSWER_PHRASE = /\b(each answer|either answer|both answers|the alternative|what visibly (?:changes|differs))\b/i;
|
|
304
|
+
const OPTION_CLAUSE = /(?:^|[.;!?]\s+|\|\s*|\*\*)([A-Z][^.:;|\n]{1,70}):\s+[a-z"']/g;
|
|
305
|
+
|
|
306
|
+
export function sentences(text) {
|
|
307
|
+
return text.replace(/\s+/g, ' ').split(/(?<=[.?!])\s+(?=[A-Z"'`*(])/).map(t => t.trim()).filter(Boolean);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Which parts an item's own text carries, from the detectors the tool ships. */
|
|
311
|
+
export function recordParts(text, settings = DEFAULTS) {
|
|
312
|
+
const s = decisionSettings(settings);
|
|
313
|
+
const body = text.replace(/\s+/g, ' ').trim();
|
|
314
|
+
const prose = sentences(text).some(t => !t.endsWith('?') && t.replace(/[^a-z]/gi, '').length >= 40);
|
|
315
|
+
const citation = CITATION.some(re => re.test(text));
|
|
316
|
+
const extra = (s.answers ?? []).map(src => new RegExp(src, 'i'));
|
|
317
|
+
const answers = ANSWER_PHRASE.test(body)
|
|
318
|
+
|| extra.some(re => re.test(body))
|
|
319
|
+
|| [...body.matchAll(OPTION_CLAUSE)].length >= 2
|
|
320
|
+
|| (/\bYes\b\s*[:,]/i.test(body) && /\bNo\b\s*[:,]/i.test(body));
|
|
321
|
+
return { prose, citation, answers };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Documents an item points at, by basename. */
|
|
325
|
+
export function pointedDocs(text) {
|
|
326
|
+
const out = new Set();
|
|
327
|
+
for (const m of text.matchAll(/\[[^\]]*\]\(([^)\s]*\.md)(?:#[^)]*)?\)/g)) out.add(m[1].split('/').pop());
|
|
328
|
+
for (const m of text.matchAll(/\b([\w.-]+\.md)\b/g)) out.add(m[1]);
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Assembly ─────────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Every item across the given documents with its disposition, its parts (its
|
|
336
|
+
* own plus a linked record's) and what it is missing. Pure.
|
|
337
|
+
* @param {{path: string, text: string}[]} docs
|
|
338
|
+
*/
|
|
339
|
+
export function analyzeDecisions(docs, settings = DEFAULTS) {
|
|
340
|
+
const s = decisionSettings(settings);
|
|
341
|
+
const items = [];
|
|
342
|
+
for (const doc of docs) {
|
|
343
|
+
for (const item of parseDecisionItems(doc.text, s)) {
|
|
344
|
+
items.push({
|
|
345
|
+
...item,
|
|
346
|
+
doc: doc.path,
|
|
347
|
+
file: path.basename(doc.path),
|
|
348
|
+
disposition: dispositionOf(item, s),
|
|
349
|
+
own: recordParts(item.text, s),
|
|
350
|
+
points: pointedDocs(item.text),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const byId = new Map();
|
|
356
|
+
for (const item of items) {
|
|
357
|
+
if (!item.id) continue;
|
|
358
|
+
if (!byId.has(item.id)) byId.set(item.id, []);
|
|
359
|
+
byId.get(item.id).push(item);
|
|
360
|
+
}
|
|
361
|
+
const linked = (a, b) => a !== b && a.file !== b.file && (a.points.has(b.file) || b.points.has(a.file));
|
|
362
|
+
|
|
363
|
+
for (const item of items) {
|
|
364
|
+
item.peers = item.id ? byId.get(item.id).filter(p => linked(item, p)) : [];
|
|
365
|
+
const parts = { ...item.own };
|
|
366
|
+
for (const peer of item.peers) for (const k of Object.keys(parts)) if (peer.own[k]) parts[k] = true;
|
|
367
|
+
item.parts = parts;
|
|
368
|
+
// A register row that indexes this record governs its disposition.
|
|
369
|
+
const row = item.kind === 'register' ? null : item.peers.find(p => p.kind === 'register');
|
|
370
|
+
item.effective = row?.disposition ?? item.disposition;
|
|
371
|
+
const missing = [];
|
|
372
|
+
if (!item.id) missing.push('id');
|
|
373
|
+
if (!item.disposition) missing.push('disposition');
|
|
374
|
+
for (const part of s.requires[item.disposition] ?? []) if (!parts[part]) missing.push(part);
|
|
375
|
+
item.missing = missing;
|
|
376
|
+
}
|
|
377
|
+
return items;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The pending rows, one per decision: linked pairs collapse, a register row preferred. */
|
|
381
|
+
export function pendingRows(items, { doc = null, all = false } = {}) {
|
|
382
|
+
let rows = items.filter(i => i.id && (all || PENDING.has(i.effective)));
|
|
383
|
+
if (doc) rows = rows.filter(i => i.doc === doc);
|
|
384
|
+
const kept = [];
|
|
385
|
+
const rank = i => (i.kind === 'register' ? 0 : 1);
|
|
386
|
+
for (const item of [...rows].sort((a, b) => rank(a) - rank(b))) {
|
|
387
|
+
const dup = kept.find(k => k.id === item.id && (k.doc === item.doc || k.peers.includes(item)));
|
|
388
|
+
if (!dup) kept.push(item);
|
|
389
|
+
}
|
|
390
|
+
const shared = new Map();
|
|
391
|
+
for (const k of kept) shared.set(k.id, (shared.get(k.id) ?? 0) + 1);
|
|
392
|
+
return kept
|
|
393
|
+
.sort((a, b) => a.id.localeCompare(b.id, 'en', { numeric: true }) || a.doc.localeCompare(b.doc))
|
|
394
|
+
.map(item => ({
|
|
395
|
+
id: item.id,
|
|
396
|
+
label: shared.get(item.id) > 1 ? `${item.id} (${item.file})` : item.id,
|
|
397
|
+
doc: item.doc,
|
|
398
|
+
line: item.line,
|
|
399
|
+
kind: item.kind,
|
|
400
|
+
disposition: item.effective,
|
|
401
|
+
missing: item.missing,
|
|
402
|
+
text: renderLine(item),
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const clean = t => t
|
|
407
|
+
.replace(/\|/g, ' ')
|
|
408
|
+
.replace(/\*\*/g, '')
|
|
409
|
+
.replace(/`/g, '')
|
|
410
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
411
|
+
.replace(/^\s*(?:[-*]\s+)?Disposition\s*:[^\n]*$/gim, '')
|
|
412
|
+
.replace(/\s+/g, ' ')
|
|
413
|
+
.trim();
|
|
414
|
+
|
|
415
|
+
function renderLine(item) {
|
|
416
|
+
const partsMissing = item.missing.filter(m => m !== 'id' && m !== 'disposition');
|
|
417
|
+
if (partsMissing.length) return `(record incomplete: missing ${partsMissing.join(', ')})`;
|
|
418
|
+
const body = clean(item.text);
|
|
419
|
+
if (item.kind === 'register') return body;
|
|
420
|
+
const parts = sentences(body);
|
|
421
|
+
const at = parts.findIndex(t => t.endsWith('?'));
|
|
422
|
+
if (at === -1) return body.slice(0, 400);
|
|
423
|
+
return [parts[at], ...parts.slice(at + 1, at + 4)].join(' ');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Defects, one per finding, for `--check`. */
|
|
427
|
+
export function decisionDefects(items) {
|
|
428
|
+
const out = [];
|
|
429
|
+
for (const item of items) {
|
|
430
|
+
if (item.missing.length) {
|
|
431
|
+
out.push({ doc: item.doc, line: item.line, id: item.id, message: `missing ${item.missing.join(', ')}` });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const seen = new Map();
|
|
435
|
+
for (const item of items) {
|
|
436
|
+
if (!item.id) continue;
|
|
437
|
+
const key = `${item.doc}#${item.id}#${item.kind === 'register'}`;
|
|
438
|
+
const prior = seen.get(key);
|
|
439
|
+
if (prior) out.push({ doc: item.doc, line: item.line, id: item.id, message: `id used again in this document (first at line ${prior.line})` });
|
|
440
|
+
else seen.set(key, item);
|
|
441
|
+
}
|
|
442
|
+
for (const item of items) {
|
|
443
|
+
if (!PENDING.has(item.disposition)) continue;
|
|
444
|
+
for (const peer of item.peers) {
|
|
445
|
+
if (peer.disposition === 'ruled' || peer.disposition === 'closed') {
|
|
446
|
+
out.push({ doc: item.doc, line: item.line, id: item.id, message: `${item.disposition} here, ${peer.disposition} at ${peer.doc}:${peer.line}` });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out.sort((a, b) => a.doc.localeCompare(b.doc) || a.line - b.line);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
export function loadDecisionDocs(config, settings) {
|
|
456
|
+
const s = decisionSettings(settings);
|
|
457
|
+
const types = new Set(s.types);
|
|
458
|
+
const archiveDir = config.archiveDir ?? 'archived';
|
|
459
|
+
const { docs } = buildIndex(config, { fast: true });
|
|
460
|
+
return docs
|
|
461
|
+
// A document whose frontmatter did not parse has no type; it is read rather than dropped.
|
|
462
|
+
.filter(d => (types.has(d.type) || !d.type) && d.status !== 'archived' && !d.path.split('/').includes(archiveDir))
|
|
463
|
+
.filter(d => !s.paths || [].concat(s.paths).some(p => d.path === p || d.path.startsWith(`${p.replace(/\/$/, '')}/`)))
|
|
464
|
+
.map(d => ({ path: d.path, text: readFileSync(path.join(config.repoRoot, d.path), 'utf8') }));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function runDecisions(args, config) {
|
|
468
|
+
const settings = decisionSettings(config.raw?.decisions ?? {});
|
|
469
|
+
const json = args.includes('--json');
|
|
470
|
+
const all = args.includes('--all');
|
|
471
|
+
const check = args.includes('--check');
|
|
472
|
+
const target = args.find(a => !a.startsWith('-')) ?? null;
|
|
473
|
+
|
|
474
|
+
const docs = loadDecisionDocs(config, settings);
|
|
475
|
+
const items = analyzeDecisions(docs, settings);
|
|
476
|
+
|
|
477
|
+
if (check) {
|
|
478
|
+
const defects = decisionDefects(items);
|
|
479
|
+
if (json) process.stdout.write(`${JSON.stringify({ ok: defects.length === 0, items: items.length, defects }, null, 2)}\n`);
|
|
480
|
+
else {
|
|
481
|
+
for (const d of defects) process.stdout.write(`${d.doc}:${d.line} ${d.id ?? '(no id)'} ${d.message}\n`);
|
|
482
|
+
process.stdout.write(`${items.length} decision items in ${docs.length} documents; ${defects.length} defects.\n`);
|
|
483
|
+
}
|
|
484
|
+
if (defects.length) process.exitCode = 1;
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let docFilter = null;
|
|
489
|
+
if (target) {
|
|
490
|
+
const idRe = new RegExp(`^(?:${settings.id})$`);
|
|
491
|
+
const resolved = idRe.test(target) ? null : resolveDocArg(target, config, { dieOnMiss: false });
|
|
492
|
+
if (resolved) docFilter = path.relative(config.repoRoot, resolved).split(path.sep).join('/');
|
|
493
|
+
else if (idRe.test(target)) return showRecord(target, items, docs, json);
|
|
494
|
+
else die(`No document or decision id matches "${target}".`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const rows = pendingRows(items, { doc: docFilter, all });
|
|
498
|
+
if (json) {
|
|
499
|
+
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!rows.length) {
|
|
503
|
+
process.stdout.write(docFilter ? `No open or held decisions in ${docFilter}.\n` : 'No open or held decisions.\n');
|
|
504
|
+
} else {
|
|
505
|
+
process.stdout.write(`${settings.listHeading}\n\n`);
|
|
506
|
+
for (const row of rows) {
|
|
507
|
+
const tag = all ? `[${row.disposition ?? 'none'}] ` : '';
|
|
508
|
+
process.stdout.write(`${row.label} ${tag}${row.text}\n`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const unread = items.filter(i => (!docFilter || i.doc === docFilter) && (!i.id || !i.disposition));
|
|
512
|
+
if (unread.length) {
|
|
513
|
+
const noId = unread.filter(i => !i.id).length;
|
|
514
|
+
process.stderr.write(`${unread.length} decision items could not be read (${noId} with no id, ${unread.length - noId} with no disposition); runlist decisions --check lists them.\n`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function showRecord(id, items, docs, json) {
|
|
519
|
+
const hits = items.filter(i => i.id === id);
|
|
520
|
+
if (!hits.length) die(`No decision ${id} in any document.`);
|
|
521
|
+
if (json) {
|
|
522
|
+
process.stdout.write(`${JSON.stringify(hits.map(h => ({ id, doc: h.doc, line: h.line, kind: h.kind, disposition: h.disposition, missing: h.missing, text: h.text })), null, 2)}\n`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const texts = new Map(docs.map(d => [d.path, d.text.split('\n')]));
|
|
526
|
+
hits.forEach((h, n) => {
|
|
527
|
+
const lines = texts.get(h.doc);
|
|
528
|
+
const bodyLines = h.text.split('\n').filter(l => l.trim() !== '').length;
|
|
529
|
+
// The record as written: its first line through the last line of its body.
|
|
530
|
+
let end = h.line - 1;
|
|
531
|
+
let seen = 0;
|
|
532
|
+
while (end < lines.length && seen < bodyLines) {
|
|
533
|
+
if (lines[end].trim() !== '') seen++;
|
|
534
|
+
end++;
|
|
535
|
+
}
|
|
536
|
+
if (n) process.stdout.write('\n');
|
|
537
|
+
process.stdout.write(`${h.doc}:${h.line} ${h.disposition ?? 'no disposition'}\n`);
|
|
538
|
+
process.stdout.write(`${lines.slice(h.line - 1, end).join('\n').trimEnd()}\n`);
|
|
539
|
+
});
|
|
540
|
+
}
|
package/src/fix-refs.mjs
CHANGED
|
@@ -43,9 +43,7 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
43
43
|
|
|
44
44
|
// ── Fix broken frontmatter references ────────────────────────────────
|
|
45
45
|
|
|
46
|
-
const brokenRefErrors = index.errors.filter(
|
|
47
|
-
e.message.includes('does not resolve to an existing file')
|
|
48
|
-
);
|
|
46
|
+
const brokenRefErrors = index.errors.filter(error => error.meta?.kind === 'ref-resolution');
|
|
49
47
|
|
|
50
48
|
if (brokenRefErrors.length > 0) {
|
|
51
49
|
const fixesByDoc = new Map();
|
|
@@ -101,12 +99,12 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
101
99
|
// ── Fix broken body links ────────────────────────────────────────────
|
|
102
100
|
|
|
103
101
|
for (const doc of index.docs) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
&&
|
|
107
|
-
&&
|
|
102
|
+
const brokenBodyErrors = doc.errors.filter(error =>
|
|
103
|
+
error.meta?.kind === 'body-link-resolution'
|
|
104
|
+
&& error.meta?.targetKind === 'document'
|
|
105
|
+
&& error.meta?.reason === 'missing'
|
|
108
106
|
);
|
|
109
|
-
if (!
|
|
107
|
+
if (!brokenBodyErrors.length) continue;
|
|
110
108
|
|
|
111
109
|
const absPath = path.join(config.repoRoot, doc.path);
|
|
112
110
|
let raw = readFileSync(absPath, 'utf8');
|
|
@@ -116,10 +114,10 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
116
114
|
const bodyFixes = [];
|
|
117
115
|
const seenBodyTargets = new Set();
|
|
118
116
|
|
|
119
|
-
for (const
|
|
120
|
-
const brokenHref =
|
|
121
|
-
const rawHref =
|
|
122
|
-
const targetKey = `${
|
|
117
|
+
for (const error of brokenBodyErrors) {
|
|
118
|
+
const brokenHref = error.meta.relPath;
|
|
119
|
+
const rawHref = error.meta.rawHref ?? brokenHref;
|
|
120
|
+
const targetKey = `${error.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
|
|
123
121
|
if (seenBodyTargets.has(targetKey)) continue;
|
|
124
122
|
seenBodyTargets.add(targetKey);
|
|
125
123
|
const brokenBasename = path.basename(brokenHref);
|
|
@@ -133,11 +131,11 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
133
131
|
|
|
134
132
|
const suffixAt = rawHref.search(/[?#]/);
|
|
135
133
|
const suffix = suffixAt === -1 ? '' : rawHref.slice(suffixAt);
|
|
136
|
-
const renderedHref =
|
|
134
|
+
const renderedHref = error.meta.angle
|
|
137
135
|
? `<${correctHref}${suffix}>`
|
|
138
136
|
: `${correctHref.replace(/([\s()[\]<>])/g, '\\$1')}${suffix}`;
|
|
139
137
|
const linkRegex = new RegExp(
|
|
140
|
-
'(\\]\\(\\s*)' + (
|
|
138
|
+
'(\\]\\(\\s*)' + (error.meta.angle ? '<' : '') + escapeRegex(rawHref) + (error.meta.angle ? '>' : '') + '(?=\\s|\\))',
|
|
141
139
|
'g'
|
|
142
140
|
);
|
|
143
141
|
let replacements = 0;
|
package/src/render.mjs
CHANGED
|
@@ -516,15 +516,7 @@ export function renderManualFixes(index) {
|
|
|
516
516
|
}
|
|
517
517
|
|
|
518
518
|
export function buildReferenceValidationCoverage(index, config) {
|
|
519
|
-
|
|
520
|
-
let terminalDocsSkipped = 0;
|
|
521
|
-
for (const doc of index.docs) {
|
|
522
|
-
const terminal = config.lifecycle.isTerminal?.(doc.status, doc.type)
|
|
523
|
-
?? config.lifecycle.terminalStatuses.has(doc.status);
|
|
524
|
-
if (terminal) terminalDocsSkipped++;
|
|
525
|
-
else checkedDocs++;
|
|
526
|
-
}
|
|
527
|
-
return { checkedDocs, terminalDocsSkipped };
|
|
519
|
+
return { checkedDocs: index.docs.length, terminalDocsSkipped: 0 };
|
|
528
520
|
}
|
|
529
521
|
|
|
530
522
|
function _renderCheck(index, config, opts = {}) {
|
package/src/validate.mjs
CHANGED
|
@@ -254,52 +254,45 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// legitimately point at moved/deleted targets and shouldn't gate the
|
|
260
|
-
// exit code with a hard error.
|
|
257
|
+
// Historical documents still need navigable references. Their prose is a
|
|
258
|
+
// dated record, but links to moved or deleted files cannot be followed.
|
|
261
259
|
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
262
260
|
const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
for (const relPath of (doc.refFields[field] || [])) {
|
|
268
|
-
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
269
|
-
doc.errors.push({
|
|
270
|
-
path: doc.path,
|
|
271
|
-
level: 'error',
|
|
272
|
-
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
273
|
-
meta: { kind: 'ref-resolution', field, relPath },
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Validate body links resolve to existing files
|
|
280
|
-
for (const link of (doc.bodyLinks || [])) {
|
|
281
|
-
const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
|
|
282
|
-
if (!resolution.ok) {
|
|
283
|
-
const shownHref = link.rawHref ?? link.href;
|
|
284
|
-
doc.warnings.push({
|
|
261
|
+
for (const field of allRefFields) {
|
|
262
|
+
for (const relPath of (doc.refFields[field] || [])) {
|
|
263
|
+
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
264
|
+
doc.errors.push({
|
|
285
265
|
path: doc.path,
|
|
286
|
-
level: '
|
|
287
|
-
message:
|
|
288
|
-
|
|
289
|
-
: `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
|
|
290
|
-
meta: {
|
|
291
|
-
kind: 'body-link-resolution',
|
|
292
|
-
field: 'body-link',
|
|
293
|
-
relPath: link.href,
|
|
294
|
-
rawHref: shownHref,
|
|
295
|
-
targetKind: link.targetKind ?? 'document',
|
|
296
|
-
angle: link.angle === true,
|
|
297
|
-
reason: resolution.reason,
|
|
298
|
-
},
|
|
266
|
+
level: 'error',
|
|
267
|
+
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
268
|
+
meta: { kind: 'ref-resolution', field, relPath },
|
|
299
269
|
});
|
|
300
270
|
}
|
|
301
271
|
}
|
|
302
272
|
}
|
|
273
|
+
|
|
274
|
+
for (const link of (doc.bodyLinks || [])) {
|
|
275
|
+
const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
|
|
276
|
+
if (!resolution.ok) {
|
|
277
|
+
const shownHref = link.rawHref ?? link.href;
|
|
278
|
+
doc.errors.push({
|
|
279
|
+
path: doc.path,
|
|
280
|
+
level: 'error',
|
|
281
|
+
message: resolution.reason === 'outside-repo'
|
|
282
|
+
? `body link \`${shownHref}\` escapes the repository.`
|
|
283
|
+
: `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
|
|
284
|
+
meta: {
|
|
285
|
+
kind: 'body-link-resolution',
|
|
286
|
+
field: 'body-link',
|
|
287
|
+
relPath: link.href,
|
|
288
|
+
rawHref: shownHref,
|
|
289
|
+
targetKind: link.targetKind ?? 'document',
|
|
290
|
+
angle: link.angle === true,
|
|
291
|
+
reason: resolution.reason,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
303
296
|
}
|
|
304
297
|
|
|
305
298
|
// Guess which doc type a ref field expects so suggestions don't propose
|