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