@sprig-and-prose/sprig-universe 0.4.3 → 0.4.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.
@@ -920,6 +920,21 @@ function getRawText(contentSpan, sourceText) {
920
920
  return sourceText.slice(contentSpan.startOffset, contentSpan.endOffset);
921
921
  }
922
922
 
923
+ /**
924
+ * @param {string} base
925
+ * @param {string} path
926
+ * @returns {string}
927
+ */
928
+ function joinRepositoryUrl(base, path) {
929
+ if (base.endsWith('/') && path.startsWith('/')) {
930
+ return base + path.slice(1);
931
+ }
932
+ if (!base.endsWith('/') && !path.startsWith('/')) {
933
+ return `${base}/${path}`;
934
+ }
935
+ return base + path;
936
+ }
937
+
923
938
  /**
924
939
  * Pass 4: Build nodes
925
940
  * @param {BoundDecl[]} boundDecls
@@ -1078,7 +1093,7 @@ function buildNodes(boundDecls, schemas, fileData) {
1078
1093
  // Extract unknown blocks (non-container, non-standard blocks)
1079
1094
  const unknownBlocks = (decl.body || []).filter(b => {
1080
1095
  if (!b.kind) return false;
1081
- const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference'];
1096
+ const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference', 'ordering'];
1082
1097
  return !knownKinds.includes(b.kind);
1083
1098
  });
1084
1099
  if (unknownBlocks.length > 0) {
@@ -1090,13 +1105,28 @@ function buildNodes(boundDecls, schemas, fileData) {
1090
1105
  }));
1091
1106
  }
1092
1107
 
1093
- // Extract references block
1108
+ // Extract references block (resolved after all nodes are built)
1094
1109
  const referencesBlock = (decl.body || []).find(b => b.kind === 'references');
1095
1110
  if (referencesBlock && referencesBlock.items) {
1096
- node.references = referencesBlock.items.map(item => {
1097
- // Will be resolved in Pass 5
1098
- return item.ref || item.name || '';
1099
- });
1111
+ node._pendingReferences = referencesBlock.items
1112
+ .map(item => ({
1113
+ name: item.ref || item.name || '',
1114
+ source: item.span || referencesBlock.source,
1115
+ }))
1116
+ .filter(item => item.name);
1117
+ }
1118
+
1119
+ // Extract ordering block (applied after all nodes are built)
1120
+ const orderingBlocks = (decl.body || []).filter(b => b.kind === 'ordering');
1121
+ if (orderingBlocks.length > 0) {
1122
+ if (orderingBlocks.length > 1) {
1123
+ diagnostics.push({
1124
+ severity: 'warning',
1125
+ message: `Multiple ordering blocks in ${node.kind} "${node.name}". Using the first one.`,
1126
+ source: orderingBlocks[1].source || orderingBlocks[0].source,
1127
+ });
1128
+ }
1129
+ node._pendingOrdering = orderingBlocks[0];
1100
1130
  }
1101
1131
 
1102
1132
  nodes.set(id, node);
@@ -1172,6 +1202,12 @@ function buildNodes(boundDecls, schemas, fileData) {
1172
1202
  if (pathsBlock && pathsBlock.paths) {
1173
1203
  ref.paths = pathsBlock.paths;
1174
1204
  }
1205
+
1206
+ // Extract kind block
1207
+ const kindBlock = (decl.body || []).find(b => b.kind === 'kind');
1208
+ if (kindBlock && kindBlock.value) {
1209
+ ref.kind = kindBlock.value;
1210
+ }
1175
1211
 
1176
1212
  // Extract title block
1177
1213
  const titleBlock = (decl.body || []).find(b => b.kind === 'title');
@@ -1202,6 +1238,293 @@ function buildNodes(boundDecls, schemas, fileData) {
1202
1238
  return { nodes, repositories, references, diagnostics };
1203
1239
  }
1204
1240
 
1241
+ /**
1242
+ * Attach named references declared inside containers to their parent nodes.
1243
+ * @param {BoundDecl[]} boundDecls
1244
+ * @param {Map<DeclId, NodeModel>} nodes
1245
+ */
1246
+ function addImplicitReferenceAttachments(boundDecls, nodes) {
1247
+ for (const bound of boundDecls) {
1248
+ const { info } = bound;
1249
+ if (info.kindResolved !== 'reference') continue;
1250
+ const parentId = info.parentDeclId;
1251
+ if (!parentId) continue;
1252
+ const parentNode = nodes.get(parentId);
1253
+ if (!parentNode || parentNode.kind === 'universe') continue;
1254
+ if (!parentNode._pendingReferences) {
1255
+ parentNode._pendingReferences = [];
1256
+ }
1257
+ parentNode._pendingReferences.push({
1258
+ name: info.name,
1259
+ source: info.span,
1260
+ });
1261
+ }
1262
+ }
1263
+
1264
+ /**
1265
+ * Resolve reference declarations to repository URLs and repository references.
1266
+ * @param {BoundDecl[]} boundDecls
1267
+ * @param {Map<DeclId, ReferenceModel>} references
1268
+ * @param {Map<DeclId, RepositoryModel>} repositories
1269
+ * @param {DeclIndex} index
1270
+ * @param {Map<DeclId, DeclId>} parentMap
1271
+ * @returns {Diagnostic[]}
1272
+ */
1273
+ function resolveReferenceModels(boundDecls, references, repositories, index, parentMap) {
1274
+ const diagnostics = [];
1275
+ const unknownRepoCounts = new Map();
1276
+
1277
+ for (const bound of boundDecls) {
1278
+ const { id, info } = bound;
1279
+ if (info.kindResolved !== 'reference') continue;
1280
+ const ref = references.get(id);
1281
+ if (!ref) continue;
1282
+
1283
+ const displayName = ref.name || info.name || 'unnamed reference';
1284
+ const hasUrl = Array.isArray(ref.urls) && ref.urls.length > 0;
1285
+ const repositoryName = ref.repositoryName;
1286
+ let urls = hasUrl ? [...ref.urls] : [];
1287
+ let repositoryRef = undefined;
1288
+
1289
+ if (hasUrl && repositoryName) {
1290
+ diagnostics.push({
1291
+ severity: 'error',
1292
+ message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
1293
+ source: info.span,
1294
+ });
1295
+ }
1296
+
1297
+ if (!hasUrl && repositoryName) {
1298
+ const pathSegments = repositoryName.split('.');
1299
+ if (pathSegments.length === 1) {
1300
+ const resolved = resolveUnqualified(pathSegments[0], id, index, parentMap, info.span);
1301
+ if (resolved.ambiguous) {
1302
+ diagnostics.push({
1303
+ severity: 'error',
1304
+ message: `Ambiguous repository "${repositoryName}" - use qualified path`,
1305
+ source: info.span,
1306
+ });
1307
+ } else if (resolved.declId) {
1308
+ const repo = repositories.get(resolved.declId);
1309
+ if (!repo) {
1310
+ diagnostics.push({
1311
+ severity: 'error',
1312
+ message: `Reference "${displayName}" uses "${repositoryName}" which is not a repository`,
1313
+ source: info.span,
1314
+ });
1315
+ } else if (!repo.url) {
1316
+ diagnostics.push({
1317
+ severity: 'error',
1318
+ message: `Repository "${repo.name}" is missing url { '...' }`,
1319
+ source: info.span,
1320
+ });
1321
+ } else {
1322
+ repositoryRef = repo.id;
1323
+ if (ref.paths && ref.paths.length > 0) {
1324
+ urls = ref.paths.map((path) => joinRepositoryUrl(repo.url, path));
1325
+ } else if (ref.paths && ref.paths.length === 0) {
1326
+ diagnostics.push({
1327
+ severity: 'error',
1328
+ message: `Reference "${displayName}" has an empty paths block`,
1329
+ source: info.span,
1330
+ });
1331
+ } else {
1332
+ urls = [repo.url];
1333
+ }
1334
+ }
1335
+ } else {
1336
+ const entry = unknownRepoCounts.get(repositoryName) || {
1337
+ count: 0,
1338
+ source: info.span,
1339
+ };
1340
+ entry.count += 1;
1341
+ if (!entry.source && info.span) {
1342
+ entry.source = info.span;
1343
+ }
1344
+ unknownRepoCounts.set(repositoryName, entry);
1345
+ }
1346
+ } else {
1347
+ const resolved = resolveQualified(pathSegments, index, info.span);
1348
+ if (resolved.declId) {
1349
+ const repo = repositories.get(resolved.declId);
1350
+ if (!repo) {
1351
+ diagnostics.push({
1352
+ severity: 'error',
1353
+ message: `Reference "${displayName}" uses "${repositoryName}" which is not a repository`,
1354
+ source: info.span,
1355
+ });
1356
+ } else if (!repo.url) {
1357
+ diagnostics.push({
1358
+ severity: 'error',
1359
+ message: `Repository "${repo.name}" is missing url { '...' }`,
1360
+ source: info.span,
1361
+ });
1362
+ } else {
1363
+ repositoryRef = repo.id;
1364
+ if (ref.paths && ref.paths.length > 0) {
1365
+ urls = ref.paths.map((path) => joinRepositoryUrl(repo.url, path));
1366
+ } else if (ref.paths && ref.paths.length === 0) {
1367
+ diagnostics.push({
1368
+ severity: 'error',
1369
+ message: `Reference "${displayName}" has an empty paths block`,
1370
+ source: info.span,
1371
+ });
1372
+ } else {
1373
+ urls = [repo.url];
1374
+ }
1375
+ }
1376
+ } else {
1377
+ const entry = unknownRepoCounts.get(repositoryName) || {
1378
+ count: 0,
1379
+ source: info.span,
1380
+ };
1381
+ entry.count += 1;
1382
+ if (!entry.source && info.span) {
1383
+ entry.source = info.span;
1384
+ }
1385
+ unknownRepoCounts.set(repositoryName, entry);
1386
+ }
1387
+ }
1388
+ }
1389
+
1390
+ if (!hasUrl && !repositoryName) {
1391
+ diagnostics.push({
1392
+ severity: 'error',
1393
+ message: `Reference "${displayName}" must have url { ... } or declared as reference ${displayName} in <Repository>`,
1394
+ source: info.span,
1395
+ });
1396
+ }
1397
+
1398
+ if (urls.length > 0) {
1399
+ ref.urls = urls;
1400
+ }
1401
+ if (repositoryRef) {
1402
+ ref.repositoryRef = repositoryRef;
1403
+ }
1404
+ }
1405
+
1406
+ for (const [repoName, info] of unknownRepoCounts.entries()) {
1407
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1408
+ diagnostics.push({
1409
+ severity: 'error',
1410
+ message: `Unknown repository "${repoName}" used by references${countText}.`,
1411
+ source: info.source,
1412
+ });
1413
+ }
1414
+
1415
+ return diagnostics;
1416
+ }
1417
+
1418
+ /**
1419
+ * Resolve references blocks to reference IDs.
1420
+ * @param {Map<DeclId, NodeModel>} nodes
1421
+ * @param {Map<DeclId, ReferenceModel>} references
1422
+ * @param {DeclIndex} index
1423
+ * @param {Map<DeclId, DeclId>} parentMap
1424
+ * @returns {Diagnostic[]}
1425
+ */
1426
+ function resolveReferenceAttachments(nodes, references, index, parentMap) {
1427
+ const diagnostics = [];
1428
+ const unknownReferenceCounts = new Map();
1429
+
1430
+ for (const node of nodes.values()) {
1431
+ if (!node._pendingReferences) continue;
1432
+ const resolvedReferences = [];
1433
+
1434
+ for (const item of node._pendingReferences) {
1435
+ const name = item.name;
1436
+ if (!name) continue;
1437
+
1438
+ let resolvedDeclId = null;
1439
+ const pathSegments = name.split('.');
1440
+ if (pathSegments.length === 1) {
1441
+ const resolved = resolveUnqualified(pathSegments[0], node.id, index, parentMap, item.source);
1442
+ if (resolved.ambiguous) {
1443
+ diagnostics.push({
1444
+ severity: 'error',
1445
+ message: `Ambiguous reference "${name}" - use qualified path`,
1446
+ source: item.source,
1447
+ });
1448
+ continue;
1449
+ }
1450
+ resolvedDeclId = resolved.declId;
1451
+ } else {
1452
+ const resolved = resolveQualified(pathSegments, index, item.source);
1453
+ resolvedDeclId = resolved.declId;
1454
+ }
1455
+
1456
+ if (resolvedDeclId && references.has(resolvedDeclId)) {
1457
+ resolvedReferences.push(resolvedDeclId);
1458
+ } else {
1459
+ const entry = unknownReferenceCounts.get(name) || { count: 0, source: item.source };
1460
+ entry.count += 1;
1461
+ if (!entry.source && item.source) {
1462
+ entry.source = item.source;
1463
+ }
1464
+ unknownReferenceCounts.set(name, entry);
1465
+ }
1466
+ }
1467
+
1468
+ node.references = resolvedReferences;
1469
+ delete node._pendingReferences;
1470
+ }
1471
+
1472
+ for (const [name, info] of unknownReferenceCounts.entries()) {
1473
+ const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
1474
+ diagnostics.push({
1475
+ severity: 'error',
1476
+ message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
1477
+ source: info.source,
1478
+ });
1479
+ }
1480
+
1481
+ return diagnostics;
1482
+ }
1483
+
1484
+ /**
1485
+ * Validate and apply ordering blocks after all nodes are created.
1486
+ * @param {Map<DeclId, NodeModel>} nodes
1487
+ * @returns {Diagnostic[]}
1488
+ */
1489
+ function applyOrderingBlocks(nodes) {
1490
+ const diagnostics = [];
1491
+
1492
+ for (const node of nodes.values()) {
1493
+ if (!node._pendingOrdering) continue;
1494
+ const orderingBlock = node._pendingOrdering;
1495
+ const orderingIdentifiers = orderingBlock.identifiers || [];
1496
+
1497
+ const childNameToNodeId = new Map();
1498
+ for (const childId of node.children || []) {
1499
+ const childNode = nodes.get(childId);
1500
+ if (childNode) {
1501
+ childNameToNodeId.set(childNode.name, childId);
1502
+ }
1503
+ }
1504
+
1505
+ const validOrdering = [];
1506
+ for (const identifier of orderingIdentifiers) {
1507
+ if (childNameToNodeId.has(identifier)) {
1508
+ validOrdering.push(identifier);
1509
+ } else {
1510
+ diagnostics.push({
1511
+ severity: 'warning',
1512
+ message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
1513
+ source: orderingBlock.source,
1514
+ });
1515
+ }
1516
+ }
1517
+
1518
+ if (validOrdering.length > 0) {
1519
+ node.ordering = validOrdering;
1520
+ }
1521
+
1522
+ delete node._pendingOrdering;
1523
+ }
1524
+
1525
+ return diagnostics;
1526
+ }
1527
+
1205
1528
  /**
1206
1529
  * Pass 5: Build edges from relationship usage
1207
1530
  * @param {BoundDecl[]} boundDecls
@@ -1467,6 +1790,14 @@ export function buildGraph(fileAstOrFileAsts, options) {
1467
1790
  // Pass 4: Build nodes
1468
1791
  const fileData = normalized.map(n => ({ sourceText: n.sourceText, filePath: n.filePath }));
1469
1792
  const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(boundDecls, schemas, fileData);
1793
+
1794
+ // Resolve reference models and attachments
1795
+ addImplicitReferenceAttachments(boundDecls, nodes);
1796
+ const referenceModelDiags = resolveReferenceModels(boundDecls, references, repositories, index, parentMap);
1797
+ const referenceAttachmentDiags = resolveReferenceAttachments(nodes, references, index, parentMap);
1798
+
1799
+ // Apply ordering blocks after all nodes are available
1800
+ const orderingDiags = applyOrderingBlocks(nodes);
1470
1801
 
1471
1802
  // Resolve relates endpoints
1472
1803
  const relatesDiags = resolveRelatesEndpoints(nodes, index, parentMap, boundDecls);
@@ -1483,6 +1814,9 @@ export function buildGraph(fileAstOrFileAsts, options) {
1483
1814
  ...bindDiags,
1484
1815
  ...schemaDiags,
1485
1816
  ...nodeDiags,
1817
+ ...referenceModelDiags,
1818
+ ...referenceAttachmentDiags,
1819
+ ...orderingDiags,
1486
1820
  ...relatesDiags,
1487
1821
  ...edgeDiags,
1488
1822
  ];
@@ -342,6 +342,7 @@ const blockParselets = new Map([
342
342
  ['label', (p) => p.parseLabelBlock()],
343
343
  ['reference', (p) => p.parseReferenceBlock()],
344
344
  ['references', (p) => p.parseReferencesBlock()],
345
+ ['ordering', (p) => p.parseOrderingBlock()],
345
346
  ['relationships', (p) => p.parseRelationshipsBlock()],
346
347
  ]);
347
348
 
@@ -1074,6 +1075,54 @@ ParserCore.prototype.parseReferenceBlock = function () {
1074
1075
  };
1075
1076
  };
1076
1077
 
1078
+ /**
1079
+ * Parses an ordering block: ordering { <identifiers> }
1080
+ * Items are identifiers (not paths).
1081
+ * @returns {any | null}
1082
+ */
1083
+ ParserCore.prototype.parseOrderingBlock = function () {
1084
+ const startToken = this.peek();
1085
+ if (!startToken) return null;
1086
+
1087
+ const { token: orderingToken } = this.expectIdentifierOrKeyword('ordering');
1088
+ if (!orderingToken) return null;
1089
+
1090
+ const { token: lbrace } = this.expect('LBRACE');
1091
+ if (!lbrace) return null;
1092
+
1093
+ const identifiers = [];
1094
+
1095
+ // Parse items until closing brace
1096
+ while (!this.isAtEnd() && !this.match('RBRACE')) {
1097
+ // Skip optional commas
1098
+ if (this.match('COMMA')) {
1099
+ this.advance();
1100
+ continue;
1101
+ }
1102
+
1103
+ const itemToken = this.peek();
1104
+ if (!itemToken) break;
1105
+
1106
+ const identifier = this.readIdent();
1107
+ if (!identifier) {
1108
+ this.reportDiagnostic('error', 'Expected identifier in ordering block', itemToken.span);
1109
+ this.advance();
1110
+ continue;
1111
+ }
1112
+
1113
+ identifiers.push(identifier);
1114
+ }
1115
+
1116
+ const { token: rbrace } = this.expect('RBRACE');
1117
+ if (!rbrace) return null;
1118
+
1119
+ return {
1120
+ kind: 'ordering',
1121
+ identifiers,
1122
+ source: this.createSpan(orderingToken, rbrace),
1123
+ };
1124
+ };
1125
+
1077
1126
  /**
1078
1127
  * Parses a references block: references { <items> }
1079
1128
  * Items are identifier paths (reference names)
@@ -37,6 +37,7 @@ const KEYWORDS = new Set([
37
37
  'title',
38
38
  'reference',
39
39
  'references',
40
+ 'ordering',
40
41
  'paths',
41
42
  'url',
42
43
  ]);
@@ -67,14 +67,16 @@ anthology AnthologyWithRelationships in CanonicalUniverse {
67
67
  A chapter can still be nested under an aliased book.
68
68
  }
69
69
 
70
- owns {
71
- ExternalOtherChapter,
72
- ExternalBook,
73
- }
70
+ relationships {
71
+ owns {
72
+ ExternalOtherChapter,
73
+ ExternalBook,
74
+ }
74
75
 
75
- singleton {
76
- ExternalAnthology
77
- ExternalSeries
76
+ singleton {
77
+ ExternalAnthology
78
+ ExternalSeries
79
+ }
78
80
  }
79
81
  }
80
82
 
@@ -104,21 +106,21 @@ anthology AnthologyWithRelationships in CanonicalUniverse {
104
106
  }
105
107
 
106
108
  reference NamedReference1 {
107
- url { 'https://github.com/owner/repository/tree/main' }
109
+ url { 'https://github.com/owner/repository/tree/main/references/reference1.md' }
108
110
  describe {
109
111
  References can have describe blocks.
110
112
  }
111
113
  }
112
114
 
113
115
  reference NamedReference2 {
114
- url { 'https://github.com/owner/repository/tree/main' }
116
+ url { 'https://github.com/owner/repository/tree/main/references/reference2.md' }
115
117
  describe {
116
118
  References can have describe blocks.
117
119
  }
118
120
  }
119
121
 
120
122
  reference NamedReferenceInRepository in InternalRepository {
121
- url { 'https://github.com/owner/repository/tree/main' }
123
+ paths { 'path/to/file1.md' 'path/to/file2.md' }
122
124
  }
123
125
 
124
126
  concept ConceptWithDirectReference {
@@ -150,6 +152,11 @@ anthology ExternalAnthology in CanonicalUniverse {
150
152
 
151
153
  bookAlias BookAlias {
152
154
  chapterAlias NestedChapterAlias { }
155
+
156
+ ordering {
157
+ NestedChapterAlias
158
+ ExternalChapterAlias
159
+ }
153
160
  }
154
161
 
155
162
  chapterAlias ExternalChapterAlias in BookAlias { }