@sprig-and-prose/sprig-universe 0.3.0 → 0.3.1

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.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Minimal universe parser for sprig",
6
6
  "main": "src/index.js",
package/src/ast.js CHANGED
@@ -133,7 +133,7 @@
133
133
  * @typedef {Object} BookDecl
134
134
  * @property {string} kind - Always 'book'
135
135
  * @property {string} name - Book name
136
- * @property {string} parentName - Parent name (from "in ParentName")
136
+ * @property {string} [parentName] - Optional parent name (from "in ParentName")
137
137
  * @property {Array<ChapterDecl | DescribeBlock | TitleBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | UnknownBlock>} body - Body declarations
138
138
  * @property {SourceSpan} source - Source span
139
139
  */
package/src/graph.js CHANGED
@@ -578,23 +578,29 @@ function processBody(
578
578
  } else if (decl.kind === 'book') {
579
579
  const nodeId = makeNodeId(universeName, 'book', decl.name);
580
580
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'book', decl.source);
581
- const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
582
- const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
583
- // Always set container for book nodes (even if undefined when parentName doesn't resolve)
581
+
582
+ // Handle optional parent
583
+ let actualParentNodeId = parentNodeId;
584
+ let containerNodeId = undefined;
585
+ if (decl.parentName) {
586
+ const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
587
+ if (resolved.nodeId && !resolved.ambiguous) {
588
+ actualParentNodeId = resolved.nodeId;
589
+ containerNodeId = resolved.nodeId;
590
+ }
591
+ // If parent not found, fall back to parentNodeId (tolerant parsing)
592
+ }
593
+
584
594
  const node = createNode(
585
595
  nodeId,
586
596
  'book',
587
597
  decl.name,
588
- containerNodeId || parentNodeId,
598
+ actualParentNodeId,
589
599
  decl,
590
- containerNodeId, // May be undefined if parentName doesn't resolve
600
+ containerNodeId, // May be undefined if no parentName or if parentName doesn't resolve
591
601
  );
592
602
  graph.nodes[nodeId] = node;
593
- if (containerNodeId) {
594
- graph.nodes[containerNodeId].children.push(nodeId);
595
- } else {
596
- graph.nodes[parentNodeId].children.push(nodeId);
597
- }
603
+ graph.nodes[actualParentNodeId].children.push(nodeId);
598
604
  addNameToScope(graph, scopeIndex, currentNodeId, decl.name, nodeId);
599
605
  processBody(
600
606
  graph,
@@ -614,8 +620,39 @@ function processBody(
614
620
  } else if (decl.kind === 'chapter') {
615
621
  const nodeId = makeNodeId(universeName, 'chapter', decl.name);
616
622
  checkDuplicateInScope(graph, scopeIndex, currentNodeId, decl.name, nodeId, 'chapter', decl.source);
623
+
624
+ // Validate: chapter must have an "in" block
625
+ if (!decl.parentName) {
626
+ graph.diagnostics.push({
627
+ severity: 'error',
628
+ message: `Chapter "${decl.name}" must belong to a book. Use "chapter ${decl.name} in <BookName> { ... }"`,
629
+ source: decl.source,
630
+ });
631
+ // Continue processing but mark as invalid
632
+ }
633
+
617
634
  const resolved = resolveNameInScope(graph, scopeIndex, decl.parentName, currentNodeId, decl.source);
618
635
  const containerNodeId = resolved.nodeId && !resolved.ambiguous ? resolved.nodeId : null;
636
+
637
+ // Validate: chapter container must be a book
638
+ if (containerNodeId) {
639
+ const containerNode = graph.nodes[containerNodeId];
640
+ if (containerNode && containerNode.kind !== 'book') {
641
+ graph.diagnostics.push({
642
+ severity: 'error',
643
+ message: `Chapter "${decl.name}" must belong to a book, but "${decl.parentName}" is a ${containerNode.kind}`,
644
+ source: decl.source,
645
+ });
646
+ }
647
+ } else if (decl.parentName) {
648
+ // Container not found
649
+ graph.diagnostics.push({
650
+ severity: 'error',
651
+ message: `Chapter "${decl.name}" references unknown book "${decl.parentName}"`,
652
+ source: decl.source,
653
+ });
654
+ }
655
+
619
656
  // Always set container for chapter nodes (even if undefined when parentName doesn't resolve)
620
657
  const node = createNode(
621
658
  nodeId,
package/src/parser.js CHANGED
@@ -347,8 +347,15 @@ class Parser {
347
347
  parseBook() {
348
348
  const startToken = this.expect('KEYWORD', 'book');
349
349
  const nameToken = this.expect('IDENTIFIER');
350
- this.expect('KEYWORD', 'in');
351
- const parentToken = this.expect('IDENTIFIER');
350
+
351
+ // Optional "in <ParentName>" syntax
352
+ let parentName = undefined;
353
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
354
+ this.expect('KEYWORD', 'in');
355
+ const parentToken = this.expect('IDENTIFIER');
356
+ parentName = parentToken.value;
357
+ }
358
+
352
359
  const lbrace = this.expect('LBRACE');
353
360
  const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
354
361
  const rbrace = this.expect('RBRACE');
@@ -356,7 +363,7 @@ class Parser {
356
363
  return {
357
364
  kind: 'book',
358
365
  name: nameToken.value,
359
- parentName: parentToken.value,
366
+ parentName,
360
367
  body,
361
368
  source: {
362
369
  file: this.file,
@@ -372,6 +379,16 @@ class Parser {
372
379
  parseChapter() {
373
380
  const startToken = this.expect('KEYWORD', 'chapter');
374
381
  const nameToken = this.expect('IDENTIFIER');
382
+
383
+ // Chapters must belong to a book - check for "in" keyword
384
+ if (!this.match('KEYWORD') || this.peek()?.value !== 'in') {
385
+ const nextToken = this.peek();
386
+ const line = nextToken ? nextToken.span.start.line : startToken.span.start.line;
387
+ throw new Error(
388
+ `Chapter "${nameToken.value}" must belong to a book. Use "chapter ${nameToken.value} in <BookName> { ... }" at ${this.file}:${line}`,
389
+ );
390
+ }
391
+
375
392
  this.expect('KEYWORD', 'in');
376
393
  const parentToken = this.expect('IDENTIFIER');
377
394
  const lbrace = this.expect('LBRACE');