@descent-vtt/spec-graph 0.2.3 → 0.4.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/dist/extract.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * broken reference to specification 256.
16
16
  */
17
17
  import { attr, attrList, directiveFor, parseDirectives } from './directives.js';
18
- import { identify, isExternal, normaliseRef, ID_KEYS } from './identity.js';
18
+ import { identify, isDocumentTarget, isExternal, looksLikePath, normaliseRef, parsePrefixedRef, withinOneEdit, ID_KEYS, } from './identity.js';
19
19
  import { isStatusHeading, phaseFromPath, phaseOf, STATUS_KEYS, supersessionTargetsIn } from './lifecycle.js';
20
20
  import { scanMarkdown, slugify } from './markdown.js';
21
21
  import { findSpecificationRegions, regionAt } from './sections.js';
@@ -25,34 +25,139 @@ import { parseFrontMatter, toRecord, valuesOf } from './yaml.js';
25
25
  /* -------------------------------------------------------------------------- */
26
26
  /* Vocabulary */
27
27
  /* -------------------------------------------------------------------------- */
28
- /** Front-matter keys that declare a relation, and what they mean. */
29
- const RELATION_KEYS = {
28
+ /**
29
+ * Front-matter keys that declare a relation, and what they mean.
30
+ *
31
+ * Every directional kind is spelled in **both** directions. A register that
32
+ * records `depends-on` but not `depended-on-by` is not a smaller vocabulary, it
33
+ * is a trap: the author writes the inverse, the key means nothing, and the edge
34
+ * they declared is missing from a graph that reports itself as consistent. Which
35
+ * half of a pair a repository writes is a filing convention, and a filing
36
+ * convention is not something a linter gets to have an opinion about.
37
+ *
38
+ * `relates-to` is the exception, and it is not one: the relation is symmetric,
39
+ * so it has no other direction to spell. `contains` is structural and never
40
+ * written by hand. `tests/parsing.test.ts` holds both facts to the fire.
41
+ */
42
+ export const RELATION_KEYS = {
30
43
  supersedes: { kind: 'supersedes', inverted: false },
31
44
  supercedes: { kind: 'supersedes', inverted: false },
32
45
  replaces: { kind: 'supersedes', inverted: false },
33
46
  obsoletes: { kind: 'supersedes', inverted: false },
47
+ deprecates: { kind: 'supersedes', inverted: false },
34
48
  'superseded-by': { kind: 'supersedes', inverted: true },
35
49
  'superceded-by': { kind: 'supersedes', inverted: true },
36
50
  'replaced-by': { kind: 'supersedes', inverted: true },
37
51
  'obsoleted-by': { kind: 'supersedes', inverted: true },
52
+ 'deprecated-by': { kind: 'supersedes', inverted: true },
53
+ 'rolled-into': { kind: 'supersedes', inverted: true },
38
54
  amends: { kind: 'amends', inverted: false },
39
55
  extends: { kind: 'amends', inverted: false },
56
+ refines: { kind: 'amends', inverted: false },
57
+ clarifies: { kind: 'amends', inverted: false },
58
+ revises: { kind: 'amends', inverted: false },
40
59
  'amended-by': { kind: 'amends', inverted: true },
60
+ 'extended-by': { kind: 'amends', inverted: true },
61
+ 'refined-by': { kind: 'amends', inverted: true },
62
+ 'clarified-by': { kind: 'amends', inverted: true },
63
+ 'revised-by': { kind: 'amends', inverted: true },
41
64
  'depends-on': { kind: 'depends-on', inverted: false },
65
+ 'dependent-on': { kind: 'depends-on', inverted: false },
42
66
  dependencies: { kind: 'depends-on', inverted: false },
43
67
  requires: { kind: 'depends-on', inverted: false },
68
+ 'builds-on': { kind: 'depends-on', inverted: false },
69
+ 'relies-on': { kind: 'depends-on', inverted: false },
70
+ 'depended-on-by': { kind: 'depends-on', inverted: true },
71
+ 'required-by': { kind: 'depends-on', inverted: true },
72
+ dependents: { kind: 'depends-on', inverted: true },
44
73
  assumes: { kind: 'assumes', inverted: false },
74
+ 'assumed-by': { kind: 'assumes', inverted: true },
45
75
  'blocked-by': { kind: 'blocked-by', inverted: false },
76
+ 'blocked-on': { kind: 'blocked-by', inverted: false },
77
+ 'waiting-on': { kind: 'blocked-by', inverted: false },
78
+ 'waiting-for': { kind: 'blocked-by', inverted: false },
79
+ 'gated-on': { kind: 'blocked-by', inverted: false },
80
+ 'gated-by': { kind: 'blocked-by', inverted: false },
46
81
  blocks: { kind: 'blocked-by', inverted: true },
47
82
  'delegates-to': { kind: 'delegates-to', inverted: false },
48
83
  'delegated-to': { kind: 'delegates-to', inverted: false },
84
+ 'deferred-to': { kind: 'delegates-to', inverted: false },
49
85
  'tracked-in': { kind: 'delegates-to', inverted: false },
86
+ 'tracked-by': { kind: 'delegates-to', inverted: false },
87
+ 'continued-in': { kind: 'delegates-to', inverted: false },
88
+ 'delegated-from': { kind: 'delegates-to', inverted: true },
50
89
  related: { kind: 'relates-to', inverted: false },
51
90
  'relates-to': { kind: 'relates-to', inverted: false },
91
+ 'related-to': { kind: 'relates-to', inverted: false },
52
92
  'see-also': { kind: 'relates-to', inverted: false },
53
93
  references: { kind: 'references', inverted: false },
94
+ reference: { kind: 'references', inverted: false },
54
95
  refs: { kind: 'references', inverted: false },
96
+ 'referenced-by': { kind: 'references', inverted: true },
55
97
  };
98
+ /**
99
+ * True when a front-matter value is shaped like a citation.
100
+ *
101
+ * The second half of the gate on {@link misreadRelationKeys}, and the half that
102
+ * does the work. `sidebar_position: 4` and `ref: main` are a hair away from a
103
+ * relation key by spelling alone; neither carries anything that could name a
104
+ * document, and reporting them would make the rule an irritation rather than a
105
+ * catch (ADR-0006).
106
+ */
107
+ function looksLikeCitation(value) {
108
+ const trimmed = value.trim();
109
+ if (trimmed.length === 0)
110
+ return false;
111
+ if (parsePrefixedRef(trimmed) !== null)
112
+ return true;
113
+ return looksLikePath(trimmed) && isDocumentTarget(trimmed) && !isExternal(trimmed);
114
+ }
115
+ /**
116
+ * Front-matter keys that read as relations and declare none.
117
+ *
118
+ * Front matter is an open vocabulary and most of what lives there is nobody
119
+ * else's business, so the bar is deliberately high: the key has to be one edit
120
+ * from a relation key *and* carry something that could name a document. Both
121
+ * conditions together are what separates `dependson: ADR-0001`, which is an edge
122
+ * the author believes exists, from `description: ...`, which is a title.
123
+ */
124
+ function misreadRelationKeys(byKey, from, at) {
125
+ const out = [];
126
+ for (const [key, entry] of byKey) {
127
+ const folded = foldRelationKey(key);
128
+ if (folded.length === 0 || RELATION_INDEX.has(folded))
129
+ continue;
130
+ if (!valuesOf(entry).some(looksLikeCitation))
131
+ continue;
132
+ const near = [...RELATION_INDEX.values()]
133
+ .filter((relation) => withinOneEdit(folded, foldRelationKey(relation.canonical)))
134
+ .map((relation) => relation.canonical);
135
+ if (near.length === 0)
136
+ continue;
137
+ out.push({ key, suggestion: near.join(', '), from, at: at(entry.start, entry.end) });
138
+ }
139
+ return out;
140
+ }
141
+ /**
142
+ * Folds a front-matter key to its lookup form.
143
+ *
144
+ * `depends-on`, `depends_on`, `dependsOn` and `Depends On` are one key written
145
+ * four ways, and which one a repository uses is decided by whatever wrote the
146
+ * front matter first. Dropping every separator collapses them - the YAML reader
147
+ * has already lower-cased the key, which is what makes camel case fold too.
148
+ *
149
+ * Which case this folds to is arbitrary and no test pins it: both sides of every
150
+ * comparison come through here, so upper would work exactly as well. Asserting
151
+ * on it would be asserting on the implementation.
152
+ */
153
+ export function foldRelationKey(key) {
154
+ return key.replace(/[^a-z0-9]/gi, '').toLowerCase();
155
+ }
156
+ /** The vocabulary above, indexed by folded key. */
157
+ const RELATION_INDEX = new Map(Object.entries(RELATION_KEYS).map(([canonical, relation]) => [
158
+ foldRelationKey(canonical),
159
+ { ...relation, canonical },
160
+ ]));
56
161
  /**
57
162
  * Phrases that give a link its meaning, longest-matching-nearest wins.
58
163
  *
@@ -315,6 +420,8 @@ export function extractSpecifications(input) {
315
420
  return first === null ? [] : [first, ...first.subSpecifications];
316
421
  }
317
422
  /** Reads a file's own specification. Returns `null` when it opts out. */
423
+ /** The one character that makes a text file read as binary to other tools. */
424
+ const NUL = '\u0000';
318
425
  export function extractDocument(input) {
319
426
  const scanned = scanMarkdown(input.text);
320
427
  const directives = parseDirectives(scanned.comments);
@@ -324,6 +431,22 @@ export function extractDocument(input) {
324
431
  const file = input.path;
325
432
  const problems = [];
326
433
  const at = (start, end) => refOf(file, index, start, end);
434
+ const nul = input.text.indexOf(NUL);
435
+ if (nul !== -1) {
436
+ // Not a finding. Nothing about the graph is wrong, and the text parsed - a
437
+ // NUL is whitespace to every rule here. It is a problem with the *input*,
438
+ // which is what a ParseProblem is for, and it is worth one line because
439
+ // spec-graph is likely the only tool that got this far: grep, diff and
440
+ // every review interface read the file as binary and show nothing at all.
441
+ //
442
+ // The cause is almost always an encoding, not a keystroke. UTF-16 read as
443
+ // UTF-8 puts a NUL between every character, and the document that produces
444
+ // looks plausible in some editors and is mojibake everywhere else.
445
+ problems.push({
446
+ message: `contains a NUL byte, so grep, diff and review tooling read this file as binary - check whether it was saved as UTF-16`,
447
+ at: at(nul, nul + 1),
448
+ });
449
+ }
327
450
  for (const directive of directives) {
328
451
  for (const unknown of directive.unknownAttributes) {
329
452
  problems.push({
@@ -432,6 +555,7 @@ export function extractDocument(input) {
432
555
  // Hand each region the items and references that fall inside it.
433
556
  const ownItems = allItems.filter((item) => item.document === identity.id);
434
557
  const ownReferences = allReferences.filter((reference) => belongsTo(reference, identity.id, allItems));
558
+ const misreadKeys = misreadRelationKeys(byKey, identity.id, at);
435
559
  const anchors = new Set(scanned.headings.map((h) => h.slug));
436
560
  const filled = subSpecifications.map((spec) => ({
437
561
  ...spec.extracted,
@@ -448,6 +572,7 @@ export function extractDocument(input) {
448
572
  items: ownItems,
449
573
  references: ownReferences,
450
574
  problems,
575
+ misreadKeys,
451
576
  anchors,
452
577
  identity,
453
578
  scanned,
@@ -526,6 +651,8 @@ function buildRegion(context) {
526
651
  items: [],
527
652
  references,
528
653
  problems: [],
654
+ // A region has no front matter of its own to misread.
655
+ misreadKeys: [],
529
656
  anchors,
530
657
  identity,
531
658
  scanned,
@@ -717,10 +844,12 @@ function extractReferences(context) {
717
844
  const { scanned, directives, document, documentAt, items, byKey, status, claimed, file, index } = context;
718
845
  const out = [];
719
846
  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)
847
+ // Front matter, walked in the order it was written rather than in the order
848
+ // the vocabulary happens to be listed: a key the table does not know still has
849
+ // to be looked at, and only the document knows which those are.
850
+ for (const [key, entry] of byKey) {
851
+ const relation = RELATION_INDEX.get(foldRelationKey(key));
852
+ if (!relation)
724
853
  continue;
725
854
  for (const value of valuesOf(entry)) {
726
855
  const target = cleanTarget(value);
@@ -1081,7 +1210,7 @@ function renderLink(link) {
1081
1210
  return `<${link.target}>`;
1082
1211
  case 'reference':
1083
1212
  case 'shortcut':
1084
- return `[${link.text}][${link.target}]`;
1213
+ return `[${link.text}][${link.label ?? ''}]`;
1085
1214
  default:
1086
1215
  return `[${link.text}](${link.target})`;
1087
1216
  }