@sprig-and-prose/sprig-universe 0.3.3 → 0.3.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprig-and-prose/sprig-universe",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "Minimal universe parser for sprig",
6
6
  "main": "src/index.js",
@@ -27,4 +27,3 @@
27
27
  "typescript": "^5.7.2"
28
28
  }
29
29
  }
30
-
package/src/ast.js CHANGED
@@ -43,6 +43,13 @@
43
43
  * @property {SourceSpan} source - Source span
44
44
  */
45
45
 
46
+ /**
47
+ * @typedef {Object} OrderingBlock
48
+ * @property {string} kind - Always 'ordering'
49
+ * @property {string[]} identifiers - Array of identifier names
50
+ * @property {SourceSpan} source - Source span
51
+ */
52
+
46
53
  /**
47
54
  * @typedef {Object} DocumentBlock
48
55
  * @property {string} kind - Always 'document'
@@ -107,7 +114,7 @@
107
114
  * @typedef {Object} UniverseDecl
108
115
  * @property {string} kind - Always 'universe'
109
116
  * @property {string} name - Universe name
110
- * @property {Array<AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body - Body declarations
117
+ * @property {Array<AnthologyDecl | SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>} body - Body declarations
111
118
  * @property {SourceSpan} source - Source span
112
119
  */
113
120
 
@@ -116,7 +123,7 @@
116
123
  * @property {string} kind - Always 'anthology'
117
124
  * @property {string} name - Anthology name
118
125
  * @property {string} [parentName] - Optional parent universe name (from "in UniverseName")
