@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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +627 -0
  3. package/bin/spec-graph.js +24 -0
  4. package/dist/baseline.d.ts +93 -0
  5. package/dist/baseline.js +187 -0
  6. package/dist/baseline.js.map +1 -0
  7. package/dist/cli.d.ts +66 -0
  8. package/dist/cli.js +565 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +66 -0
  11. package/dist/config.js +183 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/directives.d.ts +56 -0
  14. package/dist/directives.js +125 -0
  15. package/dist/directives.js.map +1 -0
  16. package/dist/extract.d.ts +116 -0
  17. package/dist/extract.js +1043 -0
  18. package/dist/extract.js.map +1 -0
  19. package/dist/glob.d.ts +73 -0
  20. package/dist/glob.js +284 -0
  21. package/dist/glob.js.map +1 -0
  22. package/dist/graph.d.ts +55 -0
  23. package/dist/graph.js +201 -0
  24. package/dist/graph.js.map +1 -0
  25. package/dist/identity.d.ts +87 -0
  26. package/dist/identity.js +294 -0
  27. package/dist/identity.js.map +1 -0
  28. package/dist/index.d.ts +63 -0
  29. package/dist/index.js +45 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/lifecycle.d.ts +57 -0
  32. package/dist/lifecycle.js +258 -0
  33. package/dist/lifecycle.js.map +1 -0
  34. package/dist/markdown.d.ts +152 -0
  35. package/dist/markdown.js +820 -0
  36. package/dist/markdown.js.map +1 -0
  37. package/dist/paths.d.ts +22 -0
  38. package/dist/paths.js +58 -0
  39. package/dist/paths.js.map +1 -0
  40. package/dist/report.d.ts +89 -0
  41. package/dist/report.js +326 -0
  42. package/dist/report.js.map +1 -0
  43. package/dist/resolve.d.ts +61 -0
  44. package/dist/resolve.js +340 -0
  45. package/dist/resolve.js.map +1 -0
  46. package/dist/rules.d.ts +61 -0
  47. package/dist/rules.js +557 -0
  48. package/dist/rules.js.map +1 -0
  49. package/dist/runner.d.ts +108 -0
  50. package/dist/runner.js +186 -0
  51. package/dist/runner.js.map +1 -0
  52. package/dist/sections.d.ts +94 -0
  53. package/dist/sections.js +372 -0
  54. package/dist/sections.js.map +1 -0
  55. package/dist/select.d.ts +96 -0
  56. package/dist/select.js +413 -0
  57. package/dist/select.js.map +1 -0
  58. package/dist/source.d.ts +43 -0
  59. package/dist/source.js +109 -0
  60. package/dist/source.js.map +1 -0
  61. package/dist/state.d.ts +45 -0
  62. package/dist/state.js +330 -0
  63. package/dist/state.js.map +1 -0
  64. package/dist/types.d.ts +294 -0
  65. package/dist/types.js +52 -0
  66. package/dist/types.js.map +1 -0
  67. package/dist/yaml.d.ts +37 -0
  68. package/dist/yaml.js +240 -0
  69. package/dist/yaml.js.map +1 -0
  70. package/package.json +71 -0
