@sprig-and-prose/sprig-universe 0.1.1 → 0.3.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.
package/src/ir.js CHANGED
@@ -63,7 +63,7 @@
63
63
  * @property {NodeId} [container] - Container node ID (for book/chapter "in" relationship; always set for book/chapter nodes when resolved)
64
64
  * @property {TextBlock} [describe] - Describe block if present
65
65
  * @property {UnknownBlock[]} [unknownBlocks] - Unknown blocks if any
66
- * @property {Array<{repository: string, paths: string[], kind?: string, describe?: TextBlock, source: SourceSpan}>} [references] - References if any
66
+ * @property {string[]} [references] - Reference IDs attached to this node
67
67
  * @property {Array<{title?: string, kind: string, path: string, describe?: TextBlock, source: SourceSpan}>} [documentation] - Documentation if any
68
68
  * @property {SourceSpan} source - Source span
69
69
  * @property {NodeId[]} [endpoints] - Endpoint node IDs (for relates nodes)
@@ -88,12 +88,27 @@
88
88
  */
89
89
 
90
90
  /**
91
- * @typedef {Object} NamedReferenceModel
91
+ * @typedef {Object} RepositoryModel
92
+ * @property {string} id - Repository ID (qualified)
93
+ * @property {string} name - Repository name
94
+ * @property {string} url - Base URL
95
+ * @property {TextBlock} [describe] - Optional describe block
96
+ * @property {TextBlock} [note] - Optional note block
97
+ * @property {string} [title] - Optional title
98
+ * @property {SourceSpan} source - Source span
99
+ */
100
+
101
+ /**
102
+ * @typedef {Object} ReferenceModel
103
+ * @property {string} id - Reference ID (qualified)
92
104
  * @property {string} name - Reference name
93
- * @property {string} repository - Repository name
94
- * @property {string[]} paths - Array of path strings
95
105
  * @property {string} [kind] - Optional reference kind
106
+ * @property {string} [title] - Optional title
96
107
  * @property {TextBlock} [describe] - Optional describe block
108
+ * @property {TextBlock} [note] - Optional note block
109
+ * @property {string[]} urls - Computed URLs
110
+ * @property {string} [repositoryRef] - Repository ID if reference uses a repository
111
+ * @property {string[]} [paths] - Raw paths (if provided)
97
112
  * @property {SourceSpan} source - Source span
98
113
  */
99
114
 
@@ -113,9 +128,9 @@
113
128
  * @property {Record<NodeId, NodeModel>} nodes - Nodes keyed by ID
114
129
  * @property {Record<EdgeId, EdgeModel>} edges - Edges keyed by ID
115
130
  * @property {Diagnostic[]} diagnostics - Diagnostics
116
- * @property {Record<string, any>} [repositories] - Repository configurations (from config)
131
+ * @property {Record<string, RepositoryModel>} [repositories] - Repositories by ID
132
+ * @property {Record<string, ReferenceModel>} [references] - References by ID
117
133
  * @property {string} [generatedAt] - ISO timestamp when manifest was generated
118
- * @property {Record<string, Record<string, NamedReferenceModel>>} [referencesByName] - Named references by universe name, then by reference name
119
134
  * @property {Record<string, Record<string, NamedDocumentModel>>} [documentsByName] - Named documents by universe name, then by document name
120
135
  */