119
- * @property {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
126
+ * @property {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
120
127
  * @property {SourceSpan} source - Source span
121
128
  */
122
129
 
@@ -125,7 +132,7 @@
125
132
  * @property {string} kind - Always 'series'
126
133
  * @property {string} name - Series name
127
134
  * @property {string} [parentName] - Optional parent anthology name (from "in AnthologyName")
128
- * @property {Array<BookDecl | ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
135
+ * @property {Array<BookDecl | ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
129
136
  * @property {SourceSpan} source - Source span
130
137
  */
131
138
 
@@ -134,7 +141,7 @@
134
141
  * @property {string} kind - Always 'book'
135
142
  * @property {string} name - Book name
136
143
  * @property {string} [parentName] - Optional parent name (from "in ParentName")
137
- * @property {Array<ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
144
+ * @property {Array<ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
138
145
  * @property {SourceSpan} source - Source span
139
146
  */
140
147
 
@@ -143,7 +150,7 @@
143
150
  * @property {string} kind - Always 'chapter'
144
151
  * @property {string} name - Chapter name
145
152
  * @property {string} parentName - Parent name (from "in ParentName")
146
- * @property {Array<DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
153
+ * @property {Array<DescribeBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
147
154
  * @property {SourceSpan} source - Source span
148
155
  */
149
156
 
@@ -152,7 +159,7 @@
152
159
  * @property {string} kind - Always 'concept'
153
160
  * @property {string} name - Concept name
154
161
  * @property {string} [parentName] - Optional parent name (from "in ParentName")
155
- * @property {Array<DescribeBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
162
+ * @property {Array<DescribeBlock | ReferencesBlock | OrderingBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
156
163
  * @property {SourceSpan} source - Source span
157
164
  */
158
165
 
package/src/graph.js CHANGED
@@ -27,6 +27,7 @@ import { normalizeProseBlock } from './util/text.js';
27
27
  * @typedef {import('./ast.js').FromBlock} FromBlock
28
28
  * @typedef {import('./ast.js').RelationshipsBlock} RelationshipsBlock
29
29
  * @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
30
+ * @typedef {import('./ast.js').OrderingBlock} OrderingBlock
30
31
  * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
31
32
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
32
33
  * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
@@ -294,6 +295,9 @@ export function buildGraph(fileASTs) {
294
295
  // Resolve container references (books/chapters) that may have forward references
295
296
  resolveContainers(graph, scopeIndex, pendingContainerResolutions);
296
297
 
298
+ // Validate and apply ordering blocks after all nodes and relationships are established
299
+ validateOrderingBlocks(graph);
300
+
297
301
  return graph;
298
302
  }
299
303
 
@@ -522,6 +526,9 @@ function processBody(
522
526
  pendingReferenceAttachments,
523
527
  pendingContainerResolutions,
524
528
  ) {
529
+ // Collect ordering blocks to process after all children are added
530
+ const orderingBlocks = [];
531
+
525
532
  for (const decl of body) {
526
533
  if (decl.kind === 'anthology') {
527
534
  const nodeId = makeNodeId(universeName, 'anthology', decl.name);
@@ -952,6 +959,9 @@ function processBody(
952
959
  universeName,
953
960
  });
954
961
  }
962
+ } else if (decl.kind === 'ordering') {
963
+ // Collect ordering blocks to process after all children are added
964
+ orderingBlocks.push(decl);
955
965
  } else if (decl.kind === 'documentation') {
956
966
  // DocumentationBlock - attach to current node
957
967
  const currentNode = graph.nodes[currentNodeId];
@@ -1116,6 +1126,30 @@ function processBody(
1116
1126
  });
1117
1127
  }
1118
1128
  }
1129
+
1130
+ // Store ordering blocks for later validation (after all nodes are created)
1131
+ // We'll validate them in a separate pass after the entire graph is built
1132
+ // This is necessary because children may be defined outside their parent's body
1133
+ // (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
1134
+ if (orderingBlocks.length > 0) {
1135
+ const currentNode = graph.nodes[currentNodeId];
1136
+ if (!currentNode) {
1137
+ return;
1138
+ }
1139
+
1140
+ // Validate: only one ordering block per container
1141
+ if (orderingBlocks.length > 1) {
1142
+ graph.diagnostics.push({
1143
+ severity: 'warning',
1144
+ message: `Multiple ordering blocks in ${currentNode.kind} "${currentNode.name}". Using the first one.`,
1145
+ source: orderingBlocks[1].source,
1146
+ });
1147
+ }
1148
+
1149
+ // Store ordering block temporarily on the node for later validation
1150
+ // We'll process this in validateOrderingBlocks() after all nodes are created
1151
+ currentNode._pendingOrdering = orderingBlocks[0];
1152
+ }
1119
1153
  }
1120
1154
 
1121
1155
  /**
@@ -1746,6 +1780,56 @@ function makeRelatesNodeId(universeName, a, b, index = 0) {
1746
1780
  return `${universeName}:relates:${a}:${b}:${index}`;
1747
1781
  }
1748
1782
 
1783
+ /**
1784
+ * Validates and applies ordering blocks after all nodes and relationships are established
1785
+ * This must run after all nodes are created because children may be defined outside their parent's body
1786
+ * (e.g., "book X in Y" defined at anthology level, not inside series Y's body)
1787
+ * @param {UniverseGraph} graph
1788
+ */
1789
+ function validateOrderingBlocks(graph) {
1790
+ for (const nodeId in graph.nodes) {
1791
+ const node = graph.nodes[nodeId];
1792
+ if (!node._pendingOrdering) {
1793
+ continue;
1794
+ }
1795
+
1796
+ const orderingBlock = node._pendingOrdering;
1797
+ const orderingIdentifiers = orderingBlock.identifiers;
1798
+
1799
+ // Build a map of child names to node IDs for validation
1800
+ const childNameToNodeId = new Map();
1801
+ for (const childId of node.children) {
1802
+ const childNode = graph.nodes[childId];
1803
+ if (childNode) {
1804
+ childNameToNodeId.set(childNode.name, childId);
1805
+ }
1806
+ }
1807
+
1808
+ // Validate ordering identifiers against actual children
1809
+ const validOrdering = [];
1810
+ for (const identifier of orderingIdentifiers) {
1811
+ if (childNameToNodeId.has(identifier)) {
1812
+ validOrdering.push(identifier);
1813
+ } else {
1814
+ // Unknown identifier - emit gentle warning
1815
+ graph.diagnostics.push({
1816
+ severity: 'warning',
1817
+ message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
1818
+ source: orderingBlock.source,
1819
+ });
1820
+ }
1821
+ }
1822
+
1823
+ // Store ordering if there are any valid identifiers
1824
+ if (validOrdering.length > 0) {
1825
+ node.ordering = validOrdering;
1826
+ }
1827
+
1828
+ // Clean up temporary field
1829
+ delete node._pendingOrdering;
1830
+ }
1831
+ }
1832
+
1749
1833
  /**
1750
1834
  * Creates an edge ID
1751
1835
  * @param {string} universeName
package/src/ir.js CHANGED
@@ -61,6 +61,7 @@
61
61
  * @property {NodeId} [parent] - Tree parent node ID
62
62
  * @property {NodeId[]} children - Child node IDs
63
63
  * @property {NodeId} [container] - Container node ID (for book/chapter "in" relationship; always set for book/chapter nodes when resolved)
64
+ * @property {string[]} [ordering] - Optional ordering of direct children by identifier name
64
65
  * @property {TextBlock} [describe] - Describe block if present
65
66
  * @property {UnknownBlock[]} [unknownBlocks] - Unknown blocks if any
66
67
  * @property {string[]} [references] - Reference IDs attached to this node
package/src/parser.js CHANGED
@@ -16,6 +16,7 @@ import { mergeSpans } from './util/span.js';
16
16
  * @typedef {import('./ast.js').DescribeBlock} DescribeBlock
17
17
  * @typedef {import('./ast.js').UnknownBlock} UnknownBlock
18
18
  * @typedef {import('./ast.js').ReferencesBlock} ReferencesBlock
19
+ * @typedef {import('./ast.js').OrderingBlock} OrderingBlock
19
20
  * @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
20
21
  * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
21
22
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
@@ -177,7 +178,7 @@ class Parser {
177
178
  const startToken = this.expect('KEYWORD', 'universe');
178
179
  const nameToken = this.expect('IDENTIFIER');
179
180
  const lbrace = this.expect('LBRACE');
180
- const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'documentation']);
181
+ const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'ordering', 'documentation']);
181
182
  const rbrace = this.expect('RBRACE');
182
183
 
183
184
  return {
@@ -246,6 +247,8 @@ class Parser {
246
247
  body.push(this.parseRelationships());
247
248
  } else if (keyword === 'references' && allowedKeywords.includes('references')) {
248
249
  body.push(this.parseReferences());
250
+ } else if (keyword === 'ordering' && allowedKeywords.includes('ordering')) {
251
+ body.push(this.parseOrdering());
249
252
  } else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
250
253
  body.push(this.parseDocumentation());
251
254
  } else {
@@ -290,6 +293,7 @@ class Parser {
290
293
  'concept',
291
294
  'relates',
292
295
  'references',
296
+ 'ordering',
293
297
  'documentation',
294
298
  'repository',
295
299
  'reference',
@@ -325,7 +329,7 @@ class Parser {
325
329
  }
326
330
 
327
331
  const lbrace = this.expect('LBRACE');
328
- const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
332
+ const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
329
333
  const rbrace = this.expect('RBRACE');
330
334
 
331
335
  return {
@@ -357,7 +361,7 @@ class Parser {
357
361
  }
358
362
 
359
363
  const lbrace = this.expect('LBRACE');
360
- const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
364
+ const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
361
365
  const rbrace = this.expect('RBRACE');
362
366
 
363
367
  return {
@@ -424,7 +428,7 @@ class Parser {
424
428
  }
425
429
 
426
430
  const lbrace = this.expect('LBRACE');
427
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
431
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'ordering', 'documentation', 'repository', 'reference']);
428
432
  const rbrace = this.expect('RBRACE');
429
433
 
430
434
  return {
@@ -641,6 +645,62 @@ class Parser {
641
645
  };
642
646
  }
643
647
 
648
+ /**
649
+ * Parses an ordering block containing a list of identifiers
650
+ * @returns {OrderingBlock}
651
+ */
652
+ parseOrdering() {
653
+ const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'ordering') : this.expect('IDENTIFIER', 'ordering');
654
+ const lbrace = this.expect('LBRACE');
655
+ const identifiers = [];
656
+ let depth = 1;
657
+
658
+ while (this.pos < this.tokens.length && !this.match('EOF')) {
659
+ if (this.match('LBRACE')) {
660
+ throw new Error(`Unexpected '{' in ordering block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
661
+ }
662
+
663
+ if (this.match('RBRACE')) {
664
+ depth -= 1;
665
+ this.advance();
666
+ if (depth === 0) {
667
+ break;
668
+ }
669
+ continue;
670
+ }
671
+
672
+ if (depth !== 1) {
673
+ this.advance();
674
+ continue;
675
+ }
676
+
677
+ if (this.match('COMMA')) {
678
+ this.advance();
679
+ continue;
680
+ }
681
+
682
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
683
+ const identifierToken = this.advance();
684
+ if (identifierToken) {
685
+ identifiers.push(identifierToken.value);
686
+ }
687
+ continue;
688
+ }
689
+
690
+ this.advance();
691
+ }
692
+
693
+ return {
694
+ kind: 'ordering',
695
+ identifiers,
696
+ source: {
697
+ file: this.file,
698
+ start: startToken.span.start,
699
+ end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
700
+ },
701
+ };
702
+ }
703
+
644
704
  /**
645
705
  * Parses a references block containing a list of identifier paths
646
706
  * @returns {ReferencesBlock}