@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 +1 -1
- package/src/ast.js +1 -1
- package/src/graph.js +47 -10
- package/src/parser.js +20 -3
package/package.json
CHANGED
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 -
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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
|
|
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');
|