patram 0.2.0 → 0.4.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.
Files changed (38) hide show
  1. package/lib/build-graph-identity.js +86 -99
  2. package/lib/build-graph.js +536 -31
  3. package/lib/build-graph.types.ts +6 -2
  4. package/lib/check-directive-metadata.js +534 -0
  5. package/lib/check-directive-value.js +291 -0
  6. package/lib/check-graph.js +23 -5
  7. package/lib/cli-help-metadata.js +56 -16
  8. package/lib/command-output.js +16 -1
  9. package/lib/derived-summary.js +10 -8
  10. package/lib/directive-diagnostics.js +38 -0
  11. package/lib/directive-type-rules.js +133 -0
  12. package/lib/discover-fields.js +435 -0
  13. package/lib/discover-fields.types.ts +52 -0
  14. package/lib/document-node-identity.js +317 -0
  15. package/lib/format-node-header.js +9 -7
  16. package/lib/format-output-metadata.js +15 -23
  17. package/lib/layout-stored-queries.js +124 -85
  18. package/lib/load-patram-config.js +433 -96
  19. package/lib/load-patram-config.types.ts +98 -3
  20. package/lib/load-project-graph.js +4 -1
  21. package/lib/output-view.types.ts +14 -6
  22. package/lib/parse-cli-arguments.types.ts +1 -1
  23. package/lib/parse-where-clause.js +344 -107
  24. package/lib/parse-where-clause.types.ts +25 -8
  25. package/lib/patram-cli.js +68 -4
  26. package/lib/patram-config.js +31 -31
  27. package/lib/patram-config.types.ts +10 -4
  28. package/lib/query-graph.js +269 -40
  29. package/lib/query-inspection.js +440 -60
  30. package/lib/render-field-discovery.js +184 -0
  31. package/lib/render-json-output.js +21 -22
  32. package/lib/render-output-view.js +301 -34
  33. package/lib/render-plain-output.js +1 -1
  34. package/lib/render-rich-output.js +1 -1
  35. package/lib/render-rich-source.js +245 -14
  36. package/lib/resolve-patram-graph-config.js +15 -9
  37. package/lib/show-document.js +66 -9
  38. package/package.json +5 -5
@@ -1,15 +1,20 @@
1
+ /* eslint-disable max-lines */
1
2
  /**
2
3
  * @import { BuildGraphResult, GraphEdge, GraphNode } from './build-graph.types.ts';
3
4
  * @import { PatramClaim } from './parse-claims.types.ts';
5
+ * @import { MetadataFieldConfig } from './load-patram-config.types.ts';
4
6
  * @import { MappingDefinition, PatramConfig } from './patram-config.types.ts';
5
7
  */
6
8
 
9
+ import { posix } from 'node:path';
10
+
7
11
  import {
8
12
  collectDocumentEntityKeys,
13
+ collectDocumentNodeReferences,
9
14
  normalizeRepoRelativePath,
10
15
  resolveNodeKey,
11
16
  resolveTargetReference,
12
- setNonDocumentPath,
17
+ setCanonicalPath,
13
18
  } from './build-graph-identity.js';
14
19
 
15
20
  /**
@@ -25,7 +30,7 @@ import {
25
30
  * Uses Term: ../docs/reference/terms/graph.md
26
31
  * Uses Term: ../docs/reference/terms/mapping.md
27
32
  * Uses Term: ../docs/reference/terms/relation.md
28
- * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
33
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfood.md
29
34
  * Decided by: ../docs/decisions/graph-materialization.md
30
35
  * Implements: ../docs/tasks/v0/materialize-graph.md
31
36
  * @patram
@@ -33,6 +38,11 @@ import {
33
38
  * @see {@link ../docs/decisions/graph-materialization.md}
34
39
  */
35
40
 
41
+ const STRUCTURAL_FIELD_NAMES = new Set(['$class', '$id', '$path']);
42
+ const DETERMINISTIC_LOCALE = 'en';
43
+ const DERIVED_TITLE_PRIORITY = 1;
44
+ const EXPLICIT_TITLE_PRIORITY = 2;
45
+
36
46
  /**
37
47
  * Build a Patram graph from semantic config and parsed claims.
38
48
  *
@@ -43,6 +53,10 @@ import {
43
53
  export function buildGraph(patram_config, claims) {
44
54
  /** @type {Map<string, GraphNode>} */
