@descent-vtt/spec-graph 0.3.0 → 0.5.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, isDocumentTarget, isExternal, looksLikePath, normaliseRef, parsePrefixedRef, 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';
@@ -95,43 +95,6 @@ export const RELATION_KEYS = {
95
95
  refs: { kind: 'references', inverted: false },
96
96
  'referenced-by': { kind: 'references', inverted: true },
97
97
  };
98
- /**
99
- * True when two folded keys are one edit apart.
100
- *
101
- * One edit, not two, and a transposition counts as one: `supercedesby` for
102
- * `supercededby` is a slip of the finger, `categories` for `dependencies` is a
103
- * different word. Written out rather than as a distance matrix because the
104
- * answer is a yes or a no and the bound is one - the general algorithm would be
105
- * more code, not less.
106
- */
107
- export function withinOneEdit(a, b) {
108
- if (a === b)
109
- return false;
110
- if (a.length === b.length) {
111
- let first = -1;
112
- for (let i = 0; i < a.length; i += 1) {
113
- if (a[i] === b[i])
114
- continue;
115
- if (first === -1) {
116
- first = i;
117
- continue;
118
- }
119
- // A second difference is allowed only if the two are a swapped pair, and
120
- // only if there is no third.
121
- return first === i - 1 && a[first] === b[i] && a[i] === b[first] && a.slice(i + 1) === b.slice(i + 1);
122
- }
123
- return true;
124
- }
125
- const [short, long] = a.length < b.length ? [a, b] : [b, a];
126
- if (long.length - short.length !== 1)
127
- return false;
128
- for (let i = 0; i < short.length; i += 1) {
129
- if (short[i] === long[i])
130
- continue;
131
- return short.slice(i) === long.slice(i + 1);
132
- }
133
- return true;
134
- }
135
98
  /**
136
99
  * True when a front-matter value is shaped like a citation.
137
100
  *
@@ -195,6 +158,35 @@ const RELATION_INDEX = new Map(Object.entries(RELATION_KEYS).map(([canonical, re
195
158
  foldRelationKey(canonical),
196
159
  { ...relation, canonical },
197
160
  ]));
161
+ /**
162
+ * Keys a region must answer for itself, so the file's answer cannot shadow one.
163
+ *
164
+ * Identity and status and title are the three things a register exists to vary
165
+ * row by row. The rest of the file's front matter describes the file, and a
166
+ * decision inside the file is described by it too.
167
+ */
168
+ const UNINHERITED = new Set([...ID_KEYS, ...STATUS_KEYS, 'title', 'alias', 'aliases']);
169
+ /**
170
+ * The front matter a region inherits from the file that contains it.
171
+ *
172
+ * [ADR-0009](../docs/adr/0009-a-specification-is-a-region.md) left this open,
173
+ * and [ADR-0016](../docs/adr/0016-a-query-needs-a-sentence.md) made leaving it
174
+ * open a defect. A repository can now write `document[fm.owner!=platform]` as a
175
+ * rule of its own, and on a register every region answered nothing - which
176
+ * `!=` reads as a mismatch, because an absent value is not the value. A file
177
+ * whose front matter said `owner: platform` was reported twice for not being
178
+ * owned by platform, and the message rendered `{0.fm.owner}` as the literal
179
+ * placeholder. Confident, wrong, and unanswerable: the ADR-0006 failure.
180
+ *
181
+ * Two kinds of key stay behind. A relation key is a claim the file made -
182
+ * `supersedes:` at the top of a register supersedes on behalf of the register,
183
+ * not on behalf of each decision in it, and the edges were built from the
184
+ * file's entries before this ran. And a key the region answers itself must not
185
+ * be shadowed, which is {@link UNINHERITED}.
186
+ */
187
+ export function inheritableFrontMatter(entries) {
188
+ return toRecord(entries.filter((entry) => !UNINHERITED.has(entry.key) && !RELATION_INDEX.has(foldRelationKey(entry.key))));
189
+ }
198
190
  /**
199
191
  * Phrases that give a link its meaning, longest-matching-nearest wins.
200
192
  *
@@ -457,6 +449,8 @@ export function extractSpecifications(input) {
457
449
  return first === null ? [] : [first, ...first.subSpecifications];
458
450
  }
459
451
  /** Reads a file's own specification. Returns `null` when it opts out. */
