@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
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The specification graph.
3
+ *
4
+ * An immutable, fully indexed view over the resolved corpus. Construction pays
5
+ * for adjacency in both directions once so that every rule, query and report is
6
+ * a lookup rather than a scan - the difference between a linter that runs on
7
+ * every save and one that gets moved to a nightly job and then ignored.
8
+ *
9
+ * The algorithms here are the ones the rules genuinely need and no more:
10
+ * adjacency, reachability, and Tarjan's strongly connected components for
11
+ * finding circular delegations.
12
+ */
13
+ import type { DocumentNode, Edge, EdgeKind, ItemNode, SpecNode } from './types.js';
14
+ export interface SpecGraph {
15
+ readonly nodes: ReadonlyMap<string, SpecNode>;
16
+ readonly edges: readonly Edge[];
17
+ readonly documents: readonly DocumentNode[];
18
+ readonly items: readonly ItemNode[];
19
+ node(id: string): SpecNode | undefined;
20
+ document(id: string): DocumentNode | undefined;
21
+ /** The document a node belongs to: itself, or the item's owner. */
22
+ owningDocument(id: string): DocumentNode | undefined;
23
+ itemsOf(documentId: string): readonly ItemNode[];
24
+ /** Edges leaving `id`, optionally filtered by kind. */
25
+ out(id: string, kinds?: readonly EdgeKind[]): readonly Edge[];
26
+ /** Edges entering `id`, optionally filtered by kind. */
27
+ in(id: string, kinds?: readonly EdgeKind[]): readonly Edge[];
28
+ /**
29
+ * Every node reachable from `id` over the given edge kinds, with the shortest
30
+ * path to each. `id` itself is included only when a cycle returns to it.
31
+ */
32
+ reach(id: string, kinds: readonly EdgeKind[]): ReadonlyMap<string, readonly Edge[]>;
33
+ /**
34
+ * Strongly connected components with more than one member, over the given
35
+ * edge kinds. Each is a real cycle.
36
+ *
37
+ * With `byDocument`, relations are projected onto the documents that own
38
+ * their endpoints before the search. That is what catches two documents
39
+ * ping-ponging one obligation: the delegation runs item -> document in each
40
+ * direction, so there is no cycle among the raw nodes even though the two
41
+ * documents are plainly passing the same question back and forth.
42
+ */
43
+ cycles(kinds: readonly EdgeKind[], options?: {
44
+ byDocument?: boolean;
45
+ }): readonly (readonly string[])[];
46
+ }
47
+ /** Builds the graph. Edges naming an unknown node are dropped, not stored. */
48
+ export declare function buildGraph(input: {
49
+ readonly nodes: ReadonlyMap<string, SpecNode>;
50
+ readonly edges: readonly Edge[];
51
+ }): SpecGraph;
52
+ /** Edge kinds that move an obligation from one document to another. */
53
+ export declare const OBLIGATION_EDGES: readonly EdgeKind[];
54
+ /** Edge kinds whose source depends on what the target says. */
55
+ export declare const LOAD_BEARING_EDGES: readonly EdgeKind[];
package/dist/graph.js ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The specification graph.
3
+ *
4
+ * An immutable, fully indexed view over the resolved corpus. Construction pays
5
+ * for adjacency in both directions once so that every rule, query and report is
6
+ * a lookup rather than a scan - the difference between a linter that runs on
7
+ * every save and one that gets moved to a nightly job and then ignored.
8
+ *
9
+ * The algorithms here are the ones the rules genuinely need and no more:
10
+ * adjacency, reachability, and Tarjan's strongly connected components for
11
+ * finding circular delegations.
12
+ */
13
+ import { EDGE_TRAITS } from './types.js';
14
+ /** Builds the graph. Edges naming an unknown node are dropped, not stored. */
15
+ export function buildGraph(input) {
16
+ const nodes = input.nodes;
17
+ const edges = input.edges.filter((edge) => nodes.has(edge.from) && nodes.has(edge.to));
18
+ const outgoing = new Map();
19
+ const incoming = new Map();
20
+ for (const edge of edges) {
21
+ push(outgoing, edge.from, edge);
22
+ push(incoming, edge.to, edge);
23
+ }
24
+ const documents = [];
25
+ const items = [];
26
+ const itemsByDocument = new Map();
27
+ for (const node of nodes.values()) {
28
+ if (node.kind === 'document')
29
+ documents.push(node);
30
+ else {
31
+ items.push(node);
32
+ push(itemsByDocument, node.document, node);
33
+ }
34
+ }
35
+ const filter = (list, kinds) => {
36
+ if (!list)
37
+ return EMPTY_EDGES;
38
+ if (!kinds || kinds.length === 0)
39
+ return list;
40
+ return list.filter((edge) => kinds.includes(edge.kind));
41
+ };
42
+ const graph = {
43
+ nodes,
44
+ edges,
45
+ documents,
46
+ items,
47
+ node: (id) => nodes.get(id),
48
+ document: (id) => {
49
+ const found = nodes.get(id);
50
+ return found?.kind === 'document' ? found : undefined;
51
+ },
52
+ owningDocument: (id) => {
53
+ const found = nodes.get(id);
54
+ if (!found)
55
+ return undefined;
56
+ if (found.kind === 'document')
57
+ return found;
58
+ const owner = nodes.get(found.document);
59
+ return owner?.kind === 'document' ? owner : undefined;
60
+ },
61
+ itemsOf: (documentId) => itemsByDocument.get(documentId) ?? EMPTY_ITEMS,
62
+ out: (id, kinds) => filter(outgoing.get(id), kinds),
63
+ in: (id, kinds) => filter(incoming.get(id), kinds),
64
+ reach(id, kinds) {
65
+ const paths = new Map();
66
+ const queue = [id];
67
+ const pathTo = new Map([[id, EMPTY_EDGES]]);
68
+ while (queue.length > 0) {
69
+ const current = queue.shift();
70
+ const prefix = pathTo.get(current);
71
+ for (const edge of filter(outgoing.get(current), kinds)) {
72
+ if (pathTo.has(edge.to) && edge.to !== id)
73
+ continue;
74
+ const path = [...prefix, edge];
75
+ if (edge.to === id) {
76
+ // A cycle back to the origin: record it, but do not re-expand.
77
+ if (!paths.has(id))
78
+ paths.set(id, path);
79
+ continue;
80
+ }
81
+ pathTo.set(edge.to, path);
82
+ paths.set(edge.to, path);
83
+ queue.push(edge.to);
84
+ }
85
+ }
86
+ return paths;
87
+ },
88
+ cycles(kinds, options) {
89
+ const relevant = (id) => [...filter(outgoing.get(id), kinds)];
90
+ if (!options?.byDocument) {
91
+ return tarjan([...nodes.keys()], (id) => relevant(id).map((edge) => edge.to).filter((to) => nodes.has(to)));
92
+ }
93
+ const ownerOf = (id) => graph.owningDocument(id)?.id ?? id;
94
+ const projected = new Map();
95
+ for (const node of nodes.values()) {
96
+ for (const edge of relevant(node.id)) {
97
+ const from = ownerOf(edge.from);
98
+ const to = ownerOf(edge.to);
99
+ // A relation that stays inside one document is a self-reference, not
100
+ // a cycle, and has a rule of its own.
101
+ if (from === to)
102
+ continue;
103
+ const set = projected.get(from) ?? new Set();
104
+ set.add(to);
105
+ projected.set(from, set);
106
+ }
107
+ }
108
+ return tarjan(documents.map((document) => document.id), (id) => [...(projected.get(id) ?? [])]);
109
+ },
110
+ };
111
+ return graph;
112
+ }
113
+ const EMPTY_EDGES = Object.freeze([]);
114
+ const EMPTY_ITEMS = Object.freeze([]);
115
+ function push(map, key, value) {
116
+ const list = map.get(key);
117
+ if (list)
118
+ list.push(value);
119
+ else
120
+ map.set(key, [value]);
121
+ }
122
+ /* -------------------------------------------------------------------------- */
123
+ /* Cycles */
124
+ /* -------------------------------------------------------------------------- */
125
+ /**
126
+ * Tarjan's strongly connected components, iterative.
127
+ *
128
+ * Iterative rather than recursive because a monorepo's reference graph can be
129
+ * deep enough to blow the call stack, and a linter that crashes on the largest
130
+ * repository in the company is a linter nobody trusts on the small ones either.
131
+ *
132
+ * Takes a successor function so the same search serves both the raw graph and
133
+ * the document-level projection.
134
+ */
135
+ function tarjan(ids, successors) {
136
+ const indexOf = new Map();
137
+ const lowLink = new Map();
138
+ const onStack = new Set();
139
+ const stack = [];
140
+ const components = [];
141
+ let counter = 0;
142
+ for (const root of ids) {
143
+ if (indexOf.has(root))
144
+ continue;
145
+ const work = [
146
+ { id: root, next: 0, children: successors(root) },
147
+ ];
148
+ indexOf.set(root, counter);
149
+ lowLink.set(root, counter);
150
+ counter += 1;
151
+ stack.push(root);
152
+ onStack.add(root);
153
+ while (work.length > 0) {
154
+ const frame = work[work.length - 1];
155
+ if (frame.next < frame.children.length) {
156
+ const child = frame.children[frame.next];
157
+ frame.next += 1;
158
+ if (!indexOf.has(child)) {
159
+ indexOf.set(child, counter);
160
+ lowLink.set(child, counter);
161
+ counter += 1;
162
+ stack.push(child);
163
+ onStack.add(child);
164
+ work.push({ id: child, next: 0, children: successors(child) });
165
+ }
166
+ else if (onStack.has(child)) {
167
+ lowLink.set(frame.id, Math.min(lowLink.get(frame.id), indexOf.get(child)));
168
+ }
169
+ continue;
170
+ }
171
+ work.pop();
172
+ const parent = work[work.length - 1];
173
+ if (parent) {
174
+ lowLink.set(parent.id, Math.min(lowLink.get(parent.id), lowLink.get(frame.id)));
175
+ }
176
+ if (lowLink.get(frame.id) === indexOf.get(frame.id)) {
177
+ const component = [];
178
+ for (;;) {
179
+ const popped = stack.pop();
180
+ onStack.delete(popped);
181
+ component.push(popped);
182
+ if (popped === frame.id)
183
+ break;
184
+ }
185
+ if (component.length > 1)
186
+ components.push(component.reverse());
187
+ else if (successors(frame.id).includes(frame.id))
188
+ components.push(component);
189
+ }
190
+ }
191
+ }
192
+ return components;
193
+ }
194
+ /* -------------------------------------------------------------------------- */
195
+ /* Derived predicates */
196
+ /* -------------------------------------------------------------------------- */
197
+ /** Edge kinds that move an obligation from one document to another. */
198
+ export const OBLIGATION_EDGES = Object.freeze(Object.keys(EDGE_TRAITS).filter((kind) => EDGE_TRAITS[kind].transfersObligation));
199
+ /** Edge kinds whose source depends on what the target says. */
200
+ export const LOAD_BEARING_EDGES = Object.freeze(Object.keys(EDGE_TRAITS).filter((kind) => EDGE_TRAITS[kind].loadBearing));
201
+ //# sourceMappingURL=graph.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graph.js","sourceRoot":"","sources":["../src/graph.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The specification graph.\n *\n * An immutable, fully indexed view over the resolved corpus. Construction pays\n * for adjacency in both directions once so that every rule, query and report is\n * a lookup rather than a scan - the difference between a linter that runs on\n * every save and one that gets moved to a nightly job and then ignored.\n *\n * The algorithms here are the ones the rules genuinely need and no more:\n * adjacency, reachability, and Tarjan's strongly connected components for\n * finding circular delegations.\n */\n\nimport type { DocumentNode, Edge, EdgeKind, ItemNode, SpecNode } from './types.js';\nimport { EDGE_TRAITS } from './types.js';\n\nexport interface SpecGraph {\n readonly nodes: ReadonlyMap<string, SpecNode>;\n readonly edges: readonly Edge[];\n readonly documents: readonly DocumentNode[];\n readonly items: readonly ItemNode[];\n\n node(id: string): SpecNode | undefined;\n document(id: string): DocumentNode | undefined;\n /** The document a node belongs to: itself, or the item's owner. */\n owningDocument(id: string): DocumentNode | undefined;\n itemsOf(documentId: string): readonly ItemNode[];\n\n /** Edges leaving `id`, optionally filtered by kind. */\n out(id: string, kinds?: readonly EdgeKind[]): readonly Edge[];\n /** Edges entering `id`, optionally filtered by kind. */\n in(id: string, kinds?: readonly EdgeKind[]): readonly Edge[];\n\n /**\n * Every node reachable from `id` over the given edge kinds, with the shortest\n * path to each. `id` itself is included only when a cycle returns to it.\n */\n reach(id: string, kinds: readonly EdgeKind[]): ReadonlyMap<string, readonly Edge[]>;\n\n /**\n * Strongly connected components with more than one member, over the given\n * edge kinds. Each is a real cycle.\n *\n * With `byDocument`, relations are projected onto the documents that own\n * their endpoints before the search. That is what catches two documents\n * ping-ponging one obligation: the delegation runs item -> document in each\n * direction, so there is no cycle among the raw nodes even though the two\n * documents are plainly passing the same question back and forth.\n */\n cycles(kinds: readonly EdgeKind[], options?: { byDocument?: boolean }): readonly (readonly string[])[];\n}\n\n/** Builds the graph. Edges naming an unknown node are dropped, not stored. */\nexport function buildGraph(input: {\n readonly nodes: ReadonlyMap<string, SpecNode>;\n readonly edges: readonly Edge[];\n}): SpecGraph {\n const nodes = input.nodes;\n const edges = input.edges.filter((edge) => nodes.has(edge.from) && nodes.has(edge.to));\n\n const outgoing = new Map<string, Edge[]>();\n const incoming = new Map<string, Edge[]>();\n for (const edge of edges) {\n push(outgoing, edge.from, edge);\n push(incoming, edge.to, edge);\n }\n\n const documents: DocumentNode[] = [];\n const items: ItemNode[] = [];\n const itemsByDocument = new Map<string, ItemNode[]>();\n for (const node of nodes.values()) {\n if (node.kind === 'document') documents.push(node);\n else {\n items.push(node);\n push(itemsByDocument, node.document, node);\n }\n }\n\n const filter = (list: readonly Edge[] | undefined, kinds?: readonly EdgeKind[]): readonly Edge[] => {\n if (!list) return EMPTY_EDGES;\n if (!kinds || kinds.length === 0) return list;\n return list.filter((edge) => kinds.includes(edge.kind));\n };\n\n const graph: SpecGraph = {\n nodes,\n edges,\n documents,\n items,\n\n node: (id) => nodes.get(id),\n document: (id) => {\n const found = nodes.get(id);\n return found?.kind === 'document' ? found : undefined;\n },\n owningDocument: (id) => {\n const found = nodes.get(id);\n if (!found) return undefined;\n if (found.kind === 'document') return found;\n const owner = nodes.get(found.document);\n return owner?.kind === 'document' ? owner : undefined;\n },\n itemsOf: (documentId) => itemsByDocument.get(documentId) ?? EMPTY_ITEMS,\n\n out: (id, kinds) => filter(outgoing.get(id), kinds),\n in: (id, kinds) => filter(incoming.get(id), kinds),\n\n reach(id, kinds) {\n const paths = new Map<string, readonly Edge[]>();\n const queue: string[] = [id];\n const pathTo = new Map<string, readonly Edge[]>([[id, EMPTY_EDGES]]);\n\n while (queue.length > 0) {\n const current = queue.shift() as string;\n const prefix = pathTo.get(current) as readonly Edge[];\n for (const edge of filter(outgoing.get(current), kinds)) {\n if (pathTo.has(edge.to) && edge.to !== id) continue;\n const path = [...prefix, edge];\n if (edge.to === id) {\n // A cycle back to the origin: record it, but do not re-expand.\n if (!paths.has(id)) paths.set(id, path);\n continue;\n }\n pathTo.set(edge.to, path);\n paths.set(edge.to, path);\n queue.push(edge.to);\n }\n }\n return paths;\n },\n\n cycles(kinds, options) {\n const relevant = (id: string): Edge[] => [...filter(outgoing.get(id), kinds)];\n if (!options?.byDocument) {\n return tarjan([...nodes.keys()], (id) => relevant(id).map((edge) => edge.to).filter((to) => nodes.has(to)));\n }\n const ownerOf = (id: string): string => graph.owningDocument(id)?.id ?? id;\n const projected = new Map<string, Set<string>>();\n for (const node of nodes.values()) {\n for (const edge of relevant(node.id)) {\n const from = ownerOf(edge.from);\n const to = ownerOf(edge.to);\n // A relation that stays inside one document is a self-reference, not\n // a cycle, and has a rule of its own.\n if (from === to) continue;\n const set = projected.get(from) ?? new Set<string>();\n set.add(to);\n projected.set(from, set);\n }\n }\n return tarjan(\n documents.map((document) => document.id),\n (id) => [...(projected.get(id) ?? [])],\n );\n },\n };\n\n return graph;\n}\n\nconst EMPTY_EDGES: readonly Edge[] = Object.freeze([]);\nconst EMPTY_ITEMS: readonly ItemNode[] = Object.freeze([]);\n\nfunction push<T>(map: Map<string, T[]>, key: string, value: T): void {\n const list = map.get(key);\n if (list) list.push(value);\n else map.set(key, [value]);\n}\n\n/* -------------------------------------------------------------------------- */\n/* Cycles */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Tarjan's strongly connected components, iterative.\n *\n * Iterative rather than recursive because a monorepo's reference graph can be\n * deep enough to blow the call stack, and a linter that crashes on the largest\n * repository in the company is a linter nobody trusts on the small ones either.\n *\n * Takes a successor function so the same search serves both the raw graph and\n * the document-level projection.\n */\nfunction tarjan(ids: readonly string[], successors: (id: string) => string[]): string[][] {\n const indexOf = new Map<string, number>();\n const lowLink = new Map<string, number>();\n const onStack = new Set<string>();\n const stack: string[] = [];\n const components: string[][] = [];\n let counter = 0;\n\n for (const root of ids) {\n if (indexOf.has(root)) continue;\n\n const work: { id: string; next: number; children: string[] }[] = [\n { id: root, next: 0, children: successors(root) },\n ];\n indexOf.set(root, counter);\n lowLink.set(root, counter);\n counter += 1;\n stack.push(root);\n onStack.add(root);\n\n while (work.length > 0) {\n const frame = work[work.length - 1] as { id: string; next: number; children: string[] };\n if (frame.next < frame.children.length) {\n const child = frame.children[frame.next] as string;\n frame.next += 1;\n if (!indexOf.has(child)) {\n indexOf.set(child, counter);\n lowLink.set(child, counter);\n counter += 1;\n stack.push(child);\n onStack.add(child);\n work.push({ id: child, next: 0, children: successors(child) });\n } else if (onStack.has(child)) {\n lowLink.set(frame.id, Math.min(lowLink.get(frame.id) as number, indexOf.get(child) as number));\n }\n continue;\n }\n\n work.pop();\n const parent = work[work.length - 1];\n if (parent) {\n lowLink.set(parent.id, Math.min(lowLink.get(parent.id) as number, lowLink.get(frame.id) as number));\n }\n\n if (lowLink.get(frame.id) === indexOf.get(frame.id)) {\n const component: string[] = [];\n for (;;) {\n const popped = stack.pop() as string;\n onStack.delete(popped);\n component.push(popped);\n if (popped === frame.id) break;\n }\n if (component.length > 1) components.push(component.reverse());\n else if (successors(frame.id).includes(frame.id)) components.push(component);\n }\n }\n }\n\n return components;\n}\n\n/* -------------------------------------------------------------------------- */\n/* Derived predicates */\n/* -------------------------------------------------------------------------- */\n\n/** Edge kinds that move an obligation from one document to another. */\nexport const OBLIGATION_EDGES: readonly EdgeKind[] = Object.freeze(\n (Object.keys(EDGE_TRAITS) as EdgeKind[]).filter((kind) => EDGE_TRAITS[kind].transfersObligation),\n);\n\n/** Edge kinds whose source depends on what the target says. */\nexport const LOAD_BEARING_EDGES: readonly EdgeKind[] = Object.freeze(\n (Object.keys(EDGE_TRAITS) as EdgeKind[]).filter((kind) => EDGE_TRAITS[kind].loadBearing),\n);\n"]}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Document identity and reference normalisation.
3
+ *
4
+ * Foreign-key validation across a corpus of Markdown only works if `ADR-7`,
5
+ * `ADR-0007`, `adr 7`, `[[0007-sharding]]` and `../adr/0007-sharding.md` are
6
+ * understood to name the same document. Getting that wrong in either direction
7
+ * is fatal: too strict and every real repository drowns in false "broken
8
+ * reference" findings, too loose and `RFC 7` silently resolves to `ADR-7`.
9
+ *
10
+ * The resolution here is deliberately asymmetric. **Textual aliases** (`adr-7`)
11
+ * are global, because a prefix already names the family. **Bare numbers** (`7`,
12
+ * `#0007`) are resolved only within the citing document's own family, because a
13
+ * repository with both `adr/0007` and `rfc/0007` is normal and guessing between
14
+ * them would be worse than reporting nothing.
15
+ */
16
+ /** Front-matter keys that may carry an explicit identifier, in preference order. */
17
+ export declare const ID_KEYS: readonly string[];
18
+ export interface DocumentIdentity {
19
+ /** The canonical id: `ADR-0007`, or a path when nothing better exists. */
20
+ readonly id: string;
21
+ /** Family prefix, upper-cased. `null` when the document is not numbered. */
22
+ readonly family: string | null;
23
+ /** Sequence number without leading zeros. `null` when not numbered. */
24
+ readonly number: number | null;
25
+ /** Every lower-cased spelling that should resolve to this document. */
26
+ readonly aliases: readonly string[];
27
+ }
28
+ export interface IdentityInput {
29
+ /** Repository-relative POSIX path. */
30
+ readonly path: string;
31
+ /** An id declared by a directive or front matter, if any. */
32
+ readonly declaredId: string | null;
33
+ /** Extra aliases declared by a directive or front matter. */
34
+ readonly declaredAliases: readonly string[];
35
+ /** The document H1, if any. */
36
+ readonly heading: string | null;
37
+ /**
38
+ * Register the file path and stem as aliases. Default true.
39
+ *
40
+ * A specification that is a *region* of a file must not claim the file's
41
+ * path: the file already answers to it, and two nodes answering to one path
42
+ * would make every link to that file ambiguous.
43
+ */
44
+ readonly includePathAliases?: boolean | undefined;
45
+ }
46
+ /**
47
+ * Derives a document's canonical id and every alias that should reach it.
48
+ *
49
+ * Evidence is used in order of how deliberate it is: an explicit declaration,
50
+ * then the file name, then the H1. Whatever loses still contributes aliases, so
51
+ * a reference written in any of those forms still resolves.
52
+ */
53
+ export declare function identify(input: IdentityInput): DocumentIdentity;
54
+ /**
55
+ * Folds a reference to its lookup key.
56
+ *
57
+ * Case, separators and surrounding punctuation carry no meaning in a citation,
58
+ * so `ADR-0007`, `adr 0007` and `Adr_0007` all become `adr0007`.
59
+ */
60
+ export declare function normaliseRef(value: string): string;
61
+ /** Splits a `target#anchor` reference. */
62
+ export declare function splitAnchor(target: string): {
63
+ target: string;
64
+ anchor: string | null;
65
+ };
66
+ /** Parses `ADR-0007` into its family and number, or returns `null`. */
67
+ export declare function parsePrefixedRef(value: string): {
68
+ family: string;
69
+ number: number;
70
+ } | null;
71
+ /** Parses a bare `7` or `#0007`, or returns `null`. */
72
+ export declare function parseBareRef(value: string): number | null;
73
+ /** True when a reference target names a location rather than an identifier. */
74
+ export declare function looksLikePath(target: string): boolean;
75
+ /**
76
+ * True when a path-like target could name a specification.
77
+ *
78
+ * A specification linking to `../../src/rules.ts` is citing source code, which
79
+ * is a different tool's job. Treating it as a dangling specification reference
80
+ * would report an error on one of the most ordinary things a design document
81
+ * does, so targets carrying a non-document extension are not references at all.
82
+ */
83
+ export declare function isDocumentTarget(target: string): boolean;
84
+ /** True when a reference target points outside the repository. */
85
+ export declare function isExternal(target: string): boolean;
86
+ /** The family prefix implied by a document's directory, if any. */
87
+ export declare function familyFromPath(posixPath: string): string | null;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Document identity and reference normalisation.
3
+ *
4
+ * Foreign-key validation across a corpus of Markdown only works if `ADR-7`,
5
+ * `ADR-0007`, `adr 7`, `[[0007-sharding]]` and `../adr/0007-sharding.md` are
6
+ * understood to name the same document. Getting that wrong in either direction
7
+ * is fatal: too strict and every real repository drowns in false "broken
8
+ * reference" findings, too loose and `RFC 7` silently resolves to `ADR-7`.
9
+ *
10
+ * The resolution here is deliberately asymmetric. **Textual aliases** (`adr-7`)
11
+ * are global, because a prefix already names the family. **Bare numbers** (`7`,
12
+ * `#0007`) are resolved only within the citing document's own family, because a
13
+ * repository with both `adr/0007` and `rfc/0007` is normal and guessing between
14
+ * them would be worse than reporting nothing.
15
+ */
16
+ /** Directory names that name a specification family, mapped to a canonical prefix. */
17
+ const FAMILY_DIRECTORIES = {
18
+ adr: 'ADR',
19
+ adrs: 'ADR',
20
+ 'architecture-decisions': 'ADR',
21
+ 'decision-records': 'ADR',
22
+ decisions: 'ADR',
23
+ rfc: 'RFC',
24
+ rfcs: 'RFC',
25
+ kep: 'KEP',
26
+ keps: 'KEP',
27
+ enhancements: 'KEP',
28
+ proposal: 'PROPOSAL',
29
+ proposals: 'PROPOSAL',
30
+ design: 'DESIGN',
31
+ designs: 'DESIGN',
32
+ prd: 'PRD',
33
+ prds: 'PRD',
34
+ spec: 'SPEC',
35
+ specs: 'SPEC',
36
+ };
37
+ /** Front-matter keys that may carry an explicit identifier, in preference order. */
38
+ export const ID_KEYS = ['id', 'adr', 'adr-id', 'rfc', 'rfc-id', 'kep-number', 'number', 'slug'];
39
+ /** `ADR-0007`, `KEP 1234`, `rfc2119`: a family prefix followed by a number. */
40
+ const PREFIXED_ID = /^([A-Za-z][A-Za-z0-9_]{0,15}?)[\s._-]*(\d{1,6})$/;
41
+ /** A bare number, optionally hash-prefixed: `7`, `#0007`. */
42
+ const BARE_NUMBER = /^#?(\d{1,6})$/;
43
+ /**
44
+ * Derives a document's canonical id and every alias that should reach it.
45
+ *
46
+ * Evidence is used in order of how deliberate it is: an explicit declaration,
47
+ * then the file name, then the H1. Whatever loses still contributes aliases, so
48
+ * a reference written in any of those forms still resolves.
49
+ */
50
+ export function identify(input) {
51
+ const stem = fileStem(input.path);
52
+ const directoryFamily = familyFromPath(input.path);
53
+ const aliases = new Set();
54
+ const add = (value) => {
55
+ if (!value)
56
+ return;
57
+ const key = normaliseRef(value);
58
+ if (key.length > 0)
59
+ aliases.add(key);
60
+ };
61
+ const candidates = [];
62
+ const consider = (raw, allowBare) => {
63
+ if (!raw)
64
+ return;
65
+ const cleaned = raw.trim();
66
+ if (cleaned.length === 0)
67
+ return;
68
+ const prefixed = PREFIXED_ID.exec(cleaned);
69
+ if (prefixed) {
70
+ candidates.push({
71
+ family: prefixed[1].toUpperCase(),
72
+ number: Number.parseInt(prefixed[2], 10),
73
+ label: cleaned,
74
+ });
75
+ return;
76
+ }
77
+ const bare = BARE_NUMBER.exec(cleaned);
78
+ if (bare && allowBare) {
79
+ candidates.push({
80
+ family: directoryFamily,
81
+ number: Number.parseInt(bare[1], 10),
82
+ label: cleaned,
83
+ });
84
+ return;
85
+ }
86
+ candidates.push({ family: null, number: null, label: cleaned });
87
+ };
88
+ consider(input.declaredId, true);
89
+ // `0007-sharding-the-write-path.md` and `kep-1234-foo.md` both start with the
90
+ // identifier; take the leading token rather than the whole stem.
91
+ consider(leadingToken(stem), true);
92
+ consider(headingId(input.heading), false);
93
+ const numbered = candidates.find((c) => c.number !== null);
94
+ const family = numbered?.family ?? directoryFamily;
95
+ const number = numbered?.number ?? null;
96
+ // Witnesses to the local zero-padding convention, most authoritative first.
97
+ // The spelling that actually produced the number comes first: an id read from
98
+ // the H1 of `sharding.md` must still render as ADR-0007, not ADR-7.
99
+ const witnesses = [numbered?.label, input.declaredId, stem];
100
+ let id;
101
+ if (number !== null && family !== null) {
102
+ id = `${family}-${padNumber(number, witnesses)}`;
103
+ }
104
+ else if (input.declaredId) {
105
+ id = input.declaredId.trim();
106
+ }
107
+ else if (number !== null) {
108
+ // Numbered but with no family to prefix - a Rust RFC in `text/0001-foo.md`,
109
+ // say. The file stem is a far better identity than a bare "1": it is what
110
+ // people actually type, and it stays unique across the repository.
111
+ id = stem.length > 0 ? stem : String(number);
112
+ }
113
+ else {
114
+ id = stem.length > 0 ? stem : input.path;
115
+ }
116
+ add(id);
117
+ if (input.includePathAliases !== false) {
118
+ add(stem);
119
+ add(input.path);
120
+ add(stripExtension(input.path));
121
+ }
122
+ add(input.declaredId);
123
+ for (const alias of input.declaredAliases)
124
+ add(alias);
125
+ if (input.heading)
126
+ add(headingId(input.heading));
127
+ if (number !== null) {
128
+ const spellings = numberSpellings(number, witnesses);
129
+ if (family !== null) {
130
+ // Every spelling a human might type. `normaliseRef` folds separators, so
131
+ // `adr-7`, `adr 7` and `adr7` collapse to one key; the padding variants do
132
+ // not, and each needs registering.
133
+ for (const digits of spellings)
134
+ add(`${family}-${digits}`);
135
+ }
136
+ else {
137
+ // With no family there is only one number space in the repository, so a
138
+ // bare `0001` is unambiguous and worth resolving.
139
+ for (const digits of spellings)
140
+ add(digits);
141
+ }
142
+ }
143
+ return { id, family, number, aliases: [...aliases] };
144
+ }
145
+ /**
146
+ * Folds a reference to its lookup key.
147
+ *
148
+ * Case, separators and surrounding punctuation carry no meaning in a citation,
149
+ * so `ADR-0007`, `adr 0007` and `Adr_0007` all become `adr0007`.
150
+ */
151
+ export function normaliseRef(value) {
152
+ return value
153
+ .trim()
154
+ .toLowerCase()
155
+ .replace(/^[#<([]+|[)\]>.,;:]+$/g, '')
156
+ .replace(/[\s._-]+/g, '')
157
+ .trim();
158
+ }
159
+ /** Splits a `target#anchor` reference. */
160
+ export function splitAnchor(target) {
161
+ const hash = target.indexOf('#');
162
+ if (hash === -1)
163
+ return { target, anchor: null };
164
+ // A leading `#` is an in-document anchor, not a separator.
165
+ if (hash === 0)
166
+ return { target: '', anchor: target.slice(1) };
167
+ return { target: target.slice(0, hash), anchor: target.slice(hash + 1) };
168
+ }
169
+ /** Parses `ADR-0007` into its family and number, or returns `null`. */
170
+ export function parsePrefixedRef(value) {
171
+ const match = PREFIXED_ID.exec(value.trim().replace(/^[#([]+/, ''));
172
+ if (!match)
173
+ return null;
174
+ return { family: match[1].toUpperCase(), number: Number.parseInt(match[2], 10) };
175
+ }
176
+ /** Parses a bare `7` or `#0007`, or returns `null`. */
177
+ export function parseBareRef(value) {
178
+ const match = BARE_NUMBER.exec(value.trim());
179
+ return match ? Number.parseInt(match[1], 10) : null;
180
+ }
181
+ /** True when a reference target names a location rather than an identifier. */
182
+ export function looksLikePath(target) {
183
+ return target.includes('/') || DOCUMENT_EXTENSION.test(target);
184
+ }
185
+ const DOCUMENT_EXTENSION = /\.(md|markdown|mdx|txt|rst|adoc|asciidoc)$/i;
186
+ /**
187
+ * True when a path-like target could name a specification.
188
+ *
189
+ * A specification linking to `../../src/rules.ts` is citing source code, which
190
+ * is a different tool's job. Treating it as a dangling specification reference
191
+ * would report an error on one of the most ordinary things a design document
192
+ * does, so targets carrying a non-document extension are not references at all.
193
+ */
194
+ export function isDocumentTarget(target) {
195
+ const withoutAnchor = splitAnchor(target).target;
196
+ const base = withoutAnchor.slice(withoutAnchor.lastIndexOf('/') + 1);
197
+ const dot = base.lastIndexOf('.');
198
+ // No extension: a directory, or an extensionless spec such as `rfcs/0007`.
199
+ if (dot <= 0)
200
+ return true;
201
+ const extension = base.slice(dot);
202
+ // Only a plausible file extension disqualifies a target. An identifier like
203
+ // `v1.2` or `ADR-0007.1` keeps its dot and is still resolved as an id.
204
+ if (!/^\.[A-Za-z][A-Za-z0-9]{0,5}$/.test(extension))
205
+ return true;
206
+ return DOCUMENT_EXTENSION.test(base);
207
+ }
208
+ /** True when a reference target points outside the repository. */
209
+ export function isExternal(target) {
210
+ return /^[a-z][a-z0-9+.-]*:/i.test(target) && !/^[a-z]:[\\/]/i.test(target);
211
+ }
212
+ /** The family prefix implied by a document's directory, if any. */
213
+ export function familyFromPath(posixPath) {
214
+ const segments = posixPath.toLowerCase().split('/');
215
+ // Nearest enclosing directory wins: `docs/rfcs/adr/0007.md` is an ADR.
216
+ for (let i = segments.length - 2; i >= 0; i -= 1) {
217
+ const found = FAMILY_DIRECTORIES[segments[i]];
218
+ if (found)
219
+ return found;
220
+ }
221
+ return null;
222
+ }
223
+ /* -------------------------------------------------------------------------- */
224
+ /* Internals */
225
+ /* -------------------------------------------------------------------------- */
226
+ function fileStem(posixPath) {
227
+ const base = posixPath.slice(posixPath.lastIndexOf('/') + 1);
228
+ const dot = base.lastIndexOf('.');
229
+ const stem = dot > 0 ? base.slice(0, dot) : base;
230
+ // `docs/adr/0007-sharding/README.md` is identified by its directory - but a
231
+ // README at the repository root has no directory to be named after, and must
232
+ // keep its own name.
233
+ const slash = posixPath.lastIndexOf('/');
234
+ if (slash > 0 && /^(readme|index)$/i.test(stem)) {
235
+ const parent = posixPath.slice(0, slash);
236
+ return parent.slice(parent.lastIndexOf('/') + 1);
237
+ }
238
+ return stem;
239
+ }
240
+ function stripExtension(posixPath) {
241
+ const dot = posixPath.lastIndexOf('.');
242
+ const slash = posixPath.lastIndexOf('/');
243
+ return dot > slash ? posixPath.slice(0, dot) : posixPath;
244
+ }
245
+ /** `kep-1234-foo` -> `kep-1234`; `0007-sharding` -> `0007`; `sharding` -> `sharding`. */
246
+ function leadingToken(stem) {
247
+ const prefixed = /^([A-Za-z]{1,15}[\s._-]?\d{1,6})(?:[\s._-]|$)/.exec(stem);
248
+ if (prefixed)
249
+ return prefixed[1];
250
+ const bare = /^(\d{1,6})(?:[\s._-]|$)/.exec(stem);
251
+ if (bare)
252
+ return bare[1];
253
+ return stem;
254
+ }
255
+ /** `# ADR-0007: Sharding the write path` -> `ADR-0007`. */
256
+ function headingId(heading) {
257
+ if (!heading)
258
+ return null;
259
+ const match = /^\s*([A-Za-z]{1,15}[\s._-]?\d{1,6})\b/.exec(heading);
260
+ return match ? match[1] : null;
261
+ }
262
+ /**
263
+ * Chooses the digit width of the canonical id.
264
+ *
265
+ * A repository that writes `0007` everywhere should see `ADR-0007` in reports,
266
+ * not `ADR-7`. The file name is the most reliable witness of the local
267
+ * convention, so it decides.
268
+ */
269
+ function padNumber(number, witnesses) {
270
+ const width = paddingWidth(number, witnesses) ?? String(number).length;
271
+ return String(number).padStart(width, '0');
272
+ }
273
+ /** The digit width of the first witness that actually spells this number. */
274
+ function paddingWidth(number, witnesses) {
275
+ for (const witness of witnesses) {
276
+ if (!witness)
277
+ continue;
278
+ const digits = /(\d{1,6})/.exec(witness)?.[1];
279
+ if (digits && Number.parseInt(digits, 10) === number)
280
+ return digits.length;
281
+ }
282
+ return null;
283
+ }
284
+ /** Zero-padded spellings a citation might reasonably use. */
285
+ function numberSpellings(number, witnesses) {
286
+ const out = new Set([String(number)]);
287
+ const observed = paddingWidth(number, witnesses);
288
+ if (observed !== null)
289
+ out.add(String(number).padStart(observed, '0'));
290
+ for (const width of [3, 4])
291
+ out.add(String(number).padStart(width, '0'));
292
+ return [...out];
293
+ }
294
+ //# sourceMappingURL=identity.js.map