45
55
  const graph_nodes = new Map();
56
+ const document_node_references = collectDocumentNodeReferences(
57
+ patram_config.mappings,
58
+ claims,
59
+ );
46
60
  const document_entity_keys = collectDocumentEntityKeys(
47
61
  patram_config.mappings,
48
62
  claims,
@@ -51,28 +65,43 @@ export function buildGraph(patram_config, claims) {
51
65
  const document_paths = new Set(
52
66
  claims.map((claim) => normalizeRepoRelativePath(claim.origin.path)),
53
67
  );
68
+ /** @type {Map<string, number>} */
69
+ const title_priorities = new Map();
54
70
 
55
- createDocumentNodes(graph_nodes, claims);
71
+ createDocumentNodes(graph_nodes, document_node_references);
56
72
  applyNodeMappings(
57
73
  graph_nodes,
58
- patram_config.mappings,
74
+ patram_config,
59
75
  claims,
60
76
  document_entity_keys,
77
+ document_node_references,
78
+ title_priorities,
61
79
  );
62
80
  const graph_edges = createGraphEdges(
63
81
  graph_nodes,
64
82
  patram_config.mappings,
65
83
  claims,
66
84
  document_entity_keys,
85
+ document_node_references,
67
86
  document_paths,
68
87
  );
88
+ applyFallbackTitles(graph_nodes, title_priorities);
69
89
 
70
- return {
90
+ const graph_result = {
71
91
  edges: graph_edges,
72
92
  nodes: Object.fromEntries(
73
93
  [...graph_nodes.entries()].sort(compareNodeEntries),
74
94
  ),
75
95
  };
96
+
97
+ attachDocumentNodeAliases(graph_result.nodes, document_node_references);
98
+ Object.defineProperty(
99
+ graph_result,
100
+ 'document_node_ids',
101
+ createDocumentNodeIdsProperty(document_node_references),
102
+ );
103
+
104
+ return graph_result;
76
105
  }
77
106
 
78
107
  /**
@@ -103,32 +132,41 @@ function resolveDirectiveMapping(mappings, claim) {
103
132
 
104
133
  /**
105
134
  * @param {Map<string, GraphNode>} graph_nodes
106
- * @param {PatramClaim[]} claims
135
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
107
136
  */
108
- function createDocumentNodes(graph_nodes, claims) {
109
- for (const claim of claims) {
110
- upsertNode(
137
+ function createDocumentNodes(graph_nodes, document_node_references) {
138
+ for (const document_node_reference of document_node_references.values()) {
139
+ const graph_node = upsertNode(
111
140
  graph_nodes,
112
- 'document',
113
- normalizeRepoRelativePath(claim.origin.path),
141
+ document_node_reference.class_name,
142
+ document_node_reference.key,
114
143
  );
144
+
145
+ setCanonicalPath(graph_node, document_node_reference.path);
115
146
  }
116
147
  }
117
148
 
118
149
  /**
119
150
  * @param {Map<string, GraphNode>} graph_nodes
120
- * @param {Record<string, MappingDefinition>} mappings
151
+ * @param {PatramConfig} patram_config
121
152
  * @param {PatramClaim[]} claims
122
153
  * @param {Map<string, string>} document_entity_keys
154
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
155
+ * @param {Map<string, number>} title_priorities
123
156
  */
124
157
  function applyNodeMappings(
125
158
  graph_nodes,
126
- mappings,
159
+ patram_config,
127
160
  claims,
128
161
  document_entity_keys,
162
+ document_node_references,
163
+ title_priorities,
129
164
  ) {
130
165
  for (const claim of claims) {
131
- const mapping_definition = resolveMappingDefinition(mappings, claim);
166
+ const mapping_definition = resolveMappingDefinition(
167
+ patram_config.mappings,
168
+ claim,
169
+ );
132
170
 
133
171
  if (!mapping_definition?.node) {
134
172
  continue;
@@ -136,9 +174,12 @@ function applyNodeMappings(
136
174
 
137
175
  applyNodeMapping(
138
176
  graph_nodes,
177
+ patram_config,
139
178
  mapping_definition.node,
140
179
  claim,
141
180
  document_entity_keys,
181
+ document_node_references,
182
+ title_priorities,
142
183
  );
143
184
  }
144
185
  }
@@ -148,6 +189,7 @@ function applyNodeMappings(
148
189
  * @param {Record<string, MappingDefinition>} mappings
149
190
  * @param {PatramClaim[]} claims
150
191
  * @param {Map<string, string>} document_entity_keys
192
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
151
193
  * @param {Set<string>} document_paths
152
194
  * @returns {GraphEdge[]}
153
195
  */
@@ -156,6 +198,7 @@ function createGraphEdges(
156
198
  mappings,
157
199
  claims,
158
200
  document_entity_keys,
201
+ document_node_references,
159
202
  document_paths,
160
203
  ) {
161
204
  /** @type {GraphEdge[]} */
@@ -169,25 +212,26 @@ function createGraphEdges(
169
212
  continue;
170
213
  }
171
214
 
172
- const source_document_node = upsertNode(
215
+ const source_document_node = getDocumentGraphNode(
173
216
  graph_nodes,
174
- 'document',
175
- normalizeRepoRelativePath(claim.origin.path),
217
+ document_node_references,
218
+ claim,
176
219
  );
177
220
  const target_reference = resolveTargetReference(
178
- mapping_definition.emit.target_kind,
221
+ mapping_definition.emit.target_class,
179
222
  mapping_definition.emit.target,
180
223
  claim,
181
224
  document_entity_keys,
225
+ document_node_references,
182
226
  document_paths,
183
227
  );
184
228
  const target_node = upsertNode(
185
229
  graph_nodes,
186
- mapping_definition.emit.target_kind,
230
+ target_reference.class_name,
187
231
  target_reference.key,
188
232
  );
189
233
 
190
- setNonDocumentPath(target_node, target_reference.path);
234
+ setCanonicalPath(target_node, target_reference.path);
191
235
 
192
236
  edge_number += 1;
193
237
  graph_edges.push({
@@ -204,23 +248,129 @@ function createGraphEdges(
204
248
 
205
249
  /**
206
250
  * @param {Map<string, GraphNode>} graph_nodes
207
- * @param {{ field: string, key?: 'path' | 'value', kind: string }} node_mapping
251
+ * @param {PatramConfig} patram_config
252
+ * @param {{ field: string, key?: 'path' | 'value', class: string }} node_mapping
208
253
  * @param {PatramClaim} claim
209
254
  * @param {Map<string, string>} document_entity_keys
255
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
256
+ * @param {Map<string, number>} title_priorities
210
257
  */
211
258
  function applyNodeMapping(
212
259
  graph_nodes,
260
+ patram_config,
213
261
  node_mapping,
214
262
  claim,
215
263
  document_entity_keys,
264
+ document_node_references,
265
+ title_priorities,
216
266
  ) {
217
267
  const source_key = normalizeRepoRelativePath(claim.origin.path);
218
- const node_key = resolveNodeKey(node_mapping, claim, document_entity_keys);
219
- const graph_node = upsertNode(graph_nodes, node_mapping.kind, node_key);
268
+ const graph_node = resolveGraphNode(
269
+ graph_nodes,
270
+ node_mapping,
271
+ claim,
272
+ document_entity_keys,
273
+ document_node_references,
274
+ );
275
+ const field_value = resolveNodeFieldValue(
276
+ graph_node,
277
+ node_mapping,
278
+ claim,
279
+ source_key,
280
+ );
281
+
282
+ setCanonicalPath(graph_node, source_key);
283
+ validateNodeFieldMapping(
284
+ patram_config,
285
+ node_mapping.class,
286
+ node_mapping.field,
287
+ );
288
+
289
+ if (node_mapping.field === 'title') {
290
+ setNodeTitle(
291
+ graph_node,
292
+ title_priorities,
293
+ field_value,
294
+ claim.type === 'document.title'
295
+ ? DERIVED_TITLE_PRIORITY
296
+ : EXPLICIT_TITLE_PRIORITY,
297
+ );
298
+ return;
299
+ }
300
+
301
+ setNodeFieldValue(
302
+ graph_node,
303
+ node_mapping.field,
304
+ field_value,
305
+ getFieldDefinition(patram_config, node_mapping.field),
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Validate one mapped node field against the configured field model.
311
+ *
312
+ * @param {PatramConfig} patram_config
313
+ * @param {string} node_class
314
+ * @param {string} field_name
315
+ */
316
+ function validateNodeFieldMapping(patram_config, node_class, field_name) {
317
+ const validation_error = getNodeFieldValidationError(
318
+ patram_config,
319
+ node_class,
320
+ field_name,
321
+ );
322
+
323
+ if (validation_error) {
324
+ throw new Error(validation_error);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * @param {PatramConfig} patram_config
330
+ * @param {string} node_class
331
+ * @param {string} field_name
332
+ * @returns {string | null}
333
+ */
334
+ function getNodeFieldValidationError(patram_config, node_class, field_name) {
335
+ if (isStructuralFieldName(field_name) || field_name === 'title') {
336
+ return null;
337
+ }
338
+
339
+ const field_definition = getFieldDefinition(patram_config, field_name);
340
+
341
+ if (!field_definition) {
342
+ return `Node class "${node_class}" maps to unknown field "${field_name}".`;
343
+ }
344
+
345
+ const class_schema = getClassSchema(patram_config, node_class);
346
+ const class_field_rule = class_schema?.fields?.[field_name];
347
+
348
+ if (isForbiddenClassField(class_field_rule)) {
349
+ return `Field "${field_name}" is forbidden for class "${node_class}".`;
350
+ }
351
+
352
+ if (isUndeclaredClassField(class_schema, class_field_rule)) {
353
+ return `Field "${field_name}" is not declared for class "${node_class}".`;
354
+ }
355
+
356
+ return null;
357
+ }
220
358
 
221
- setNonDocumentPath(graph_node, source_key);
359
+ /**
360
+ * @param {{ presence: 'required' | 'optional' | 'forbidden' } | undefined} class_field_rule
361
+ * @returns {boolean}
362
+ */
363
+ function isForbiddenClassField(class_field_rule) {
364
+ return class_field_rule?.presence === 'forbidden';
365
+ }
222
366
 
223
- graph_node[node_mapping.field] = getNodeFieldValue(claim);
367
+ /**
368
+ * @param {{ fields?: Record<string, { presence: 'required' | 'optional' | 'forbidden' }>, unknown_fields?: 'ignore' | 'error' } | undefined} class_schema
369
+ * @param {{ presence: 'required' | 'optional' | 'forbidden' } | undefined} class_field_rule
370
+ * @returns {boolean}
371
+ */
372
+ function isUndeclaredClassField(class_schema, class_field_rule) {
373
+ return class_schema?.unknown_fields === 'error' && !class_field_rule;
224
374
  }
225
375
 
226
376
  /**
@@ -246,23 +396,26 @@ function upsertNode(graph_nodes, kind_name, node_key) {
246
396
 
247
397
  /**
248
398
  * @param {string} node_id
249
- * @param {string} kind_name
399
+ * @param {string} class_name
250
400
  * @param {string} node_key
251
401
  * @returns {GraphNode}
252
402
  */
253
- function createNode(node_id, kind_name, node_key) {
254
- if (kind_name === 'document') {
403
+ function createNode(node_id, class_name, node_key) {
404
+ if (class_name === 'document') {
255
405
  return {
406
+ $class: class_name,
407
+ $id: node_id,
408
+ $path: node_key,
256
409
  id: node_id,
257
- kind: kind_name,
258
410
  path: node_key,
259
411
  };
260
412
  }
261
413
 
262
414
  return {
415
+ $class: class_name,
416
+ $id: node_id,
263
417
  id: node_id,
264
418
  key: node_key,
265
- kind: kind_name,
266
419
  };
267
420
  }
268
421
 
@@ -291,11 +444,363 @@ function getNodeFieldValue(claim) {
291
444
  throw new Error(`Claim "${claim.id}" does not carry a string value.`);
292
445
  }
293
446
 
447
+ /**
448
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
449
+ * @returns {{ configurable: false, enumerable: false, value: Record<string, string>, writable: false }}
450
+ */
451
+ function createDocumentNodeIdsProperty(document_node_references) {
452
+ return {
453
+ configurable: false,
454
+ enumerable: false,
455
+ value: Object.fromEntries(
456
+ [...document_node_references.entries()].map(
457
+ ([document_path, document_node_reference]) => [
458
+ document_path,
459
+ document_node_reference.id,
460
+ ],
461
+ ),
462
+ ),
463
+ writable: false,
464
+ };
465
+ }
466
+
467
+ /**
468
+ * @param {Map<string, GraphNode>} graph_nodes
469
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
470
+ * @param {PatramClaim} claim
471
+ * @returns {GraphNode}
472
+ */
473
+ function getDocumentGraphNode(graph_nodes, document_node_references, claim) {
474
+ const document_path = normalizeRepoRelativePath(claim.origin.path);
475
+ const document_node_reference = document_node_references.get(document_path);
476
+
477
+ if (!document_node_reference) {
478
+ throw new Error(`Missing document node reference for "${document_path}".`);
479
+ }
480
+
481
+ const graph_node = upsertNode(
482
+ graph_nodes,
483
+ document_node_reference.class_name,
484
+ document_node_reference.key,
485
+ );
486
+
487
+ setCanonicalPath(graph_node, document_node_reference.path);
488
+
489
+ return graph_node;
490
+ }
491
+
492
+ /**
493
+ * @param {Map<string, GraphNode>} graph_nodes
494
+ * @param {{ field: string, key?: 'path' | 'value', class: string }} node_mapping
495
+ * @param {PatramClaim} claim
496
+ * @param {Map<string, string>} document_entity_keys
497
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
498
+ * @returns {GraphNode}
499
+ */
500
+ function resolveGraphNode(
501
+ graph_nodes,
502
+ node_mapping,
503
+ claim,
504
+ document_entity_keys,
505
+ document_node_references,
506
+ ) {
507
+ if (node_mapping.class === 'document') {
508
+ return getDocumentGraphNode(graph_nodes, document_node_references, claim);
509
+ }
510
+
511
+ const node_key = resolveNodeKey(node_mapping, claim, document_entity_keys);
512
+
513
+ return upsertNode(graph_nodes, node_mapping.class, node_key);
514
+ }
515
+
516
+ /**
517
+ * @param {GraphNode} graph_node
518
+ * @param {{ field: string, key?: 'path' | 'value', class: string }} node_mapping
519
+ * @param {PatramClaim} claim
520
+ * @param {string} source_path
521
+ * @returns {string}
522
+ */
523
+ function resolveNodeFieldValue(graph_node, node_mapping, claim, source_path) {
524
+ if (node_mapping.field === '$id') {
525
+ return graph_node.id;
526
+ }
527
+
528
+ if (node_mapping.field === '$class') {
529
+ return graph_node.$class ?? graph_node.kind ?? node_mapping.class;
530
+ }
531
+
532
+ if (node_mapping.field === '$path') {
533
+ return source_path;
534
+ }
535
+
536
+ return getNodeFieldValue(claim);
537
+ }
538
+
539
+ /**
540
+ * @param {Record<string, GraphNode>} graph_nodes
541
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
542
+ */
543
+ function attachDocumentNodeAliases(graph_nodes, document_node_references) {
544
+ for (const [
545
+ document_path,
546
+ document_node_reference,
547
+ ] of document_node_references) {
548
+ const document_node_id = `doc:${document_path}`;
549
+
550
+ if (document_node_id === document_node_reference.id) {
551
+ continue;
552
+ }
553
+
554
+ Object.defineProperty(graph_nodes, document_node_id, {
555
+ configurable: false,
556
+ enumerable: false,
557
+ value: graph_nodes[document_node_reference.id],
558
+ writable: false,
559
+ });
560
+ }
561
+ }
562
+
563
+ /**
564
+ * @param {Map<string, GraphNode>} graph_nodes
565
+ * @param {Map<string, number>} title_priorities
566
+ */
567
+ function applyFallbackTitles(graph_nodes, title_priorities) {
568
+ for (const graph_node of graph_nodes.values()) {
569
+ if (graph_node.title !== undefined) {
570
+ continue;
571
+ }
572
+
573
+ const fallback_title = getFallbackTitle(graph_node);
574
+
575
+ graph_node.title = fallback_title;
576
+ title_priorities.set(graph_node.id, 0);
577
+ }
578
+ }
579
+
580
+ /**
581
+ * @param {GraphNode} graph_node
582
+ * @returns {string}
583
+ */
584
+ function getFallbackTitle(graph_node) {
585
+ if (graph_node.$path) {
586
+ return posix.basename(graph_node.$path);
587
+ }
588
+
589
+ return getNodeIdKey(graph_node.$id ?? graph_node.id);
590
+ }
591
+
592
+ /**
593
+ * @param {GraphNode} graph_node
594
+ * @param {Map<string, number>} title_priorities
595
+ * @param {string} title_value
596
+ * @param {number} source_priority
597
+ */
598
+ function setNodeTitle(
599
+ graph_node,
600
+ title_priorities,
601
+ title_value,
602
+ source_priority,
603
+ ) {
604
+ const current_priority = title_priorities.get(graph_node.id);
605
+
606
+ if (current_priority === undefined) {
607
+ graph_node.title = title_value;
608
+ title_priorities.set(graph_node.id, source_priority);
609
+ return;
610
+ }
611
+
612
+ if (source_priority > current_priority) {
613
+ graph_node.title = title_value;
614
+ title_priorities.set(graph_node.id, source_priority);
615
+ return;
616
+ }
617
+
618
+ if (
619
+ source_priority === current_priority &&
620
+ graph_node.title !== title_value
621
+ ) {
622
+ throw new Error(
623
+ `Node "${graph_node.id}" has conflicting title values "${graph_node.title}" and "${title_value}".`,
624
+ );
625
+ }
626
+ }
627
+
628
+ /**
629
+ * @param {GraphNode} graph_node
630
+ * @param {string} field_name
631
+ * @param {string} field_value
632
+ * @param {MetadataFieldConfig | undefined} field_definition
633
+ */
634
+ function setNodeFieldValue(
635
+ graph_node,
636
+ field_name,
637
+ field_value,
638
+ field_definition,
639
+ ) {
640
+ if (
641
+ field_name === '$id' ||
642
+ field_name === '$class' ||
643
+ field_name === '$path'
644
+ ) {
645
+ setStructuralFieldValue(graph_node, field_name, field_value);
646
+ return;
647
+ }
648
+
649
+ if (!field_definition || field_definition.multiple !== true) {
650
+ setSingleValueField(graph_node, field_name, field_value);
651
+ return;
652
+ }
653
+
654
+ setMultiValueField(graph_node, field_name, field_value);
655
+ }
656
+
657
+ /**
658
+ * @param {GraphNode} graph_node
659
+ * @param {'$id' | '$class' | '$path'} field_name
660
+ * @param {string} field_value
661
+ */
662
+ function setStructuralFieldValue(graph_node, field_name, field_value) {
663
+ const current_value = graph_node[field_name];
664
+
665
+ if (current_value === undefined) {
666
+ assignStructuralFieldValue(graph_node, field_name, field_value);
667
+ return;
668
+ }
669
+
670
+ if (
671
+ field_name === '$class' &&
672
+ graph_node.id.startsWith('doc:') &&
673
+ current_value === 'document'
674
+ ) {
675
+ assignStructuralFieldValue(graph_node, field_name, field_value);
676
+ return;
677
+ }
678
+
679
+ if (current_value !== field_value) {
680
+ throw new Error(
681
+ `Node "${graph_node.id}" has conflicting structural values for "${field_name}".`,
682
+ );
683
+ }
684
+ }
685
+
686
+ /**
687
+ * Keep legacy mirrors in sync while the rest of the codebase still reads them.
688
+ *
689
+ * @param {GraphNode} graph_node
690
+ * @param {'$id' | '$class' | '$path'} field_name
691
+ * @param {string} field_value
692
+ */
693
+ function assignStructuralFieldValue(graph_node, field_name, field_value) {
694
+ graph_node[field_name] = field_value;
695
+
696
+ if (field_name === '$path') {
697
+ graph_node.path = field_value;
698
+ }
699
+ }
700
+
701
+ /**
702
+ * @param {GraphNode} graph_node
703
+ * @param {string} field_name
704
+ * @param {string} field_value
705
+ */
706
+ function setSingleValueField(graph_node, field_name, field_value) {
707
+ const current_value = graph_node[field_name];
708
+
709
+ if (current_value === undefined) {
710
+ graph_node[field_name] = field_value;
711
+ return;
712
+ }
713
+
714
+ if (current_value !== field_value) {
715
+ throw new Error(
716
+ `Node "${graph_node.id}" has conflicting values for field "${field_name}": "${current_value}" and "${field_value}".`,
717
+ );
718
+ }
719
+ }
720
+
721
+ /**
722
+ * @param {GraphNode} graph_node
723
+ * @param {string} field_name
724
+ * @param {string} field_value
725
+ */
726
+ function setMultiValueField(graph_node, field_name, field_value) {
727
+ const current_value = graph_node[field_name];
728
+
729
+ if (current_value === undefined) {
730
+ graph_node[field_name] = [field_value];
731
+ return;
732
+ }
733
+
734
+ if (Array.isArray(current_value)) {
735
+ if (current_value.includes(field_value)) {
736
+ return;
737
+ }
738
+
739
+ graph_node[field_name] = [...current_value, field_value].sort(
740
+ compareFieldValues,
741
+ );
742
+ return;
743
+ }
744
+
745
+ graph_node[field_name] = [current_value, field_value].sort(
746
+ compareFieldValues,
747
+ );
748
+ }
749
+
750
+ /**
751
+ * @param {string} left_value
752
+ * @param {string} right_value
753
+ * @returns {number}
754
+ */
755
+ function compareFieldValues(left_value, right_value) {
756
+ return left_value.localeCompare(right_value, DETERMINISTIC_LOCALE);
757
+ }
758
+
759
+ /**
760
+ * @param {PatramConfig} patram_config
761
+ * @param {string} field_name
762
+ * @returns {MetadataFieldConfig | undefined}
763
+ */
764
+ function getFieldDefinition(patram_config, field_name) {
765
+ return patram_config.fields?.[field_name];
766
+ }
767
+
768
+ /**
769
+ * @param {PatramConfig} patram_config
770
+ * @param {string} class_name
771
+ * @returns {{ fields?: Record<string, { presence: 'required' | 'optional' | 'forbidden' }>, unknown_fields?: 'ignore' | 'error' } | undefined}
772
+ */
773
+ function getClassSchema(patram_config, class_name) {
774
+ return patram_config.class_schemas?.[class_name];
775
+ }
776
+
777
+ /**
778
+ * @param {string} field_name
779
+ * @returns {field_name is '$class' | '$id' | '$path'}
780
+ */
781
+ function isStructuralFieldName(field_name) {
782
+ return STRUCTURAL_FIELD_NAMES.has(field_name);
783
+ }
784
+
294
785
  /**
295
786
  * @param {[string, GraphNode]} left_entry
296
787
  * @param {[string, GraphNode]} right_entry
297
788
  * @returns {number}
298
789
  */
299
790
  function compareNodeEntries(left_entry, right_entry) {
300
- return left_entry[0].localeCompare(right_entry[0], 'en');
791
+ return left_entry[0].localeCompare(right_entry[0], DETERMINISTIC_LOCALE);
792
+ }
793
+
794
+ /**
795
+ * @param {string} node_id
796
+ * @returns {string}
797
+ */
798
+ function getNodeIdKey(node_id) {
799
+ const separator_index = node_id.indexOf(':');
800
+
801
+ if (separator_index < 0) {
802
+ return node_id;
803
+ }
804
+
805
+ return node_id.slice(separator_index + 1);
301
806
  }