452
+ /** The one character that makes a text file read as binary to other tools. */
453
+ const NUL = '\u0000';
460
454
  export function extractDocument(input) {
461
455
  const scanned = scanMarkdown(input.text);
462
456
  const directives = parseDirectives(scanned.comments);
@@ -466,6 +460,22 @@ export function extractDocument(input) {
466
460
  const file = input.path;
467
461
  const problems = [];
468
462
  const at = (start, end) => refOf(file, index, start, end);
463
+ const nul = input.text.indexOf(NUL);
464
+ if (nul !== -1) {
465
+ // Not a finding. Nothing about the graph is wrong, and the text parsed - a
466
+ // NUL is whitespace to every rule here. It is a problem with the *input*,
467
+ // which is what a ParseProblem is for, and it is worth one line because
468
+ // spec-graph is likely the only tool that got this far: grep, diff and
469
+ // every review interface read the file as binary and show nothing at all.
470
+ //
471
+ // The cause is almost always an encoding, not a keystroke. UTF-16 read as
472
+ // UTF-8 puts a NUL between every character, and the document that produces
473
+ // looks plausible in some editors and is mojibake everywhere else.
474
+ problems.push({
475
+ message: `contains a NUL byte, so grep, diff and review tooling read this file as binary - check whether it was saved as UTF-16`,
476
+ at: at(nul, nul + 1),
477
+ });
478
+ }
469
479
  for (const directive of directives) {
470
480
  for (const unknown of directive.unknownAttributes) {
471
481
  problems.push({
@@ -478,6 +488,7 @@ export function extractDocument(input) {
478
488
  const byKey = new Map();
479
489
  for (const entry of entries)
480
490
  byKey.set(entry.key, entry);
491
+ const inherited = inheritableFrontMatter(entries);
481
492
  const h1 = scanned.headings.find((h) => h.level === 1) ?? null;
482
493
  const frontMatterId = firstValue(byKey, ID_KEYS) ?? null;
483
494
  const identityOf = (declaredId, directive) => identify({
@@ -544,6 +555,7 @@ export function extractDocument(input) {
544
555
  file,
545
556
  pathPhase,
546
557
  record,
558
+ inherited,
547
559
  }));
548
560
  const ownerAt = (offset) => {
549
561
  const region = regionAt(regions, offset);
@@ -612,12 +624,13 @@ function belongsTo(reference, documentId, items) {
612
624
  * It produces exactly the `DocumentNode` a whole file produces, which is the
613
625
  * reason every rule, query and reporter works on registers without knowing they
614
626
  * exist. The only deliberate differences: the node is anchored at its heading
615
- * rather than at line one, it carries no front matter of its own, and it does
616
- * not claim the file's path as an alias - the file already does, and two nodes
617
- * answering to one path would make every link to that file ambiguous.
627
+ * rather than at line one, it carries the file's front matter rather than front
628
+ * matter of its own, and it does not claim the file's path as an alias - the
629
+ * file already does, and two nodes answering to one path would make every link
630
+ * to that file ambiguous.
618
631
  */
619
632
  function buildRegion(context) {
620
- const { region, input, scanned, index, file, pathPhase, record } = context;
633
+ const { region, input, scanned, index, file, pathPhase, record, inherited } = context;
621
634
  const at = (start, end) => refOf(file, index, start, end);
622
635
  const identity = identify({
623
636
  path: input.path,
@@ -648,7 +661,7 @@ function buildRegion(context) {
648
661
  phase: record ? 'record' : declaredPhase !== 'unknown' ? declaredPhase : pathPhase,
649
662
  rawStatus,
650
663
  statusAt,
651
- frontMatter: {},
664
+ frontMatter: inherited,
652
665
  };
653
666
  const anchors = new Set(scanned.headings.filter((h) => h.start >= region.start && h.start < region.end).map((h) => h.slug));
654
667
  // Relations a table declares by column, typed by the header the author wrote.
@@ -670,7 +683,8 @@ function buildRegion(context) {
670
683
  items: [],
671
684
  references,
672
685
  problems: [],
673
- // A region has no front matter of its own to misread.
686
+ // The file has already reported anything misread at the top of it, and
687
+ // there is one file however many regions it holds.
674
688
  misreadKeys: [],
675
689
  anchors,
676
690
  identity,