121
136
 
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,6 +105,7 @@ class Parser {
106
105
  parseFile() {
107
106
  const universes = [];
108
107
  const scenes = [];
108
+ const topLevelDeclsUnscoped = [];
109
109
  const startToken = this.peek();
110
110
 
111
111
  while (!this.match('EOF')) {
@@ -113,6 +113,34 @@ class Parser {
113
113
  universes.push(this.parseUniverse());
114
114
  } else if (this.match('KEYWORD') && this.peek()?.value === 'scene') {
115
115
  scenes.push(this.parseScene());
116
+ } else if (this.match('KEYWORD')) {
117
+ const keyword = this.peek()?.value;
118
+ let decl = null;
119
+ if (keyword === 'series') {
120
+ decl = this.parseSeries();
121
+ } else if (keyword === 'book') {
122
+ decl = this.parseBook();
123
+ } else if (keyword === 'chapter') {
124
+ decl = this.parseChapter();
125
+ } else if (keyword === 'concept') {
126
+ decl = this.parseConcept();
127
+ } else if (keyword === 'anthology') {
128
+ decl = this.parseAnthology();
129
+ } else if (keyword === 'repository') {
130
+ decl = this.parseRepository();
131
+ } else if (keyword === 'reference') {
132
+ decl = this.parseReferenceDecl();
133
+ }
134
+
135
+ if (decl) {
136
+ topLevelDeclsUnscoped.push(decl);
137
+ } else {
138
+ // Skip unknown top-level content (tolerant parsing)
139
+ const token = this.advance();
140
+ if (token && token.type !== 'EOF') {
141
+ // Could emit warning here, but for now just skip
142
+ }
143
+ }
116
144
  } else {
117
145
  // Skip unknown top-level content (tolerant parsing)
118
146
  const token = this.advance();
@@ -122,10 +150,16 @@ class Parser {
122
150
  }
123
151
  }
124
152
 
153
+ // Merge top-level declarations into their target universe
154
+ if (topLevelDeclsUnscoped.length > 0 && universes.length === 1) {
155
+ universes[0].body.push(...topLevelDeclsUnscoped);
156
+ }
157
+
125
158
  return {
126
159
  file: this.file,
127
160
  universes,
128
161
  scenes,
162
+ topLevelDecls: topLevelDeclsUnscoped.length > 0 ? topLevelDeclsUnscoped : undefined,
129
163
  source: startToken
130
164
  ? {
131
165
  file: this.file,
@@ -143,7 +177,7 @@ class Parser {
143
177
  const startToken = this.expect('KEYWORD', 'universe');
144
178
  const nameToken = this.expect('IDENTIFIER');
145
179
  const lbrace = this.expect('LBRACE');
146
- 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']);
147
181
  const rbrace = this.expect('RBRACE');
148
182
 
149
183
  return {
@@ -160,7 +194,7 @@ class Parser {
160
194
 
161
195
  /**
162
196
  * @param {string[]} allowedKeywords - Keywords allowed in this body
163
- * @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>}
164
198
  */
165
199
  parseBlockBody(allowedKeywords) {
166
200
  const body = [];
@@ -171,21 +205,6 @@ class Parser {
171
205
  const keyword = this.peek()?.value;
172
206
  if (!keyword) break;
173
207
 
174
- // Check for named reference: reference <IDENTIFIER> { ... }
175
- if (keyword === 'reference') {
176
- const nextPos = this.pos + 1;
177
- if (nextPos < this.tokens.length &&
178
- this.tokens[nextPos].type === 'IDENTIFIER' &&
179
- nextPos + 1 < this.tokens.length &&
180
- this.tokens[nextPos + 1].type === 'LBRACE') {
181
- // This is a named reference block
182
- body.push(this.parseNamedReference());
183
- continue;
184
- }
185
- // Otherwise, it's an inline reference (only valid inside references block)
186
- // Fall through to unknown block handling
187
- }
188
-
189
208
  // Check for named document: document <IDENTIFIER> { ... }
190
209
  if (keyword === 'document') {
191
210
  const nextPos = this.pos + 1;
@@ -219,13 +238,15 @@ class Parser {
219
238
  body.push(this.parseTitle());
220
239
  } else if (keyword === 'repository' && allowedKeywords.includes('repository')) {
221
240
  body.push(this.parseRepository());
241
+ } else if (keyword === 'reference' && allowedKeywords.includes('reference')) {
242
+ body.push(this.parseReferenceDecl());
222
243
  } else if (keyword === 'from' && allowedKeywords.includes('from')) {
223
244
  body.push(this.parseFrom());
224
245
  } else if (keyword === 'relationships' && allowedKeywords.includes('relationships')) {
225
246
  body.push(this.parseRelationships());
226
- } else if (keyword === 'references') {
247
+ } else if (keyword === 'references' && allowedKeywords.includes('references')) {
227
248
  body.push(this.parseReferences());
228
- } else if (keyword === 'documentation') {
249
+ } else if (keyword === 'documentation' && allowedKeywords.includes('documentation')) {
229
250
  body.push(this.parseDocumentation());
230
251
  } else {
231
252
  // Unknown keyword/identifier followed by brace - parse as UnknownBlock
@@ -253,13 +274,32 @@ class Parser {
253
274
  parseAnthology() {
254
275
  const startToken = this.expect('KEYWORD', 'anthology');
255
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
+ }
256
283
  const lbrace = this.expect('LBRACE');
257
- 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
+ ]);
258
297
  const rbrace = this.expect('RBRACE');
259
298
 
260
299
  return {
261
300
  kind: 'anthology',
262
301
  name: nameToken.value,
302
+ parentName,
263
303
  body,
264
304
  source: {
265
305
  file: this.file,
@@ -285,7 +325,7 @@ class Parser {
285
325
  }
286
326
 
287
327
  const lbrace = this.expect('LBRACE');
288
- const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation']);
328
+ const body = this.parseBlockBody(['book', 'chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
289
329
  const rbrace = this.expect('RBRACE');
290
330
 
291
331
  return {
@@ -310,7 +350,7 @@ class Parser {
310
350
  this.expect('KEYWORD', 'in');
311
351
  const parentToken = this.expect('IDENTIFIER');
312
352
  const lbrace = this.expect('LBRACE');
313
- const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation']);
353
+ const body = this.parseBlockBody(['chapter', 'describe', 'title', 'references', 'documentation', 'repository', 'reference']);
314
354
  const rbrace = this.expect('RBRACE');
315
355
 
316
356
  return {
@@ -335,7 +375,7 @@ class Parser {
335
375
  this.expect('KEYWORD', 'in');
336
376
  const parentToken = this.expect('IDENTIFIER');
337
377
  const lbrace = this.expect('LBRACE');
338
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
378
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
339
379
  const rbrace = this.expect('RBRACE');
340
380
 
341
381
  return {
@@ -367,7 +407,7 @@ class Parser {
367
407
  }
368
408
 
369
409
  const lbrace = this.expect('LBRACE');
370
- const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation']);
410
+ const body = this.parseBlockBody(['describe', 'title', 'references', 'documentation', 'repository', 'reference']);
371
411
  const rbrace = this.expect('RBRACE');
372
412
 
373
413
  return {
@@ -585,193 +625,141 @@ class Parser {
585
625
  }
586
626
 
587
627
  /**
588
- * Parses a references block containing nested reference blocks and using blocks
628
+ * Parses a references block containing a list of identifier paths
589
629
  * @returns {ReferencesBlock}
590
630
  */
591
631
  parseReferences() {
592
632
  const startToken = this.expect('IDENTIFIER', 'references');
593
633
  const lbrace = this.expect('LBRACE');
594
- const references = [];
634
+ const items = [];
635
+ let depth = 1;
595
636
 
596
- // Parse nested reference blocks and using blocks
597
- while (!this.match('RBRACE') && !this.match('EOF')) {
598
- // Check for 'using' keyword first (consistent with Scene layer)
599
- if (this.match('KEYWORD') && this.peek()?.value === 'using') {
600
- references.push(this.parseUsingInReferences());
601
- } else if ((this.match('IDENTIFIER') || this.match('KEYWORD')) && this.peek()?.value === 'reference') {
602
- // Look for 'reference' identifier
603
- references.push(this.parseReference());
604
- } else {
605
- // Skip unexpected tokens (tolerant parsing)
637
+ while (this.pos < this.tokens.length && !this.match('EOF')) {
638
+ if (this.match('LBRACE')) {
639
+ throw new Error(`Unexpected '{' in references block at ${this.file}:${this.peek()?.span.start.line}. Use identifiers only.`);
640
+ }
641
+ if (this.match('LBRACE')) {
642
+ depth += 1;
606
643
  this.advance();
644
+ continue;
607
645
  }
608
- }
609
-
610
- const rbrace = this.expect('RBRACE');
611
-
612
- return {
613
- kind: 'references',
614
- references,
615
- source: {
616
- file: this.file,
617
- start: startToken.span.start,
618
- end: rbrace.span.end,
619
- },
620
- };
621
- }
622
646
 
623
- /**
624
- * Parses a using block inside a references block: using { IdentifierList }
625
- * @returns {UsingInReferencesBlock}
626
- */
627
- parseUsingInReferences() {
628
- const startToken = this.expect('KEYWORD', 'using');
629
- const lbrace = this.expect('LBRACE');
630
- const names = [];
631
-
632
- while (!this.match('RBRACE') && !this.match('EOF')) {
633
- if (this.match('IDENTIFIER')) {
634
- names.push(this.expect('IDENTIFIER').value);
635
- // Skip optional comma
636
- if (this.match('COMMA')) {
637
- this.expect('COMMA');
638
- }
639
- } else if (this.match('COMMA')) {
640
- // Skip stray commas
641
- this.expect('COMMA');
642
- } else {
643
- // Skip non-identifiers (tolerant parsing)
647
+ if (this.match('RBRACE')) {
648
+ depth -= 1;
644
649
  this.advance();
650
+ if (depth === 0) {
651
+ break;
652
+ }
653
+ continue;
645
654
  }
646
- }
647
655
 
648
- const rbrace = this.expect('RBRACE');
649
-
650
- return {
651
- kind: 'using-in-references',
652
- names,
653
- source: {
654
- file: this.file,
655
- start: startToken.span.start,
656
- end: rbrace.span.end,
657
- },
658
- };
659
- }
660
-
661
- /**
662
- * Parses a single reference block
663
- * @returns {ReferenceBlock}
664
- */
665
- parseReference() {
666
- const startToken = this.expect('IDENTIFIER', 'reference');
667
- const lbrace = this.expect('LBRACE');
668
- let repository = null;
669
- let paths = [];
670
- let describe = null;
671
- let kind = null;
656
+ if (depth !== 1) {
657
+ this.advance();
658
+ continue;
659
+ }
672
660
 
673
- // Parse repository, paths, optional kind, and optional describe
674
- while (!this.match('RBRACE') && !this.match('EOF')) {
675
- if (this.match('RBRACE')) break;
661
+ if (this.match('COMMA')) {
662
+ this.advance();
663
+ continue;
664
+ }
676
665
 
677
- // Check for identifier or keyword (describe is a keyword)
678
666
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
679
- const identifier = this.peek()?.value;
680
- if (identifier === 'repository') {
681
- repository = this.parseStringBlock('repository');
682
- } else if (identifier === 'paths') {
683
- paths = this.parsePathsBlock();
684
- } else if (identifier === 'kind') {
685
- kind = this.parseStringBlock('kind');
686
- } else if (identifier === 'describe') {
687
- describe = this.parseDescribe();
688
- } else {
689
- // Skip unknown identifier/keyword
690
- this.advance();
667
+ const startToken = this.peek();
668
+ const name = this.parseIdentifierPath();
669
+ if (name && startToken) {
670
+ const lastToken = this.tokens[this.pos - 1] || startToken;
671
+ items.push({
672
+ name,
673
+ source: {
674
+ file: this.file,
675
+ start: startToken.span.start,
676
+ end: lastToken.span.end,
677
+ },
678
+ });
691
679
  }
692
- } else {
693
- // Skip unexpected tokens
694
- this.advance();
680
+ continue;
695
681
  }
696
- }
697
682
 
698
- const rbrace = this.expect('RBRACE');
699
-
700
- if (!repository) {
701
- throw new Error(`Missing 'repository' field in reference block at ${this.file}:${startToken.span.start.line}`);
702
- }
703
- if (paths.length === 0) {
704
- throw new Error(`Missing or empty 'paths' field in reference block at ${this.file}:${startToken.span.start.line}`);
683
+ this.advance();
705
684
  }
706
685
 
707
686
  return {
708
- kind: 'reference',
709
- repository,
710
- paths,
711
- referenceKind: kind,
712
- describe,
687
+ kind: 'references',
688
+ items,
713
689
  source: {
714
690
  file: this.file,
715
691
  start: startToken.span.start,
716
- end: rbrace.span.end,
692
+ end: this.tokens[this.pos - 1]?.span.end || lbrace.span.end,
717
693
  },
718
694
  };
719
695
  }
720
696
 
721
697
  /**
722
- * Parses a named reference block: reference <Name> { ... }
723
- * @returns {NamedReferenceBlock}
698
+ * Parses a reference declaration: reference <Name> [in <RepoName>] { ... }
699
+ * @returns {ReferenceDecl}
724
700
  */
725
- parseNamedReference() {
726
- const startToken = this.expect('IDENTIFIER', 'reference');
727
- const nameToken = this.expect('IDENTIFIER');
701
+ parseReferenceDecl() {
702
+ const startToken = this.match('KEYWORD') ? this.expect('KEYWORD', 'reference') : this.expect('IDENTIFIER', 'reference');
703
+ let nameToken = null;
704
+ if (this.match('IDENTIFIER')) {
705
+ nameToken = this.expect('IDENTIFIER');
706
+ }
707
+ let repositoryName = null;
708
+
709
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
710
+ this.expect('KEYWORD', 'in');
711
+ const repoName = this.parseIdentifierPath();
712
+ if (!repoName) {
713
+ throw new Error(`Expected repository name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
714
+ }
715
+ repositoryName = repoName;
716
+ }
717
+
728
718
  const lbrace = this.expect('LBRACE');
729
- let repository = null;
730
- let paths = [];
731
- let describe = null;
719
+ let url = null;
720
+ let paths = null;
732
721
  let kind = null;
722
+ let describe = null;
723
+ let note = null;
724
+ let title = null;
733
725
 
734
- // Parse repository, paths, optional kind, and optional describe (same as parseReference)
735
726
  while (!this.match('RBRACE') && !this.match('EOF')) {
736
727
  if (this.match('RBRACE')) break;
737
728
 
738
- // Check for identifier or keyword (describe is a keyword)
739
729
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
740
730
  const identifier = this.peek()?.value;
741
- if (identifier === 'repository') {
742
- repository = this.parseStringBlock('repository');
731
+ if (identifier === 'url') {
732
+ url = this.parseStringBlock('url');
743
733
  } else if (identifier === 'paths') {
744
734
  paths = this.parsePathsBlock();
745
735
  } else if (identifier === 'kind') {
746
736
  kind = this.parseStringBlock('kind');
747
737
  } else if (identifier === 'describe') {
748
738
  describe = this.parseDescribe();
739
+ } else if (identifier === 'note') {
740
+ note = this.parseNote();
741
+ } else if (identifier === 'title') {
742
+ title = this.parseTitle();
749
743
  } else {
750
- // Skip unknown identifier/keyword
751
744
  this.advance();
752
745
  }
753
746
  } else {
754
- // Skip unexpected tokens
755
747
  this.advance();
756
748
  }
757
749
  }
758
750
 
759
751
  const rbrace = this.expect('RBRACE');
760
752
 
761
- if (!repository) {
762
- throw new Error(`Missing 'repository' field in named reference block at ${this.file}:${startToken.span.start.line}`);
763
- }
764
- if (paths.length === 0) {
765
- throw new Error(`Missing or empty 'paths' field in named reference block at ${this.file}:${startToken.span.start.line}`);
766
- }
767
-
768
753
  return {
769
- kind: 'named-reference',
770
- name: nameToken.value,
771
- repository,
772
- paths,
773
- referenceKind: kind,
774
- describe,
754
+ kind: 'reference',
755
+ name: nameToken ? nameToken.value : undefined,
756
+ repositoryName: repositoryName || undefined,
757
+ url: url || undefined,
758
+ paths: paths || undefined,
759
+ referenceKind: kind || undefined,
760
+ describe: describe || undefined,
761
+ note: note || undefined,
762
+ title: title || undefined,
775
763
  source: {
776
764
  file: this.file,
777
765
  start: startToken.span.start,
@@ -932,45 +920,58 @@ class Parser {
932
920
  }
933
921
 
934
922
  /**
935
- * Parses a repository block: repository <Identifier> { kind { ... } options { ... } }
923
+ * Parses a repository block: repository <Identifier> { url { ... } ... }
936
924
  * @returns {RepositoryDecl}
937
925
  */
938
926
  parseRepository() {
939
927
  const startToken = this.expect('KEYWORD', 'repository');
940
928
  const nameToken = this.expect('IDENTIFIER');
929
+ let parentName = undefined;
930
+
931
+ if (this.match('KEYWORD') && this.peek()?.value === 'in') {
932
+ this.expect('KEYWORD', 'in');
933
+ const containerName = this.parseIdentifierPath();
934
+ if (!containerName) {
935
+ throw new Error(`Expected container name after "in" at ${this.file}:${this.peek()?.span.start.line || '?'}`);
936
+ }
937
+ parentName = containerName;
938
+ }
941
939
  const lbrace = this.expect('LBRACE');
942
940
 
943
- let kind = null;
944
- let options = null;
941
+ let url = null;
942
+ let describe = null;
943
+ let note = null;
944
+ let title = null;
945
945
 
946
946
  while (!this.match('RBRACE') && !this.match('EOF')) {
947
947
  if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
948
948
  const identifier = this.peek()?.value;
949
- if (identifier === 'kind') {
950
- kind = this.parseValueBlock('kind');
951
- } else if (identifier === 'options') {
952
- options = this.parseOptionsBlock();
949
+ if (identifier === 'url') {
950
+ url = this.parseStringBlock('url');
951
+ } else if (identifier === 'describe') {
952
+ describe = this.parseDescribe();
953
+ } else if (identifier === 'note') {
954
+ note = this.parseNote();
955
+ } else if (identifier === 'title') {
956
+ title = this.parseTitle();
953
957
  } else {
954
- // Skip unknown identifier/keyword
955
958
  this.advance();
956
959
  }
957
960
  } else {
958
- // Skip unexpected tokens
959
961
  this.advance();
960
962
  }
961
963
  }
962
964
 
963
965
  const rbrace = this.expect('RBRACE');
964
966
 
965
- if (!kind) {
966
- throw new Error(`Missing 'kind' field in repository block at ${this.file}:${startToken.span.start.line}`);
967
- }
968
-
969
967
  return {
970
968
  kind: 'repository',
971
969
  name: nameToken.value,
972
- repositoryKind: kind,
973
- options: options || {},
970
+ parentName,
971
+ url: url || undefined,
972
+ describe: describe || undefined,
973
+ note: note || undefined,
974
+ title: title || undefined,
974
975
  source: {
975
976
  file: this.file,
976
977
  start: startToken.span.start,
@@ -979,6 +980,82 @@ class Parser {
979
980
  };
980
981
  }
981
982
 
983
+ /**
984
+ * Parses an identifier path (IDENTIFIER (DOT IDENTIFIER)*)
985
+ * @returns {string | null}
986
+ */
987
+ parseIdentifierPath() {
988
+ if (!this.match('IDENTIFIER') && !this.match('KEYWORD')) {
989
+ return null;
990
+ }
991
+
992
+ const parts = [];
993
+ parts.push(this.advance().value);
994
+
995
+ while (this.match('DOT')) {
996
+ this.expect('DOT');
997
+ if (this.match('IDENTIFIER') || this.match('KEYWORD')) {
998
+ parts.push(this.advance().value);
999
+ } else {
1000
+ break;
1001
+ }
1002
+ }
1003
+
1004
+ return parts.join('.');
1005
+ }
1006
+
1007
+ /**
1008
+ * Parses a note block, same format as describe
1009
+ * @returns {DescribeBlock}
1010
+ */
1011
+ parseNote() {
1012
+ const startToken = this.expect('IDENTIFIER', 'note');
1013
+ const lbrace = this.expect('LBRACE');
1014
+
1015
+ let depth = 1;
1016
+ const startOffset = lbrace.span.end.offset;
1017
+ let endOffset = startOffset;
1018
+ let endToken = null;
1019
+
1020
+ while (depth > 0 && this.pos < this.tokens.length) {
1021
+ const token = this.tokens[this.pos];
1022
+ if (token.type === 'EOF') break;
1023
+
1024
+ if (token.type === 'LBRACE') {
1025
+ depth++;
1026
+ this.pos++;
1027
+ } else if (token.type === 'RBRACE') {
1028
+ depth--;
1029
+ if (depth === 0) {
1030
+ endToken = token;
1031
+ endOffset = token.span.start.offset;
1032
+ this.pos++;
1033
+ break;
1034
+ } else {
1035
+ this.pos++;
1036
+ }
1037
+ } else {
1038
+ this.pos++;
1039
+ }
1040
+ }
1041
+
1042
+ if (depth > 0) {
1043
+ throw new Error(`Unclosed note block at ${this.file}:${startToken.span.start.line}`);
1044
+ }
1045
+
1046
+ const rawContent = this.sourceText.substring(startOffset, endOffset).trim();
1047
+
1048
+ return {
1049
+ kind: 'note',
1050
+ raw: rawContent,
1051
+ source: {
1052
+ file: this.file,
1053
+ start: lbrace.span.end,
1054
+ end: endToken ? endToken.span.start : lbrace.span.end,
1055
+ },
1056
+ };
1057
+ }
1058
+
982
1059
  /**
983
1060
  * Parses a paths block (e.g., paths { 'path1', 'path2' })
984
1061
  * @returns {string[]}