@sprig-and-prose/sprig-universe 0.2.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/src/parser.js CHANGED
@@ -16,9 +16,8 @@ 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').ReferenceBlock} ReferenceBlock
20
- * @typedef {import('./ast.js').NamedReferenceBlock} NamedReferenceBlock
21
- * @typedef {import('./ast.js').UsingInReferencesBlock} UsingInReferencesBlock
19
+ * @typedef {import('./ast.js').RepositoryDecl} RepositoryDecl
20
+ * @typedef {import('./ast.js').ReferenceDecl} ReferenceDecl
22
21
  * @typedef {import('./ast.js').DocumentationBlock} DocumentationBlock
23
22
  * @typedef {import('./ast.js').DocumentBlock} DocumentBlock
24
23
  * @typedef {import('./ast.js').NamedDocumentBlock} NamedDocumentBlock
@@ -106,7 +105,6 @@ class Parser {
106
105
  parseFile() {
107
106
  const universes = [];
108
107
  const scenes = [];
109
- const topLevelDeclsByUniverse = new Map();
110
108
  const topLevelDeclsUnscoped = [];
111
109
  const startToken = this.peek();
112
110
 
@@ -128,18 +126,14 @@ class Parser {
128
126
  decl = this.parseConcept();
129
127
  } else if (keyword === 'anthology') {
130
128
  decl = this.parseAnthology();
129
+ } else if (keyword === 'repository') {
130
+ decl = this.parseRepository();
131
+ } else if (keyword === 'reference') {
132
+ decl = this.parseReferenceDecl();
131
133
  }
132
134
 
133
135
  if (decl) {
134
- if (decl.parentName) {
135
- const universeName = decl.parentName;
136
- if (!topLevelDeclsByUniverse.has(universeName)) {
137
- topLevelDeclsByUniverse.set(universeName, []);
138
- }
139
- topLevelDeclsByUniverse.get(universeName).push(decl);
140
- } else {
141
- topLevelDeclsUnscoped.push(decl);
142
- }
136
+ topLevelDeclsUnscoped.push(decl);
143
137
  } else {
144
138
  // Skip unknown top-level content (tolerant parsing)
145
139
  const token = this.advance();
@@ -156,43 +150,16 @@ class Parser {
156
150
  }
157
151
  }
158
152
 
159
- // Merge top-level declarations into their target universes
160
- if (topLevelDeclsByUniverse.size > 0 || topLevelDeclsUnscoped.length > 0) {
161
- const universesByName = new Map();
162
- for (const universe of universes) {
163
- universesByName.set(universe.name, universe);
164
- }
165
-
166
- for (const [universeName, decls] of topLevelDeclsByUniverse.entries()) {
167
- const universe = universesByName.get(universeName);
168
- if (universe) {
169
- universe.body.push(...decls);
170
- } else {
171
- const firstDecl = decls[0];
172
- const lastDecl = decls[decls.length - 1];
173
- universes.push({
174
- kind: 'universe',
175
- name: universeName,
176
- body: decls,
177
- source: {
178
- file: this.file,
179
- start: firstDecl.source.start,
180
- end: lastDecl.source.end,
181
- },
182
- });
183
- universesByName.set(universeName, universes[universes.length - 1]);
184
- }
185
- }
186
-
187
- if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
188
- universes[0].body.push(...topLevelDeclsUnscoped);
189
- }
153
+ // Merge top-level declarations into their target universe
154
+ if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
155
+ universes[0].body.push(...topLevelDeclsUnscoped);
190
156
  }
191
157
 
192
158
  return {
193
159
  file: this.file,
194
160
  universes,
195
161
  scenes,
162
+ topLevelDecls: topLevelDeclsUnscoped.length > 0 ? topLevelDeclsUnscoped : undefined,
196
163
  source: startToken
197
164
  ? {
198
165
  file: this.file,
@@ -210,7 +177,7 @@ class Parser {
210
177
  const startToken = this.expect('KEYWORD', 'universe');
211
178
  const nameToken = this.expect('IDENTIFIER');
212
179
  const lbrace = this.expect('LBRACE');
213
- const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository']);
180
+ const body = this.parseBlockBody(['anthology', 'series', 'book', 'chapter', 'concept', 'relates', 'describe', 'title', 'repository', 'reference', 'references', 'documentation']);
214
181
  const rbrace = this.expect('RBRACE');
215
182
 
216
183
  return {
@@ -227,7 +194,7 @@ class Parser {
227
194
 
228
195
  /**
229
196
  * @param {string[]} allowedKeywords - Keywords allowed in this body
230
- * @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | NamedReferenceBlock | NamedDocumentBlock | UnknownBlock>}
197
+ * @returns {Array<SeriesDecl | BookDecl | ChapterDecl | ConceptDecl | RelatesDecl | DescribeBlock | TitleBlock | FromBlock | RelationshipsBlock | ReferencesBlock | DocumentationBlock | RepositoryDecl | ReferenceDecl | NamedDocumentBlock | UnknownBlock>}
231
198
  */
232
199
  parseBlockBody(allowedKeywords) {
233
200
  const body = [];
@@ -238,21 +205,6 @@ class Parser {
238
205
  const keyword = this.peek()?.value;
239
206
  if (!keyword) break;
240
207
 
241
- // Check for named reference: reference <IDENTIFIER> { ... }
242
- if (keyword === 'reference') {
243
- const nextPos = this.pos + 1;
244
- if (nextPos < this.tokens.length &&
245
- this.tokens[nextPos].type === 'IDENTIFIER' &&
246
- nextPos + 1 < this.tokens.length &&
247
- this.tokens[nextPos + 1].type === 'LBRACE') {
248
- // This is a named reference block
249
- body.push(this.parseNamedReference());
250
- continue;
251
- }
252
- // Otherwise, it's an inline reference (only valid inside references block)
253
- // Fall through to unknown block handling
254
- }
255
-
256
208
  // Check for named document: document <IDENTIFIER> { ... }
257
209
  if (keyword === 'document') {
258
210
  const nextPos = this.pos + 1;
@@ -286,13 +238,15 @@ class Parser {
286
238
  body.push(this.parseTitle());
287
239
  } else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
288
240
  body.push(this.parseRepository());
241
+ } else if (keyword === 'reference' && allowedKeywords.includes('reference')) {
242
+ body.push(this.parseReferenceDecl());
289
243
  } else if (keyword === 'from' && allowedKeywords.includes('from')) {
290
244
  body.push(this.parseFrom());
291
245
  } else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
292
246
  body.push(this.parseRelationships());
293
- } else if (keyword === 'references') {
247
+ } else if (keyword === 'references' && allowedKeywords.includes('references')) {
294
248
  body.push(this.parseReferences());
295
- } else if (keyword === 'documentation') {
249
+ } else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
296
250
  body.push(this.parseDocumentation());
297
251
  } else {
298
252
  // Unknown keyword/identifier followed by brace - parse as UnknownBlock
@@ -320,13 +274,32 @@ class Parser {
320
274
  parseAnthology() {
321
275
  const startToken = this.expect('KEYWORD', 'anthology');
322
276
  const nameToken = this.expect('IDENTIFIER');
277
+ let parentName = undefined;
278
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
279
+ this.expect('KEYWORD', 'in');
280
+ const parentToken = this.expect('IDENTIFIER');
281
+ parentName = parentToken.value;
282
+ }
323
283
  const lbrace = this.expect('LBRACE');
324
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
284
+ const body = this.parseBlockBody([
285
+ 'describe',
286
+ 'title',
287
+ 'series',
288
+ 'book',
289
+ 'chapter',
290
+ 'concept',
291
+ 'relates',
292
+ 'references',
293
+ 'documentation',
294
+ 'repository',
295
+ 'reference',
296
+ ]);
325
297
  const rbrace = this.expect('RBRACE');
326
298
 
327
299
  return {
328
300
  kind: 'anthology',
329
301
  name: nameToken.value,
302
+ parentName,
330
303
  body,
331
304
  source: {
332
305
  file: this.file,
@@ -352,7 +325,7 @@ class Parser {
352
325
  }
353
326
 
354
327
  const lbrace = this.expect('LBRACE');
355
- const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
328
+ const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
356
329
  const rbrace = this.expect('RBRACE');
357
330
 
358
331
  return {
@@ -374,16 +347,23 @@ class Parser {
374
347
  parseBook() {
375
348
  const startToken = this.expect('KEYWORD', 'book');
376
349
  const nameToken = this.expect('IDENTIFIER');
377
- this.expect('KEYWORD', 'in');
378
- 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
+
379
359
  const lbrace = this.expect('LBRACE');
380
- const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
360
+ const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
381
361
  const rbrace = this.expect('RBRACE');
382
362
 
383
363
  return {
384
364
  kind: 'book',
385
365
  name: nameToken.value,
386
- parentName: parentToken.value,
366
+ parentName,
387
367
  body,
388
368
  source: {
389
369
  file: this.file,
@@ -399,10 +379,20 @@ class Parser {
399
379
  parseChapter() {
400
380
  const startToken = this.expect('KEYWORD', 'chapter');
401
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
+
402
392
  this.expect('KEYWORD', 'in');
403
393
  const parentToken = this.expect('IDENTIFIER');
404
394
  const lbrace = this.expect('LBRACE');
405
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
395
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
406
396
  const rbrace = this.expect('RBRACE');
407
397
 
408
398
  return {
@@ -434,7 +424,7 @@ class Parser {
434
424
  }
435
425
 
436
426
  const lbrace = this.expect('LBRACE');
437
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
427
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
438
428
  const rbrace = this.expect('RBRACE');
439
429
 
440
430
  return {
@@ -652,193 +642,141 @@ class Parser {
652
642
  }
653
643
 
654
644
  /**
655
- * Parses a references block containing nested reference blocks and using blocks
645
+ * Parses a references block containing a list of identifier paths
656
646
  * @returns {ReferencesBlock}
657
647
  */
658
648
  parseReferences() {
659
649
  const startToken = this.expect('IDENTIFIER', 'references');
660
650
  const lbrace = this.expect('LBRACE');
661
- const references = [];
651
+ const items = [];
652
+ let depth = 1;
662
653
 
663
- // Parse nested reference blocks and using blocks
664
- while (!this.match('RBRACE') && !this.match('EOF')) {
665
- // Check for 'using' keyword first (consistent with Scene layer)
666
- if (this.match('KEYWORD') && this.peek()?.value === 'using') {
667
- references.push(this.parseUsingInReferences());
668
- } else if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'reference') {
669
- // Look for 'reference' identifier
670
- references.push(this.parseReference());
671
- } else {
672
- // Skip unexpected tokens (tolerant parsing)
654
+ while (this.pos < this.tokens.length && !this.match('EOF')) {
655
+ if (this.match('LBRACE')) {
656
+ throw new Error(`Unexpected '{' in references block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
657
+ }
658
+ if (this.match('LBRACE')) {
659
+ depth += 1;
673
660
  this.advance();
661
+ continue;
674
662
  }
675
- }
676
-
677
- const rbrace = this.expect('RBRACE');
678
-
679
- return {
680
- kind: 'references',
681
- references,
682
- source: {
683
- file: this.file,
684
- start: startToken.span.start,
685
- end: rbrace.span.end,
686
- },
687
- };
688
- }
689
663
 
690
- /**
691
- * Parses a using block inside a references block: using { IdentifierList }
692
- * @returns {UsingInReferencesBlock}
693
- */
694
- parseUsingInReferences() {
695
- const startToken = this.expect('KEYWORD', 'using');
696
- const lbrace = this.expect('LBRACE');
697
- const names = [];
698
-
699
- while (!this.match('RBRACE') && !this.match('EOF')) {
700
- if (this.match('IDENTIFIER')) {
701
- names.push(this.expect('IDENTIFIER').value);
702
- // Skip optional comma
703
- if (this.match('COMMA')) {
704
- this.expect('COMMA');
705
- }
706
- } else if (this.match('COMMA')) {
707
- // Skip stray commas
708
- this.expect('COMMA');
709
- } else {
710
- // Skip non-identifiers (tolerant parsing)
664
+ if (this.match('RBRACE')) {
665
+ depth -= 1;
711
666
  this.advance();
667
+ if (depth === 0) {
668
+ break;
669
+ }
670
+ continue;
712
671
  }
713
- }
714
-
715
- const rbrace = this.expect('RBRACE');
716
-
717
- return {
718
- kind: 'using-in-references',
719
- names,
720
- source: {
721
- file: this.file,
722
- start: startToken.span.start,
723
- end: rbrace.span.end,
724
- },
725
- };
726
- }
727
672
 
728
- /**
729
- * Parses a single reference block
730
- * @returns {ReferenceBlock}
731
- */
732
- parseReference() {
733
- const startToken = this.expect('IDENTIFIER', 'reference');
734
- const lbrace = this.expect('LBRACE');
735
- let repository = null;
736
- let paths = [];
737
- let describe = null;
738
- let kind = null;
673
+ if (depth !== 1) {
674
+ this.advance();
675
+ continue;
676
+ }
739
677
 
740
- // Parse repository, paths, optional kind, and optional describe
741
- while (!this.match('RBRACE') && !this.match('EOF')) {
742
- if (this.match('RBRACE')) break;
678
+ if (this.match('COMMA')) {
679
+ this.advance();
680
+ continue;
681
+ }
743
682
 
744
- // Check for identifier or keyword (describe is a keyword)
745
683
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
746
- const identifier = this.peek()?.value;
747
- if (identifier === 'repository') {
748
- repository = this.parseStringBlock('repository');
749
- } else if (identifier === 'paths') {
750
- paths = this.parsePathsBlock();
751
- } else if (identifier === 'kind') {
752
- kind = this.parseStringBlock('kind');
753
- } else if (identifier === 'describe') {
754
- describe = this.parseDescribe();
755
- } else {
756
- // Skip unknown identifier/keyword
757
- this.advance();
684
+ const startToken = this.peek();
685
+ const name = this.parseIdentifierPath();
686
+ if (name && startToken) {
687
+ const lastToken = this.tokens[this.pos - 1] || startToken;
688
+ items.push({
689
+ name,
690
+ source: {
691
+ file: this.file,
692
+ start: startToken.span.start,
693
+ end: lastToken.span.end,
694
+ },
695
+ });
758
696
  }
759
- } else {
760
- // Skip unexpected tokens
761
- this.advance();
697
+ continue;
762
698
  }
763
- }
764
699
 
765
- const rbrace = this.expect('RBRACE');
766
-
767
- if (!repository) {
768
- throw new Error(`Missing 'repository' field in reference block at ${this.file}:${startToken.span.start.line}`);
769
- }
770
- if (paths.length === 0) {
771
- throw new Error(`Missing or empty 'paths' field in reference block at ${this.file}:${startToken.span.start.line}`);
700
+ this.advance();
772
701
  }
773
702
 
774
703
  return {
775
- kind: 'reference',
776
- repository,
777
- paths,
778
- referenceKind: kind,
779
- describe,
704
+ kind: 'references',
705
+ items,
780
706
  source: {
781
707
  file: this.file,
782
708
  start: startToken.span.start,
783
- end: rbrace.span.end,
709
+ end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
784
710
  },
785
711
  };
786
712
  }
787
713
 
788
714
  /**
789
- * Parses a named reference block: reference <Name> { ... }
790
- * @returns {NamedReferenceBlock}
715
+ * Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
716
+ * @returns {ReferenceDecl}
791
717
  */
792
- parseNamedReference() {
793
- const startToken = this.expect('IDENTIFIER', 'reference');
794
- const nameToken = this.expect('IDENTIFIER');
718
+ parseReferenceDecl() {
719
+ const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
720
+ let nameToken = null;
721
+ if (this.match('IDENTIFIER')) {
722
+ nameToken = this.expect('IDENTIFIER');
723
+ }
724
+ let repositoryName = null;
725
+
726
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
727
+ this.expect('KEYWORD', 'in');
728
+ const repoName = this.parseIdentifierPath();
729
+ if (!repoName) {
730
+ throw new Error(`Expected repository name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
731
+ }
732
+ repositoryName = repoName;
733
+ }
734
+
795
735
  const lbrace = this.expect('LBRACE');
796
- let repository = null;
797
- let paths = [];
798
- let describe = null;
736
+ let url = null;
737
+ let paths = null;
799
738
  let kind = null;
739
+ let describe = null;
740
+ let note = null;
741
+ let title = null;
800
742
 
801
- // Parse repository, paths, optional kind, and optional describe (same as parseReference)
802
743
  while (!this.match('RBRACE') && !this.match('EOF')) {
803
744
  if (this.match('RBRACE')) break;
804
745
 
805
- // Check for identifier or keyword (describe is a keyword)
806
746
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
807
747
  const identifier = this.peek()?.value;
808
- if (identifier === 'repository') {
809
- repository = this.parseStringBlock('repository');
748
+ if (identifier === 'url') {
749
+ url = this.parseStringBlock('url');
810
750
  } else if (identifier === 'paths') {
811
751
  paths = this.parsePathsBlock();
812
752
  } else if (identifier === 'kind') {
813
753
  kind = this.parseStringBlock('kind');
814
754
  } else if (identifier === 'describe') {
815
755
  describe = this.parseDescribe();
756
+ } else if (identifier === 'note') {
757
+ note = this.parseNote();
758
+ } else if (identifier === 'title') {
759
+ title = this.parseTitle();
816
760
  } else {
817
- // Skip unknown identifier/keyword
818
761
  this.advance();
819
762
  }
820
763
  } else {
821
- // Skip unexpected tokens
822
764
  this.advance();
823
765
  }
824
766
  }
825
767
 
826
768
  const rbrace = this.expect('RBRACE');
827
769
 
828
- if (!repository) {
829
- throw new Error(`Missing 'repository' field in named reference block at ${this.file}:${startToken.span.start.line}`);
830
- }
831
- if (paths.length === 0) {
832
- throw new Error(`Missing or empty 'paths' field in named reference block at ${this.file}:${startToken.span.start.line}`);
833
- }
834
-
835
770
  return {
836
- kind: 'named-reference',
837
- name: nameToken.value,
838
- repository,
839
- paths,
840
- referenceKind: kind,
841
- describe,
771
+ kind: 'reference',
772
+ name: nameToken ? nameToken.value : undefined,
773
+ repositoryName: repositoryName || undefined,
774
+ url: url || undefined,
775
+ paths: paths || undefined,
776
+ referenceKind: kind || undefined,
777
+ describe: describe || undefined,
778
+ note: note || undefined,
779
+ title: title || undefined,
842
780
  source: {
843
781
  file: this.file,
844
782
  start: startToken.span.start,
@@ -999,45 +937,58 @@ class Parser {
999
937
  }
1000
938
 
1001
939
  /**
1002
- * Parses a repository block: repository <Identifier> { kind { ... } options { ... } }
940
+ * Parses a repository block: repository <Identifier> { url { ... } ... }
1003
941
  * @returns {RepositoryDecl}
1004
942
  */
1005
943
  parseRepository() {
1006
944
  const startToken = this.expect('KEYWORD', 'repository');
1007
945
  const nameToken = this.expect('IDENTIFIER');
946
+ let parentName = undefined;
947
+
948
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
949
+ this.expect('KEYWORD', 'in');
950
+ const containerName = this.parseIdentifierPath();
951
+ if (!containerName) {
952
+ throw new Error(`Expected container name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
953
+ }
954
+ parentName = containerName;
955
+ }
1008
956
  const lbrace = this.expect('LBRACE');
1009
957
 
1010
- let kind = null;
1011
- let options = null;
958
+ let url = null;
959
+ let describe = null;
960
+ let note = null;
961
+ let title = null;
1012
962
 
1013
963
  while (!this.match('RBRACE') && !this.match('EOF')) {
1014
964
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1015
965
  const identifier = this.peek()?.value;
1016
- if (identifier === 'kind') {
1017
- kind = this.parseValueBlock('kind');
1018
- } else if (identifier === 'options') {
1019
- options = this.parseOptionsBlock();
966
+ if (identifier === 'url') {
967
+ url = this.parseStringBlock('url');
968
+ } else if (identifier === 'describe') {
969
+ describe = this.parseDescribe();
970
+ } else if (identifier === 'note') {
971
+ note = this.parseNote();
972
+ } else if (identifier === 'title') {
973
+ title = this.parseTitle();
1020
974
  } else {
1021
- // Skip unknown identifier/keyword
1022
975
  this.advance();
1023
976
  }
1024
977
  } else {
1025
- // Skip unexpected tokens
1026
978
  this.advance();
1027
979
  }
1028
980
  }
1029
981
 
1030
982
  const rbrace = this.expect('RBRACE');
1031
983
 
1032
- if (!kind) {
1033
- throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
1034
- }
1035
-
1036
984
  return {
1037
985
  kind: 'repository',
1038
986
  name: nameToken.value,
1039
- repositoryKind: kind,
1040
- options: options || {},
987
+ parentName,
988
+ url: url || undefined,
989
+ describe: describe || undefined,
990
+ note: note || undefined,
991
+ title: title || undefined,
1041
992
  source: {
1042
993
  file: this.file,
1043
994
  start: startToken.span.start,
@@ -1046,6 +997,82 @@ class Parser {
1046
997
  };
1047
998
  }
1048
999
 
1000
+ /**
1001
+ * Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
1002
+ * @returns {string | null}
1003
+ */
1004
+ parseIdentifierPath() {
1005
+ if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
1006
+ return null;
1007
+ }
1008
+
1009
+ const parts = [];
1010
+ parts.push(this.advance().value);
1011
+
1012
+ while (this.match('DOT')) {
1013
+ this.expect('DOT');
1014
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
1015
+ parts.push(this.advance().value);
1016
+ } else {
1017
+ break;
1018
+ }
1019
+ }
1020
+
1021
+ return parts.join('.');
1022
+ }
1023
+
1024
+ /**
1025
+ * Parses a note block, same format as describe
1026
+ * @returns {DescribeBlock}
1027
+ */
1028
+ parseNote() {
1029
+ const startToken = this.expect('IDENTIFIER', 'note');
1030
+ const lbrace = this.expect('LBRACE');
1031
+
1032
+ let depth = 1;
1033
+ const startOffset = lbrace.span.end.offset;
1034
+ let endOffset = startOffset;
1035
+ let endToken = null;
1036
+
1037
+ while (depth > 0 && this.pos < this.tokens.length) {
1038
+ const token = this.tokens[this.pos];
1039
+ if (token.type === 'EOF') break;
1040
+
1041
+ if (token.type === 'LBRACE') {
1042
+ depth++;
1043
+ this.pos++;
1044
+ } else if (token.type === 'RBRACE') {
1045
+ depth--;
1046
+ if (depth === 0) {
1047
+ endToken = token;
1048
+ endOffset = token.span.start.offset;
1049
+ this.pos++;
1050
+ break;
1051
+ } else {
1052
+ this.pos++;
1053
+ }
1054
+ } else {
1055
+ this.pos++;
1056
+ }
1057
+ }
1058
+
1059
+ if (depth > 0) {
1060
+ throw new Error(`Unclosed note block at ${this.file}:${startToken.span.start.line}`);
1061
+ }
1062
+
1063
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1064
+
1065
+ return {
1066
+ kind: 'note',
1067
+ raw: rawContent,
1068
+ source: {
1069
+ file: this.file,
1070
+ start: lbrace.span.end,
1071
+ end: endToken ? endToken.span.start : lbrace.span.end,
1072
+ },
1073
+ };
1074
+ }
1075
+
1049
1076
  /**
1050
1077
  * Parses a paths block (e.g., paths { 'path1', 'path2' })
1051
1078
  * @returns {string[]}