@sprig-and-prose/sprig-universe 0.4.3 → 0.4.5
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/universe/.sprig/manifest.json +2125 -0
- package/src/universe/graph.js +480 -8
- package/src/universe/parser.js +49 -0
- package/src/universe/scanner.js +1 -0
- package/src/universe/universe.prose +43 -14
package/src/universe/graph.js
CHANGED
|
@@ -920,6 +920,90 @@ function getRawText(contentSpan, sourceText) {
|
|
|
920
920
|
return sourceText.slice(contentSpan.startOffset, contentSpan.endOffset);
|
|
921
921
|
}
|
|
922
922
|
|
|
923
|
+
/**
|
|
924
|
+
* @param {string | undefined} raw
|
|
925
|
+
* @returns {string | undefined}
|
|
926
|
+
*/
|
|
927
|
+
function normalizeTitleValue(raw) {
|
|
928
|
+
if (!raw) return undefined;
|
|
929
|
+
const trimmed = raw.trim();
|
|
930
|
+
if (!trimmed) return undefined;
|
|
931
|
+
const unquoted = trimmed.replace(/^['"]|['"]$/g, '');
|
|
932
|
+
return unquoted.trim() || undefined;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* @param {string} base
|
|
937
|
+
* @param {string} path
|
|
938
|
+
* @returns {string}
|
|
939
|
+
*/
|
|
940
|
+
function joinRepositoryUrl(base, path) {
|
|
941
|
+
if (base.endsWith('/') && path.startsWith('/')) {
|
|
942
|
+
return base + path.slice(1);
|
|
943
|
+
}
|
|
944
|
+
if (!base.endsWith('/') && !path.startsWith('/')) {
|
|
945
|
+
return `${base}/${path}`;
|
|
946
|
+
}
|
|
947
|
+
return base + path;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* @param {{ title?: string, paths?: string[], urls?: string[] }} ref
|
|
952
|
+
* @returns {string}
|
|
953
|
+
*/
|
|
954
|
+
function deriveReferenceName(ref) {
|
|
955
|
+
if (ref.title) {
|
|
956
|
+
return ref.title;
|
|
957
|
+
}
|
|
958
|
+
if (ref.paths && ref.paths.length > 0) {
|
|
959
|
+
const rawPath = ref.paths[0];
|
|
960
|
+
const trimmed = rawPath.replace(/\/+$/, '');
|
|
961
|
+
const parts = trimmed.split('/').filter(Boolean);
|
|
962
|
+
if (parts.length > 0) {
|
|
963
|
+
const lastPart = parts[parts.length - 1];
|
|
964
|
+
const withoutExt = lastPart.replace(/\.[^/.]+$/, '');
|
|
965
|
+
return withoutExt.replace(/\./g, '-');
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (ref.urls && ref.urls.length > 0) {
|
|
969
|
+
try {
|
|
970
|
+
const parsed = new URL(ref.urls[0]);
|
|
971
|
+
const segments = parsed.pathname.split('/').filter(Boolean);
|
|
972
|
+
if (segments.length > 0) {
|
|
973
|
+
const lastSegment = segments[segments.length - 1];
|
|
974
|
+
const withoutExt = lastSegment.replace(/\.[^/.]+$/, '');
|
|
975
|
+
return withoutExt.replace(/\./g, '-');
|
|
976
|
+
}
|
|
977
|
+
if (parsed.hostname) {
|
|
978
|
+
return parsed.hostname.replace(/\./g, '-');
|
|
979
|
+
}
|
|
980
|
+
} catch {
|
|
981
|
+
// Ignore malformed URLs here; validation handles required fields
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return 'reference';
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @param {Map<string, true>} nameMap
|
|
989
|
+
* @param {string} baseName
|
|
990
|
+
* @returns {string}
|
|
991
|
+
*/
|
|
992
|
+
function pickUniqueInlineReferenceName(nameMap, baseName) {
|
|
993
|
+
if (!nameMap.has(baseName)) {
|
|
994
|
+
nameMap.set(baseName, true);
|
|
995
|
+
return baseName;
|
|
996
|
+
}
|
|
997
|
+
let suffix = 2;
|
|
998
|
+
let candidate = `${baseName}-${suffix}`;
|
|
999
|
+
while (nameMap.has(candidate)) {
|
|
1000
|
+
suffix += 1;
|
|
1001
|
+
candidate = `${baseName}-${suffix}`;
|
|
1002
|
+
}
|
|
1003
|
+
nameMap.set(candidate, true);
|
|
1004
|
+
return candidate;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
923
1007
|
/**
|
|
924
1008
|
* Pass 4: Build nodes
|
|
925
1009
|
* @param {BoundDecl[]} boundDecls
|
|
@@ -927,11 +1011,12 @@ function getRawText(contentSpan, sourceText) {
|
|
|
927
1011
|
* @param {Array<{ sourceText: string, filePath: string }>} fileData
|
|
928
1012
|
* @returns {{ nodes: Map<DeclId, NodeModel>, repositories: Map<DeclId, RepositoryModel>, references: Map<DeclId, ReferenceModel>, diagnostics: Diagnostic[] }}
|
|
929
1013
|
*/
|
|
930
|
-
function buildNodes(boundDecls, schemas, fileData) {
|
|
1014
|
+
function buildNodes(boundDecls, schemas, fileData, universeName) {
|
|
931
1015
|
const nodes = new Map();
|
|
932
1016
|
const repositories = new Map();
|
|
933
1017
|
const references = new Map();
|
|
934
1018
|
const diagnostics = [];
|
|
1019
|
+
const inlineReferenceNamesByScope = new Map();
|
|
935
1020
|
|
|
936
1021
|
// Build file data map
|
|
937
1022
|
const fileDataMap = new Map();
|
|
@@ -954,6 +1039,47 @@ function buildNodes(boundDecls, schemas, fileData) {
|
|
|
954
1039
|
source: block.span,
|
|
955
1040
|
};
|
|
956
1041
|
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* @param {any} block
|
|
1045
|
+
* @param {string} sourceText
|
|
1046
|
+
* @returns {ReferenceModel}
|
|
1047
|
+
*/
|
|
1048
|
+
function buildReferenceFromBlock(block, sourceText) {
|
|
1049
|
+
const ref = {
|
|
1050
|
+
id: '',
|
|
1051
|
+
name: '',
|
|
1052
|
+
urls: [],
|
|
1053
|
+
source: block.source || block.span,
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
if (block.repositoryName) {
|
|
1057
|
+
ref.repositoryName = block.repositoryName;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const children = block.children || [];
|
|
1061
|
+
for (const child of children) {
|
|
1062
|
+
if (child.kind === 'url' && child.value) {
|
|
1063
|
+
ref.urls = [child.value];
|
|
1064
|
+
} else if (child.kind === 'paths' && child.paths) {
|
|
1065
|
+
ref.paths = child.paths;
|
|
1066
|
+
} else if (child.kind === 'kind' && child.value) {
|
|
1067
|
+
ref.kind = child.value;
|
|
1068
|
+
} else if (child.kind === 'title') {
|
|
1069
|
+
const raw = getRawText(child.contentSpan, sourceText);
|
|
1070
|
+
const titleValue = normalizeTitleValue(raw);
|
|
1071
|
+
if (titleValue) {
|
|
1072
|
+
ref.title = titleValue;
|
|
1073
|
+
}
|
|
1074
|
+
} else if (child.kind === 'describe') {
|
|
1075
|
+
ref.describe = buildTextBlock(child, sourceText);
|
|
1076
|
+
} else if (child.kind === 'note') {
|
|
1077
|
+
ref.note = buildTextBlock(child, sourceText);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
return ref;
|
|
1082
|
+
}
|
|
957
1083
|
|
|
958
1084
|
for (const bound of boundDecls) {
|
|
959
1085
|
const { id, info, decl } = bound;
|
|
@@ -1078,7 +1204,7 @@ function buildNodes(boundDecls, schemas, fileData) {
|
|
|
1078
1204
|
// Extract unknown blocks (non-container, non-standard blocks)
|
|
1079
1205
|
const unknownBlocks = (decl.body || []).filter(b => {
|
|
1080
1206
|
if (!b.kind) return false;
|
|
1081
|
-
const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference'];
|
|
1207
|
+
const knownKinds = ['describe', 'title', 'note', 'relationships', 'from', 'references', 'reference', 'ordering'];
|
|
1082
1208
|
return !knownKinds.includes(b.kind);
|
|
1083
1209
|
});
|
|
1084
1210
|
if (unknownBlocks.length > 0) {
|
|
@@ -1090,13 +1216,49 @@ function buildNodes(boundDecls, schemas, fileData) {
|
|
|
1090
1216
|
}));
|
|
1091
1217
|
}
|
|
1092
1218
|
|
|
1093
|
-
// Extract references block
|
|
1219
|
+
// Extract references block (resolved after all nodes are built)
|
|
1094
1220
|
const referencesBlock = (decl.body || []).find(b => b.kind === 'references');
|
|
1095
1221
|
if (referencesBlock && referencesBlock.items) {
|
|
1096
|
-
node.
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1222
|
+
node._pendingReferences = referencesBlock.items
|
|
1223
|
+
.map(item => ({
|
|
1224
|
+
name: item.ref || item.name || '',
|
|
1225
|
+
source: item.span || referencesBlock.source,
|
|
1226
|
+
}))
|
|
1227
|
+
.filter(item => item.name);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Extract inline reference blocks and attach directly
|
|
1231
|
+
const inlineReferenceBlocks = (decl.body || []).filter(b => b.kind === 'reference' && b.children);
|
|
1232
|
+
if (inlineReferenceBlocks.length > 0) {
|
|
1233
|
+
const nameMap = inlineReferenceNamesByScope.get(id) || new Map();
|
|
1234
|
+
inlineReferenceNamesByScope.set(id, nameMap);
|
|
1235
|
+
for (const block of inlineReferenceBlocks) {
|
|
1236
|
+
const refModel = buildReferenceFromBlock(block, sourceText);
|
|
1237
|
+
const baseName = deriveReferenceName(refModel);
|
|
1238
|
+
const uniqueName = pickUniqueInlineReferenceName(nameMap, baseName);
|
|
1239
|
+
const refId = makeDeclId(universeName, 'reference', `${id}:${uniqueName}`);
|
|
1240
|
+
refModel.id = refId;
|
|
1241
|
+
refModel.name = uniqueName;
|
|
1242
|
+
refModel.scopeDeclId = id;
|
|
1243
|
+
references.set(refId, refModel);
|
|
1244
|
+
if (!node.references) {
|
|
1245
|
+
node.references = [];
|
|
1246
|
+
}
|
|
1247
|
+
node.references.push(refId);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Extract ordering block (applied after all nodes are built)
|
|
1252
|
+
const orderingBlocks = (decl.body || []).filter(b => b.kind === 'ordering');
|
|
1253
|
+
if (orderingBlocks.length > 0) {
|
|
1254
|
+
if (orderingBlocks.length > 1) {
|
|
1255
|
+
diagnostics.push({
|
|
1256
|
+
severity: 'warning',
|
|
1257
|
+
message: `Multiple ordering blocks in ${node.kind} "${node.name}". Using the first one.`,
|
|
1258
|
+
source: orderingBlocks[1].source || orderingBlocks[0].source,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
node._pendingOrdering = orderingBlocks[0];
|
|
1100
1262
|
}
|
|
1101
1263
|
|
|
1102
1264
|
nodes.set(id, node);
|
|
@@ -1172,6 +1334,12 @@ function buildNodes(boundDecls, schemas, fileData) {
|
|
|
1172
1334
|
if (pathsBlock && pathsBlock.paths) {
|
|
1173
1335
|
ref.paths = pathsBlock.paths;
|
|
1174
1336
|
}
|
|
1337
|
+
|
|
1338
|
+
// Extract kind block
|
|
1339
|
+
const kindBlock = (decl.body || []).find(b => b.kind === 'kind');
|
|
1340
|
+
if (kindBlock && kindBlock.value) {
|
|
1341
|
+
ref.kind = kindBlock.value;
|
|
1342
|
+
}
|
|
1175
1343
|
|
|
1176
1344
|
// Extract title block
|
|
1177
1345
|
const titleBlock = (decl.body || []).find(b => b.kind === 'title');
|
|
@@ -1194,6 +1362,8 @@ function buildNodes(boundDecls, schemas, fileData) {
|
|
|
1194
1362
|
if (noteBlock) {
|
|
1195
1363
|
ref.note = buildTextBlock(noteBlock, sourceText);
|
|
1196
1364
|
}
|
|
1365
|
+
|
|
1366
|
+
ref.scopeDeclId = info.parentDeclId || id;
|
|
1197
1367
|
|
|
1198
1368
|
references.set(id, ref);
|
|
1199
1369
|
}
|
|
@@ -1202,6 +1372,292 @@ function buildNodes(boundDecls, schemas, fileData) {
|
|
|
1202
1372
|
return { nodes, repositories, references, diagnostics };
|
|
1203
1373
|
}
|
|
1204
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* Attach named references declared inside containers to their parent nodes.
|
|
1377
|
+
* @param {BoundDecl[]} boundDecls
|
|
1378
|
+
* @param {Map<DeclId, NodeModel>} nodes
|
|
1379
|
+
*/
|
|
1380
|
+
function addImplicitReferenceAttachments(boundDecls, nodes) {
|
|
1381
|
+
for (const bound of boundDecls) {
|
|
1382
|
+
const { info } = bound;
|
|
1383
|
+
if (info.kindResolved !== 'reference') continue;
|
|
1384
|
+
const parentId = info.parentDeclId;
|
|
1385
|
+
if (!parentId) continue;
|
|
1386
|
+
const parentNode = nodes.get(parentId);
|
|
1387
|
+
if (!parentNode || parentNode.kind === 'universe') continue;
|
|
1388
|
+
if (!parentNode._pendingReferences) {
|
|
1389
|
+
parentNode._pendingReferences = [];
|
|
1390
|
+
}
|
|
1391
|
+
parentNode._pendingReferences.push({
|
|
1392
|
+
name: info.name,
|
|
1393
|
+
source: info.span,
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Resolve reference declarations to repository URLs and repository references.
|
|
1400
|
+
* @param {BoundDecl[]} boundDecls
|
|
1401
|
+
* @param {Map<DeclId, ReferenceModel>} references
|
|
1402
|
+
* @param {Map<DeclId, RepositoryModel>} repositories
|
|
1403
|
+
* @param {DeclIndex} index
|
|
1404
|
+
* @param {Map<DeclId, DeclId>} parentMap
|
|
1405
|
+
* @returns {Diagnostic[]}
|
|
1406
|
+
*/
|
|
1407
|
+
function resolveReferenceModels(boundDecls, references, repositories, index, parentMap) {
|
|
1408
|
+
const diagnostics = [];
|
|
1409
|
+
const unknownRepoCounts = new Map();
|
|
1410
|
+
|
|
1411
|
+
for (const ref of references.values()) {
|
|
1412
|
+
const scopeDeclId = ref.scopeDeclId || ref.id;
|
|
1413
|
+
const source = ref.source;
|
|
1414
|
+
const displayName = ref.name || 'unnamed reference';
|
|
1415
|
+
const hasUrl = Array.isArray(ref.urls) && ref.urls.length > 0;
|
|
1416
|
+
const repositoryName = ref.repositoryName;
|
|
1417
|
+
let urls = hasUrl ? [...ref.urls] : [];
|
|
1418
|
+
let repositoryRef = undefined;
|
|
1419
|
+
|
|
1420
|
+
if (hasUrl && repositoryName) {
|
|
1421
|
+
diagnostics.push({
|
|
1422
|
+
severity: 'error',
|
|
1423
|
+
message: `Reference "${displayName}" cannot include both url { ... } and in <Repository>`,
|
|
1424
|
+
source,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
if (!hasUrl && repositoryName) {
|
|
1429
|
+
const pathSegments = repositoryName.split('.');
|
|
1430
|
+
if (pathSegments.length === 1) {
|
|
1431
|
+
const resolved = resolveUnqualified(pathSegments[0], scopeDeclId, index, parentMap, source);
|
|
1432
|
+
if (resolved.ambiguous) {
|
|
1433
|
+
diagnostics.push({
|
|
1434
|
+
severity: 'error',
|
|
1435
|
+
message: `Ambiguous repository "${repositoryName}" - use qualified path`,
|
|
1436
|
+
source,
|
|
1437
|
+
});
|
|
1438
|
+
} else if (resolved.declId) {
|
|
1439
|
+
const repo = repositories.get(resolved.declId);
|
|
1440
|
+
if (!repo) {
|
|
1441
|
+
diagnostics.push({
|
|
1442
|
+
severity: 'error',
|
|
1443
|
+
message: `Reference "${displayName}" uses "${repositoryName}" which is not a repository`,
|
|
1444
|
+
source,
|
|
1445
|
+
});
|
|
1446
|
+
} else if (!repo.url) {
|
|
1447
|
+
diagnostics.push({
|
|
1448
|
+
severity: 'error',
|
|
1449
|
+
message: `Repository "${repo.name}" is missing url { '...' }`,
|
|
1450
|
+
source,
|
|
1451
|
+
});
|
|
1452
|
+
} else {
|
|
1453
|
+
repositoryRef = repo.id;
|
|
1454
|
+
if (ref.paths && ref.paths.length > 0) {
|
|
1455
|
+
urls = ref.paths.map((path) => joinRepositoryUrl(repo.url, path));
|
|
1456
|
+
} else if (ref.paths && ref.paths.length === 0) {
|
|
1457
|
+
diagnostics.push({
|
|
1458
|
+
severity: 'error',
|
|
1459
|
+
message: `Reference "${displayName}" has an empty paths block`,
|
|
1460
|
+
source,
|
|
1461
|
+
});
|
|
1462
|
+
} else {
|
|
1463
|
+
urls = [repo.url];
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
} else {
|
|
1467
|
+
const entry = unknownRepoCounts.get(repositoryName) || {
|
|
1468
|
+
count: 0,
|
|
1469
|
+
source,
|
|
1470
|
+
};
|
|
1471
|
+
entry.count += 1;
|
|
1472
|
+
if (!entry.source && source) {
|
|
1473
|
+
entry.source = source;
|
|
1474
|
+
}
|
|
1475
|
+
unknownRepoCounts.set(repositoryName, entry);
|
|
1476
|
+
}
|
|
1477
|
+
} else {
|
|
1478
|
+
const resolved = resolveQualified(pathSegments, index, source);
|
|
1479
|
+
if (resolved.declId) {
|
|
1480
|
+
const repo = repositories.get(resolved.declId);
|
|
1481
|
+
if (!repo) {
|
|
1482
|
+
diagnostics.push({
|
|
1483
|
+
severity: 'error',
|
|
1484
|
+
message: `Reference "${displayName}" uses "${repositoryName}" which is not a repository`,
|
|
1485
|
+
source,
|
|
1486
|
+
});
|
|
1487
|
+
} else if (!repo.url) {
|
|
1488
|
+
diagnostics.push({
|
|
1489
|
+
severity: 'error',
|
|
1490
|
+
message: `Repository "${repo.name}" is missing url { '...' }`,
|
|
1491
|
+
source,
|
|
1492
|
+
});
|
|
1493
|
+
} else {
|
|
1494
|
+
repositoryRef = repo.id;
|
|
1495
|
+
if (ref.paths && ref.paths.length > 0) {
|
|
1496
|
+
urls = ref.paths.map((path) => joinRepositoryUrl(repo.url, path));
|
|
1497
|
+
} else if (ref.paths && ref.paths.length === 0) {
|
|
1498
|
+
diagnostics.push({
|
|
1499
|
+
severity: 'error',
|
|
1500
|
+
message: `Reference "${displayName}" has an empty paths block`,
|
|
1501
|
+
source,
|
|
1502
|
+
});
|
|
1503
|
+
} else {
|
|
1504
|
+
urls = [repo.url];
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
} else {
|
|
1508
|
+
const entry = unknownRepoCounts.get(repositoryName) || {
|
|
1509
|
+
count: 0,
|
|
1510
|
+
source,
|
|
1511
|
+
};
|
|
1512
|
+
entry.count += 1;
|
|
1513
|
+
if (!entry.source && source) {
|
|
1514
|
+
entry.source = source;
|
|
1515
|
+
}
|
|
1516
|
+
unknownRepoCounts.set(repositoryName, entry);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
if (!hasUrl && !repositoryName) {
|
|
1522
|
+
diagnostics.push({
|
|
1523
|
+
severity: 'error',
|
|
1524
|
+
message: `Reference "${displayName}" must have url { ... } or declared as reference ${displayName} in <Repository>`,
|
|
1525
|
+
source,
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
if (urls.length > 0) {
|
|
1530
|
+
ref.urls = urls;
|
|
1531
|
+
}
|
|
1532
|
+
if (repositoryRef) {
|
|
1533
|
+
ref.repositoryRef = repositoryRef;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
for (const [repoName, info] of unknownRepoCounts.entries()) {
|
|
1538
|
+
const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
|
|
1539
|
+
diagnostics.push({
|
|
1540
|
+
severity: 'error',
|
|
1541
|
+
message: `Unknown repository "${repoName}" used by references${countText}.`,
|
|
1542
|
+
source: info.source,
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
return diagnostics;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Resolve references blocks to reference IDs.
|
|
1551
|
+
* @param {Map<DeclId, NodeModel>} nodes
|
|
1552
|
+
* @param {Map<DeclId, ReferenceModel>} references
|
|
1553
|
+
* @param {DeclIndex} index
|
|
1554
|
+
* @param {Map<DeclId, DeclId>} parentMap
|
|
1555
|
+
* @returns {Diagnostic[]}
|
|
1556
|
+
*/
|
|
1557
|
+
function resolveReferenceAttachments(nodes, references, index, parentMap) {
|
|
1558
|
+
const diagnostics = [];
|
|
1559
|
+
const unknownReferenceCounts = new Map();
|
|
1560
|
+
|
|
1561
|
+
for (const node of nodes.values()) {
|
|
1562
|
+
if (!node._pendingReferences) continue;
|
|
1563
|
+
const resolvedReferences = [];
|
|
1564
|
+
const existingReferences = Array.isArray(node.references) ? node.references : [];
|
|
1565
|
+
|
|
1566
|
+
for (const item of node._pendingReferences) {
|
|
1567
|
+
const name = item.name;
|
|
1568
|
+
if (!name) continue;
|
|
1569
|
+
|
|
1570
|
+
let resolvedDeclId = null;
|
|
1571
|
+
const pathSegments = name.split('.');
|
|
1572
|
+
if (pathSegments.length === 1) {
|
|
1573
|
+
const resolved = resolveUnqualified(pathSegments[0], node.id, index, parentMap, item.source);
|
|
1574
|
+
if (resolved.ambiguous) {
|
|
1575
|
+
diagnostics.push({
|
|
1576
|
+
severity: 'error',
|
|
1577
|
+
message: `Ambiguous reference "${name}" - use qualified path`,
|
|
1578
|
+
source: item.source,
|
|
1579
|
+
});
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
resolvedDeclId = resolved.declId;
|
|
1583
|
+
} else {
|
|
1584
|
+
const resolved = resolveQualified(pathSegments, index, item.source);
|
|
1585
|
+
resolvedDeclId = resolved.declId;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
if (resolvedDeclId && references.has(resolvedDeclId)) {
|
|
1589
|
+
resolvedReferences.push(resolvedDeclId);
|
|
1590
|
+
} else {
|
|
1591
|
+
const entry = unknownReferenceCounts.get(name) || { count: 0, source: item.source };
|
|
1592
|
+
entry.count += 1;
|
|
1593
|
+
if (!entry.source && item.source) {
|
|
1594
|
+
entry.source = item.source;
|
|
1595
|
+
}
|
|
1596
|
+
unknownReferenceCounts.set(name, entry);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const combined = [...existingReferences, ...resolvedReferences];
|
|
1601
|
+
node.references = Array.from(new Set(combined));
|
|
1602
|
+
delete node._pendingReferences;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
for (const [name, info] of unknownReferenceCounts.entries()) {
|
|
1606
|
+
const countText = info.count > 1 ? ` (${info.count} occurrences)` : '';
|
|
1607
|
+
diagnostics.push({
|
|
1608
|
+
severity: 'error',
|
|
1609
|
+
message: `Unknown reference "${name}" in references list${countText}. References must use reference names, not paths.`,
|
|
1610
|
+
source: info.source,
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
return diagnostics;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* Validate and apply ordering blocks after all nodes are created.
|
|
1619
|
+
* @param {Map<DeclId, NodeModel>} nodes
|
|
1620
|
+
* @returns {Diagnostic[]}
|
|
1621
|
+
*/
|
|
1622
|
+
function applyOrderingBlocks(nodes) {
|
|
1623
|
+
const diagnostics = [];
|
|
1624
|
+
|
|
1625
|
+
for (const node of nodes.values()) {
|
|
1626
|
+
if (!node._pendingOrdering) continue;
|
|
1627
|
+
const orderingBlock = node._pendingOrdering;
|
|
1628
|
+
const orderingIdentifiers = orderingBlock.identifiers || [];
|
|
1629
|
+
|
|
1630
|
+
const childNameToNodeId = new Map();
|
|
1631
|
+
for (const childId of node.children || []) {
|
|
1632
|
+
const childNode = nodes.get(childId);
|
|
1633
|
+
if (childNode) {
|
|
1634
|
+
childNameToNodeId.set(childNode.name, childId);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const validOrdering = [];
|
|
1639
|
+
for (const identifier of orderingIdentifiers) {
|
|
1640
|
+
if (childNameToNodeId.has(identifier)) {
|
|
1641
|
+
validOrdering.push(identifier);
|
|
1642
|
+
} else {
|
|
1643
|
+
diagnostics.push({
|
|
1644
|
+
severity: 'warning',
|
|
1645
|
+
message: `Ordering block in ${node.kind} "${node.name}" references unknown child "${identifier}". This identifier will be ignored.`,
|
|
1646
|
+
source: orderingBlock.source,
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
if (validOrdering.length > 0) {
|
|
1652
|
+
node.ordering = validOrdering;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
delete node._pendingOrdering;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
return diagnostics;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1205
1661
|
/**
|
|
1206
1662
|
* Pass 5: Build edges from relationship usage
|
|
1207
1663
|
* @param {BoundDecl[]} boundDecls
|
|
@@ -1466,7 +1922,20 @@ export function buildGraph(fileAstOrFileAsts, options) {
|
|
|
1466
1922
|
|
|
1467
1923
|
// Pass 4: Build nodes
|
|
1468
1924
|
const fileData = normalized.map(n => ({ sourceText: n.sourceText, filePath: n.filePath }));
|
|
1469
|
-
const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(
|
|
1925
|
+
const { nodes, repositories, references, diagnostics: nodeDiags } = buildNodes(
|
|
1926
|
+
boundDecls,
|
|
1927
|
+
schemas,
|
|
1928
|
+
fileData,
|
|
1929
|
+
universeName,
|
|
1930
|
+
);
|
|
1931
|
+
|
|
1932
|
+
// Resolve reference models and attachments
|
|
1933
|
+
addImplicitReferenceAttachments(boundDecls, nodes);
|
|
1934
|
+
const referenceModelDiags = resolveReferenceModels(boundDecls, references, repositories, index, parentMap);
|
|
1935
|
+
const referenceAttachmentDiags = resolveReferenceAttachments(nodes, references, index, parentMap);
|
|
1936
|
+
|
|
1937
|
+
// Apply ordering blocks after all nodes are available
|
|
1938
|
+
const orderingDiags = applyOrderingBlocks(nodes);
|
|
1470
1939
|
|
|
1471
1940
|
// Resolve relates endpoints
|
|
1472
1941
|
const relatesDiags = resolveRelatesEndpoints(nodes, index, parentMap, boundDecls);
|
|
@@ -1483,6 +1952,9 @@ export function buildGraph(fileAstOrFileAsts, options) {
|
|
|
1483
1952
|
...bindDiags,
|
|
1484
1953
|
...schemaDiags,
|
|
1485
1954
|
...nodeDiags,
|
|
1955
|
+
...referenceModelDiags,
|
|
1956
|
+
...referenceAttachmentDiags,
|
|
1957
|
+
...orderingDiags,
|
|
1486
1958
|
...relatesDiags,
|
|
1487
1959
|
...edgeDiags,
|
|
1488
1960
|
];
|
package/src/universe/parser.js
CHANGED
|
@@ -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)
|