package/dist/rules.js ADDED
@@ -0,0 +1,557 @@
1
+ /**
2
+ * The built-in diagnostics.
3
+ *
4
+ * Two of them - the two the whole project exists for - are written as selector
5
+ * queries rather than as bespoke traversals. That is not a stunt: it is the
6
+ * proof that the query language is expressive enough to be worth exposing. A
7
+ * team whose convention spec-graph never anticipated can write the equivalent
8
+ * of a built-in rule on the command line.
9
+ *
10
+ * ```text
11
+ * ghost-handover *[openness!=closed][phase!=retired] -delegates-to,blocked-by-> *[receptivity=sealed]
12
+ * stale-premise *[phase!=retired] -depends-on,assumes,amends,blocked-by-> document[phase=retired]
13
+ * ```
14
+ *
15
+ * The rest are not path queries and are not pretended to be: dangling
16
+ * references never became edges, and a cycle is not a fixed-length path.
17
+ *
18
+ * Every rule here obeys one discipline - **report the place a human can fix**.
19
+ * For a supersession that the superseded document never acknowledged, that is
20
+ * the superseded document, not the one that declared the relation.
21
+ */
22
+ import { OBLIGATION_EDGES } from './graph.js';
23
+ import { dirnamePosix, resolveFrom } from './paths.js';
24
+ import { execute, parseQuery } from './select.js';
25
+ import { EDGE_TRAITS } from './types.js';
26
+ /* -------------------------------------------------------------------------- */
27
+ /* Configuration */
28
+ /* -------------------------------------------------------------------------- */
29
+ export const DEFAULT_SEVERITIES = Object.freeze({
30
+ 'ghost-handover': 'error',
31
+ 'stale-premise': 'error',
32
+ 'broken-reference': 'error',
33
+ 'reference-outside-corpus': 'warn',
34
+ 'ambiguous-reference': 'warn',
35
+ 'circular-delegation': 'error',
36
+ 'orphaned-obligation': 'error',
37
+ 'live-supersession': 'error',
38
+ 'unreciprocated-supersession': 'warn',
39
+ 'state-conflict': 'warn',
40
+ 'self-reference': 'info',
41
+ });
42
+ export const RULE_IDS = Object.keys(DEFAULT_SEVERITIES);
43
+ /** One-line descriptions, printed by `spec-graph rules`. */
44
+ export const RULE_DESCRIPTIONS = Object.freeze({
45
+ 'ghost-handover': 'an open obligation is handed to a document that can no longer absorb it',
46
+ 'stale-premise': 'a live document rests on a decision that has been retired or obviated',
47
+ 'broken-reference': 'a citation names a document or anchor that does not exist',
48
+ 'reference-outside-corpus': 'a citation names a real document the include patterns did not reach',
49
+ 'ambiguous-reference': 'a citation matches more than one document',
50
+ 'circular-delegation': 'obligations or supersessions form a cycle, so none of them can ever land',
51
+ 'orphaned-obligation': 'a retired or frozen document still holds open obligations',
52
+ 'live-supersession': 'a document has been superseded but still presents itself as current',
53
+ 'unreciprocated-supersession': 'a retired document does not say what replaced it',
54
+ 'state-conflict': 'an item declares two states that disagree about whether work remains',
55
+ 'self-reference': 'a document delegates to or depends on itself',
56
+ });
57
+ /**
58
+ * The severity map `--strict` implies, and which rules it actually moved.
59
+ *
60
+ * Strict raises `warn` to `error` and leaves `info` alone. That asymmetry is
61
+ * deliberate: the `info` rules are advisory by nature - a self-link in a table
62
+ * of contents is a formatting quirk - and promoting them would resurrect
63
+ * precisely the false-positive problem ADR-0006 exists to prevent.
64
+ *
65
+ * An explicit `--rule` always wins over strict, so `--strict --rule x=warn`
66
+ * keeps `x` at warn. That is what makes strict usable: a team can turn it on
67
+ * and exempt the one rule their repository disagrees with, rather than choosing
68
+ * between all of it and none of it.
69
+ */
70
+ export function resolveStrict(overrides = {}, strict = false) {
71
+ const severities = { ...overrides };
72
+ const escalated = new Set();
73
+ if (!strict)
74
+ return { severities, escalated };
75
+ for (const id of RULE_IDS) {
76
+ if (overrides[id] !== undefined)
77
+ continue;
78
+ if (DEFAULT_SEVERITIES[id] !== 'warn')
79
+ continue;
80
+ severities[id] = 'error';
81
+ escalated.add(id);
82
+ }
83
+ return { severities, escalated };
84
+ }
85
+ const DEFAULT_MAX_RELATED = 8;
86
+ /* -------------------------------------------------------------------------- */
87
+ /* Entry point */
88
+ /* -------------------------------------------------------------------------- */
89
+ /** Runs every enabled rule and returns findings in report order. */
90
+ export function runRules(graph, corpus, options = {}) {
91
+ const severities = { ...DEFAULT_SEVERITIES, ...(options.severities ?? {}) };
92
+ const maxRelated = options.maxRelated ?? DEFAULT_MAX_RELATED;
93
+ const out = [];
94
+ const emit = (rule, build) => {
95
+ const severity = severities[rule];
96
+ if (severity === 'off')
97
+ return;
98
+ const body = build();
99
+ if (exempt(graph, rule, body.nodes))
100
+ return;
101
+ // `target` ahead of the spread: a rule that names one overrides the default,
102
+ // and the eight that have nothing to name say nothing.
103
+ out.push({ rule, severity, target: null, ...body, related: body.related.slice(0, maxRelated) });
104
+ };
105
+ // Ghost handovers are reported first and claim their edges, because
106
+ // `blocked-by` is both obligation-transferring and load-bearing: without this,
107
+ // one open question blocked by an archived decision would produce two
108
+ // findings on the same line, for the same defect, with the same fix.
109
+ const claimed = ghostHandovers(graph, emit);
110
+ stalePremises(graph, emit, claimed);
111
+ brokenReferences(corpus, emit);
112
+ circularDelegations(graph, emit);
113
+ orphanedObligations(graph, emit, maxRelated);
114
+ supersessions(graph, emit);
115
+ stateConflicts(graph, emit);
116
+ selfReferences(graph, emit);
117
+ return sortDiagnostics(out);
118
+ }
119
+ /**
120
+ * Rules about what a document cites, rather than about what it owes.
121
+ *
122
+ * These are the ones a historical record still answers for. A link that goes
123
+ * nowhere is broken whoever wrote it and whenever they wrote it.
124
+ */
125
+ const REFERENCE_RULES = new Set([
126
+ 'broken-reference',
127
+ 'reference-outside-corpus',
128
+ 'ambiguous-reference',
129
+ ]);
130
+ /**
131
+ * Whether a finding is about history rather than about work.
132
+ *
133
+ * A record reports that something was decided; it does not decide anything. Its
134
+ * open checkboxes are minutes, its delegations are things that were said, and
135
+ * its lifecycle is not a lifecycle. So a record is never the *subject* of a
136
+ * finding about obligations or lifecycle - though it is still a legitimate
137
+ * target of one, because delegating live work into a log is exactly the ghost
138
+ * handover this tool exists to find.
139
+ *
140
+ * Stated once, here, rather than as a guard repeated in eight rules: the
141
+ * exemption is a property of what counts as a finding, not of any one rule, and
142
+ * a rule added later inherits it without having to remember. See ADR-0011.
143
+ */
144
+ function exempt(graph, rule, nodes) {
145
+ if (REFERENCE_RULES.has(rule))
146
+ return false;
147
+ const subject = nodes[0];
148
+ if (subject === undefined)
149
+ return false;
150
+ return graph.owningDocument(subject)?.phase === 'record';
151
+ }
152
+ /* -------------------------------------------------------------------------- */
153
+ /* Ghost handovers */
154
+ /* -------------------------------------------------------------------------- */
155
+ /**
156
+ * The flagship failure mode: an obligation handed into a closed ledger.
157
+ *
158
+ * The source must still be open and must not itself be retired - a retired
159
+ * document delegating to another retired document is history, not a defect,
160
+ * and reporting it would bury the live findings underneath the archive.
161
+ */
162
+ const GHOST_HANDOVER = parseQuery('*[openness!=closed][phase!=retired] -delegates-to,blocked-by-> *[receptivity=sealed]');
163
+ /** Emits ghost-handover findings and returns the edges they account for. */
164
+ function ghostHandovers(graph, emit) {
165
+ const claimed = new Set();
166
+ for (const match of execute(graph, GHOST_HANDOVER)) {
167
+ const source = match.nodes[0];
168
+ const target = match.nodes[1];
169
+ const edge = match.edges[0];
170
+ const targetDocument = graph.owningDocument(target.id);
171
+ if (!targetDocument)
172
+ continue;
173
+ const sealed = SEALED_WORD[targetDocument.phase] ?? 'frozen';
174
+ const what = source.kind === 'item' ? 'open obligation' : 'obligation';
175
+ const verb = EDGE_TRAITS[edge.kind].phrase;
176
+ claimed.add(edgeKey(edge));
177
+ emit('ghost-handover', () => ({
178
+ message: `${what} ${verb} ${describe(target)}, which is ${sealed}`,
179
+ at: edge.declaredAt,
180
+ nodes: [source.id, target.id],
181
+ related: [
182
+ related(targetDocument.statusAt ?? targetDocument.at, `${targetDocument.id} is ${sealed}${statusSuffix(targetDocument)}`),
183
+ ...(source.kind === 'item' ? [related(source.at, `the obligation: ${source.text}`)] : []),
184
+ ],
185
+ hint: HANDOVER_HINT[targetDocument.phase]?.(targetDocument.id) ?? FROZEN_HINT(targetDocument.id),
186
+ }));
187
+ }
188
+ return claimed;
189
+ }
190
+ /** How a sealed target is described in a ghost-handover message. */
191
+ const SEALED_WORD = {
192
+ retired: 'retired',
193
+ record: 'a historical record',
194
+ };
195
+ const FROZEN_HINT = (id) => `${id} is frozen and cannot take on new work - open an amendment, or close this here`;
196
+ /** What to do about work handed into each kind of sealed document. */
197
+ const HANDOVER_HINT = {
198
+ retired: (id) => `nothing will be read from ${id} again - re-home this in a live document, or close it here`,
199
+ record: (id) => `${id} is a log of what happened and will never act - re-home this in a live document`,
200
+ };
201
+ /** Identity of a relation, for the hand-off between overlapping rules. */
202
+ function edgeKey(edge) {
203
+ return `${edge.kind} ${edge.from} ${edge.to}`;
204
+ }
205
+ /* -------------------------------------------------------------------------- */
206
+ /* Stale premises */
207
+ /* -------------------------------------------------------------------------- */
208
+ /**
209
+ * A live document resting on something that stopped being true.
210
+ *
211
+ * Only load-bearing relations count. A "See also" pointing at a superseded ADR
212
+ * is ordinary cross-referencing; `assumes` pointing at the same document means
213
+ * somebody is designing around a constraint that was abolished.
214
+ */
215
+ const STALE_PREMISE_DOCUMENT = parseQuery('*[phase!=retired] -depends-on,assumes,amends,blocked-by-> document[phase=retired]');
216
+ const STALE_PREMISE_ITEM = parseQuery('*[phase!=retired] -depends-on,assumes,amends,blocked-by-> item[state=obviated]');
217
+ function stalePremises(graph, emit, claimed) {
218
+ const seen = new Set();
219
+ for (const spec of [STALE_PREMISE_DOCUMENT, STALE_PREMISE_ITEM]) {
220
+ for (const match of execute(graph, spec)) {
221
+ const source = match.nodes[0];
222
+ const target = match.nodes[1];
223
+ const edge = match.edges[0];
224
+ // Already reported as a ghost handover, which says the same thing in the
225
+ // terms the reader needs.
226
+ if (claimed.has(edgeKey(edge)))
227
+ continue;
228
+ const key = `${source.id}>${target.id}>${edge.kind}`;
229
+ if (seen.has(key))
230
+ continue;
231
+ seen.add(key);
232
+ const targetDocument = graph.owningDocument(target.id);
233
+ const because = target.kind === 'item'
234
+ ? 'that question was obviated - its premise no longer holds'
235
+ : `${target.id} is retired${statusSuffix(targetDocument)}`;
236
+ emit('stale-premise', () => ({
237
+ message: `${describe(source)} ${EDGE_TRAITS[edge.kind].phrase} ${describe(target)}, which no longer holds`,
238
+ at: edge.declaredAt,
239
+ nodes: [source.id, target.id],
240
+ // Point at the status line when there is one: that is the sentence that
241
+ // makes the premise stale, and repeating the document's first line
242
+ // underneath it adds nothing.
243
+ related: [related(target.kind === 'document' ? (targetDocument?.statusAt ?? target.at) : target.at, because)],
244
+ hint: `re-check this dependency: the constraint it assumes may have been lifted when ${target.kind === 'item' ? 'the question closed' : `${target.id} was retired`}`,
245
+ }));
246
+ }
247
+ }
248
+ }
249
+ /* -------------------------------------------------------------------------- */
250
+ /* Reference integrity */
251
+ /* -------------------------------------------------------------------------- */
252
+ function brokenReferences(corpus, emit) {
253
+ for (const ref of corpus.dangling) {
254
+ if (ref.reason === 'ambiguous') {
255
+ emit('ambiguous-reference', () => ({
256
+ message: `"${ref.target}" matches ${ref.candidates.length} documents`,
257
+ at: ref.declaredAt,
258
+ nodes: [ref.from],
259
+ target: ref.target,
260
+ related: [],
261
+ hint: `disambiguate it - candidates: ${ref.candidates.join(', ')}`,
262
+ }));
263
+ continue;
264
+ }
265
+ // A link to a file that exists but was not included is a configuration
266
+ // problem: the graph is not broken, the corpus is just incomplete. Reporting
267
+ // it as an error means a repository whose specifications live somewhere the
268
+ // default patterns miss fails on its first run, for something the user has
269
+ // not done wrong.
270
+ const rule = ref.reason === 'not-a-spec' ? 'reference-outside-corpus' : 'broken-reference';
271
+ emit(rule, () => ({
272
+ message: brokenMessage(ref),
273
+ at: ref.declaredAt,
274
+ nodes: [ref.from],
275
+ target: ref.target,
276
+ related: [],
277
+ hint: brokenHint(ref),
278
+ }));
279
+ }
280
+ }
281
+ function brokenMessage(ref) {
282
+ switch (ref.reason) {
283
+ case 'unknown-anchor':
284
+ return `"${ref.target}" points at an anchor that does not exist`;
285
+ case 'not-a-spec':
286
+ return `"${ref.target}" resolves to a file that is not a specification`;
287
+ default:
288
+ return `"${ref.target}" does not resolve to any document`;
289
+ }
290
+ }
291
+ function brokenHint(ref) {
292
+ if (ref.candidates.length > 0)
293
+ return `did you mean ${ref.candidates.slice(0, 3).join(', ')}?`;
294
+ // A wiki link carries no path, only a name, so whether it means a document at
295
+ // all is a repository convention. Naming the flag here is what keeps that a
296
+ // one-run discovery rather than a support question.
297
+ if (ref.raw.startsWith('[[') && ref.reason === 'unknown-target') {
298
+ return `fix the identifier, or - if [[...]] tags a concept here - exclude it: --ignore-ref "${suggestReferenceGlob(ref.target)}"`;
299
+ }
300
+ switch (ref.reason) {
301
+ case 'unknown-anchor':
302
+ return 'check the heading or item id it is meant to address';
303
+ case 'not-a-spec':
304
+ // Name the pattern that would include it: "widen your patterns" is advice
305
+ // the reader then has to translate, and this is the translation.
306
+ return `include it, for example: spec-graph "${suggestPattern(ref)}"`;
307
+ default:
308
+ return 'fix the identifier, or add the document it names';
309
+ }
310
+ }
311
+ /**
312
+ * A glob covering the family of concept tags a target belongs to.
313
+ *
314
+ * `trap 55` suggests `trap *` rather than `trap 55`, because these tags come in
315
+ * numbered series and excluding them one at a time is not a fix anybody would
316
+ * accept. A target with no trailing number is suggested verbatim.
317
+ */
318
+ function suggestReferenceGlob(target) {
319
+ const trimmed = target.trim();
320
+ const series = /^(.*?)[\s._-]*\d+$/.exec(trimmed);
321
+ const stem = series?.[1]?.trim();
322
+ return stem !== undefined && stem.length > 0 ? `${stem} *` : trimmed;
323
+ }
324
+ /**
325
+ * The narrowest include pattern that would reach a target the corpus missed.
326
+ *
327
+ * Built from the *resolved* repository-relative path, not the link as written:
328
+ * `../notes.md` cited from `docs/adr/` needs `docs/**\/*.md`, and suggesting
329
+ * `../**\/*.md` would be worse than saying nothing.
330
+ */
331
+ function suggestPattern(ref) {
332
+ const withoutAnchor = (ref.target.split('#')[0] ?? ref.target).trim();
333
+ const resolved = resolveFrom(ref.declaredAt.file, withoutAnchor);
334
+ const directory = dirnamePosix(resolved);
335
+ return directory.length > 0 ? `${directory}/**/*.md` : '**/*.md';
336
+ }
337
+ /* -------------------------------------------------------------------------- */
338
+ /* Cycles */
339
+ /* -------------------------------------------------------------------------- */
340
+ function circularDelegations(graph, emit) {
341
+ const kinds = [...OBLIGATION_EDGES, 'supersedes'];
342
+ // Projected onto documents, so a question handed back and forth between two
343
+ // of them is found even though each delegation runs item -> document and the
344
+ // raw graph therefore contains no cycle at all.
345
+ for (const component of graph.cycles(kinds, { byDocument: true })) {
346
+ const members = component
347
+ .map((id) => graph.document(id))
348
+ .filter((node) => node !== undefined);
349
+ if (members.length < 2)
350
+ continue;
351
+ // A cycle is a set, not a subject, so the exemption at `emit` cannot see it.
352
+ // One record in the loop means the loop is partly a report of what was once
353
+ // said, and nothing in it is owed by anybody.
354
+ if (members.some((member) => member.phase === 'record'))
355
+ continue;
356
+ const edges = cycleEdges(graph, component, kinds);
357
+ const onlySupersession = edges.length > 0 && edges.every((edge) => edge.kind === 'supersedes');
358
+ const head = members[0];
359
+ emit('circular-delegation', () => ({
360
+ message: onlySupersession
361
+ ? `supersession cycle across ${members.length} documents`
362
+ : `delegation cycle across ${members.length} documents: nothing in it can ever land`,
363
+ at: edges[0]?.declaredAt ?? head.at,
364
+ nodes: component,
365
+ related: edges.map((edge) => related(edge.declaredAt, `${edge.from} ${EDGE_TRAITS[edge.kind].phrase} ${edge.to}`)),
366
+ hint: 'break the loop: one of these must own the work outright, or be closed',
367
+ }));
368
+ }
369
+ }
370
+ /**
371
+ * The relations that keep a component strongly connected.
372
+ *
373
+ * Membership is by owning document, because the component is a projection: the
374
+ * edge that closes the loop is usually written on an item, and that is the line
375
+ * the report has to point at.
376
+ */
377
+ function cycleEdges(graph, component, kinds) {
378
+ const inside = new Set(component);
379
+ const owner = (id) => graph.owningDocument(id)?.id ?? id;
380
+ const out = [];
381
+ for (const edge of graph.edges) {
382
+ if (!kinds.includes(edge.kind))
383
+ continue;
384
+ const from = owner(edge.from);
385
+ const to = owner(edge.to);
386
+ if (from !== to && inside.has(from) && inside.has(to))
387
+ out.push(edge);
388
+ }
389
+ return out;
390
+ }
391
+ /* -------------------------------------------------------------------------- */
392
+ /* Orphaned obligations */
393
+ /* -------------------------------------------------------------------------- */
394
+ /**
395
+ * The black hole itself: a document nobody reads any more, still holding work.
396
+ *
397
+ * Reported once per document rather than once per item. Five findings that all
398
+ * say "this archived ADR has open questions" is five times the noise and none
399
+ * of the extra information.
400
+ */
401
+ function orphanedObligations(graph, emit, maxRelated) {
402
+ for (const document of graph.documents) {
403
+ if (document.phase !== 'retired' && document.phase !== 'frozen')
404
+ continue;
405
+ const open = graph.itemsOf(document.id).filter((item) => item.openness !== 'closed');
406
+ if (open.length === 0)
407
+ continue;
408
+ const sealed = document.phase === 'retired' ? 'retired' : 'frozen';
409
+ emit('orphaned-obligation', () => ({
410
+ message: `${sealed} document still holds ${open.length} open ${open.length === 1 ? 'obligation' : 'obligations'}`,
411
+ at: document.statusAt ?? document.at,
412
+ nodes: [document.id, ...open.map((item) => item.id)],
413
+ related: open
414
+ .slice(0, maxRelated)
415
+ .map((item) => related(item.at, `${item.disposition}: ${item.text}`)),
416
+ hint: `move each one to a live document or close it - as it stands, ${open.length === 1 ? 'it disappears' : 'they disappear'} with ${document.id}`,
417
+ }));
418
+ }
419
+ }
420
+ /* -------------------------------------------------------------------------- */
421
+ /* Supersession consistency */
422
+ /* -------------------------------------------------------------------------- */
423
+ function supersessions(graph, emit) {
424
+ for (const edge of graph.edges) {
425
+ if (edge.kind !== 'supersedes' || edge.reflexive)
426
+ continue;
427
+ const superseded = graph.document(edge.to);
428
+ const superseding = graph.document(edge.from);
429
+ if (!superseded || !superseding)
430
+ continue;
431
+ // A record narrating "ADR-0005 replaced ADR-0001" is reporting a
432
+ // supersession, not declaring one. Both hints below ask somebody to edit
433
+ // the document that made the claim, and a log is the one document nobody
434
+ // can edit - it says what was true when it was written. The exemption at
435
+ // `emit` cannot see this: the subject here is the *other* document.
436
+ if (superseding.phase === 'record')
437
+ continue;
438
+ if (superseded.phase !== 'retired' && superseded.phase !== 'unknown') {
439
+ emit('live-supersession', () => ({
440
+ // The fix belongs in the superseded document, so that is where the
441
+ // finding points - not at the document that made the claim.
442
+ message: `${superseded.id} is superseded by ${superseding.id} but still reads as ${superseded.phase}`,
443
+ at: superseded.statusAt ?? superseded.at,
444
+ nodes: [superseded.id, superseding.id],
445
+ related: [related(edge.declaredAt, `the supersession is declared here`)],
446
+ hint: `mark ${superseded.id} as superseded, or drop the claim in ${superseding.id}`,
447
+ }));
448
+ continue;
449
+ }
450
+ if (superseded.phase === 'retired' && !edge.declaredIn.includes(superseded.path)) {
451
+ emit('unreciprocated-supersession', () => ({
452
+ message: `${superseded.id} is retired but never says that ${superseding.id} replaced it`,
453
+ at: superseded.statusAt ?? superseded.at,
454
+ nodes: [superseded.id, superseding.id],
455
+ related: [related(edge.declaredAt, `only ${superseding.id} records the relationship`)],
456
+ hint: `add "superseded-by: ${superseding.id}" to ${superseded.path} so a reader who lands there is redirected`,
457
+ }));
458
+ }
459
+ }
460
+ }
461
+ /* -------------------------------------------------------------------------- */
462
+ /* Item state */
463
+ /* -------------------------------------------------------------------------- */
464
+ function stateConflicts(graph, emit) {
465
+ for (const item of graph.items) {
466
+ if (item.conflicts.length === 0)
467
+ continue;
468
+ emit('state-conflict', () => ({
469
+ message: `item reads as ${item.disposition} but also carries ${describeSignals(item)}`,
470
+ at: item.evidence.at,
471
+ nodes: [item.id],
472
+ related: item.conflicts.map((signal) => related(signal.at, `${signal.source} says ${signal.disposition}${signal.raw ? `: ${signal.raw}` : ''}`)),
473
+ hint: `make the two agree - spec-graph is treating it as ${item.openness}`,
474
+ }));
475
+ }
476
+ }
477
+ function describeSignals(item) {
478
+ const parts = item.conflicts.map((signal) => `a ${signal.source} saying ${signal.disposition}`);
479
+ return parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
480
+ }
481
+ /* -------------------------------------------------------------------------- */
482
+ /* Self references */
483
+ /* -------------------------------------------------------------------------- */
484
+ /**
485
+ * A document that delegates to, or depends on, itself.
486
+ *
487
+ * Almost always a copied link that was never repointed. Informational, because
488
+ * a self-link in a table of contents is harmless - but a *delegation* to self
489
+ * means an obligation that looks handed off and was not.
490
+ */
491
+ function selfReferences(graph, emit) {
492
+ for (const edge of graph.edges) {
493
+ if (!edge.reflexive || edge.kind === 'contains')
494
+ continue;
495
+ const traits = EDGE_TRAITS[edge.kind];
496
+ if (!traits.transfersObligation && !traits.loadBearing)
497
+ continue;
498
+ const node = graph.node(edge.from);
499
+ if (!node)
500
+ continue;
501
+ emit('self-reference', () => ({
502
+ message: `${describe(node)} ${traits.phrase} itself`,
503
+ at: edge.declaredAt,
504
+ nodes: [edge.from],
505
+ related: [],
506
+ hint: traits.transfersObligation
507
+ ? 'this obligation looks delegated but never left - point it at another document, or own it here'
508
+ : 'a document cannot depend on itself - repoint or remove the reference',
509
+ }));
510
+ }
511
+ }
512
+ /* -------------------------------------------------------------------------- */
513
+ /* Helpers */
514
+ /* -------------------------------------------------------------------------- */
515
+ function describe(node) {
516
+ return node.kind === 'document' ? node.id : `${node.document} "${truncate(node.text, 48)}"`;
517
+ }
518
+ function truncate(text, limit) {
519
+ return text.length <= limit ? text : `${text.slice(0, limit - 3)}...`;
520
+ }
521
+ function statusSuffix(document) {
522
+ return document?.rawStatus ? ` ("${document.rawStatus}")` : '';
523
+ }
524
+ function related(at, note) {
525
+ return { at, note };
526
+ }
527
+ const SEVERITY_ORDER = { error: 0, warn: 1, info: 2 };
528
+ /**
529
+ * Total ordering, so two runs on the same corpus produce byte-identical output.
530
+ *
531
+ * CI output that reorders between runs cannot be diffed, and output that cannot
532
+ * be diffed stops being read.
533
+ */
534
+ export function sortDiagnostics(diagnostics) {
535
+ return [...diagnostics].sort((a, b) => {
536
+ const bySeverity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
537
+ if (bySeverity !== 0)
538
+ return bySeverity;
539
+ if (a.at.file !== b.at.file)
540
+ return a.at.file < b.at.file ? -1 : 1;
541
+ if (a.at.span.start.line !== b.at.span.start.line)
542
+ return a.at.span.start.line - b.at.span.start.line;
543
+ if (a.at.span.start.column !== b.at.span.start.column)
544
+ return a.at.span.start.column - b.at.span.start.column;
545
+ if (a.rule !== b.rule)
546
+ return a.rule < b.rule ? -1 : 1;
547
+ return a.message < b.message ? -1 : a.message > b.message ? 1 : 0;
548
+ });
549
+ }
550
+ /** The selector equivalent of a rule, for `spec-graph rules --explain`. */
551
+ export const RULE_QUERIES = Object.freeze({
552
+ 'ghost-handover': '*[openness!=closed][phase!=retired] -delegates-to,blocked-by-> *[receptivity=sealed]',
553
+ 'stale-premise': '*[phase!=retired] -depends-on,assumes,amends,blocked-by-> document[phase=retired]',
554
+ 'orphaned-obligation': 'document[receptivity=sealed] -contains-> item[openness!=closed]',
555
+ 'self-reference': 'see --format json; reflexive edges are excluded from traversal by default',
556
+ });
557
+ //# sourceMappingURL=rules.js.map