patram 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/lib/build-graph-identity.js +57 -24
  2. package/lib/build-graph.js +383 -17
  3. package/lib/build-graph.types.ts +5 -2
  4. package/lib/check-directive-metadata.js +516 -0
  5. package/lib/check-directive-value.js +282 -0
  6. package/lib/check-graph.js +24 -5
  7. package/lib/cli-help-metadata.js +580 -0
  8. package/lib/derived-summary.js +280 -0
  9. package/lib/directive-diagnostics.js +38 -0
  10. package/lib/directive-type-rules.js +133 -0
  11. package/lib/discover-fields.js +427 -0
  12. package/lib/discover-fields.types.ts +52 -0
  13. package/lib/format-derived-summary-row.js +9 -0
  14. package/lib/format-node-header.js +21 -0
  15. package/lib/format-output-item-block.js +22 -0
  16. package/lib/format-output-metadata.js +54 -0
  17. package/lib/layout-stored-queries.js +96 -2
  18. package/lib/load-patram-config.js +754 -18
  19. package/lib/load-patram-config.types.ts +128 -2
  20. package/lib/load-project-graph.js +4 -1
  21. package/lib/output-view.types.ts +29 -6
  22. package/lib/parse-cli-arguments-helpers.js +263 -90
  23. package/lib/parse-cli-arguments.js +160 -8
  24. package/lib/parse-cli-arguments.types.ts +49 -4
  25. package/lib/parse-where-clause.js +670 -209
  26. package/lib/parse-where-clause.types.ts +72 -0
  27. package/lib/patram-cli.js +180 -21
  28. package/lib/patram-config.js +31 -31
  29. package/lib/patram-config.types.ts +10 -4
  30. package/lib/patram.js +6 -0
  31. package/lib/query-graph.js +444 -113
  32. package/lib/query-inspection.js +798 -0
  33. package/lib/render-check-output.js +1 -1
  34. package/lib/render-cli-help.js +419 -0
  35. package/lib/render-field-discovery.js +148 -0
  36. package/lib/render-json-output.js +66 -14
  37. package/lib/render-output-view.js +272 -22
  38. package/lib/render-plain-output.js +31 -86
  39. package/lib/render-rich-output.js +34 -87
  40. package/lib/resolve-patram-graph-config.js +15 -9
  41. package/lib/resolve-where-clause.js +18 -3
  42. package/lib/show-document.js +51 -7
  43. package/lib/tagged-fenced-block-error.js +17 -0
  44. package/lib/tagged-fenced-block-markdown.js +111 -0
  45. package/lib/tagged-fenced-block-metadata.js +97 -0
  46. package/lib/tagged-fenced-block-parser.js +292 -0
  47. package/lib/tagged-fenced-blocks.js +100 -0
  48. package/lib/tagged-fenced-blocks.types.ts +38 -0
  49. package/package.json +12 -7
@@ -1,9 +1,13 @@
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,
9
13
  normalizeRepoRelativePath,
@@ -25,7 +29,7 @@ import {
25
29
  * Uses Term: ../docs/reference/terms/graph.md
26
30
  * Uses Term: ../docs/reference/terms/mapping.md
27
31
  * Uses Term: ../docs/reference/terms/relation.md
28
- * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
32
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfood.md
29
33
  * Decided by: ../docs/decisions/graph-materialization.md
30
34
  * Implements: ../docs/tasks/v0/materialize-graph.md
31
35
  * @patram
@@ -33,6 +37,11 @@ import {
33
37
  * @see {@link ../docs/decisions/graph-materialization.md}
34
38
  */
35
39
 
40
+ const STRUCTURAL_FIELD_NAMES = new Set(['$class', '$id', '$path']);
41
+ const DETERMINISTIC_LOCALE = 'en';
42
+ const DERIVED_TITLE_PRIORITY = 1;
43
+ const EXPLICIT_TITLE_PRIORITY = 2;
44
+
36
45
  /**
37
46
  * Build a Patram graph from semantic config and parsed claims.
38
47
  *
@@ -47,20 +56,29 @@ export function buildGraph(patram_config, claims) {
47
56
  patram_config.mappings,
48
57
  claims,
49
58
  );
59
+ /** @type {Set<string>} */
60
+ const document_paths = new Set(
61
+ claims.map((claim) => normalizeRepoRelativePath(claim.origin.path)),
62
+ );
63
+ /** @type {Map<string, number>} */
64
+ const title_priorities = new Map();
50
65
 
51
66
  createDocumentNodes(graph_nodes, claims);
52
67
  applyNodeMappings(
53
68
  graph_nodes,
54
- patram_config.mappings,
69
+ patram_config,
55
70
  claims,
56
71
  document_entity_keys,
72
+ title_priorities,
57
73
  );
58
74
  const graph_edges = createGraphEdges(
59
75
  graph_nodes,
60
76
  patram_config.mappings,
61
77
  claims,
62
78
  document_entity_keys,
79
+ document_paths,
63
80
  );
81
+ applyFallbackTitles(graph_nodes, title_priorities);
64
82
 
65
83
  return {
66
84
  edges: graph_edges,
@@ -112,18 +130,23 @@ function createDocumentNodes(graph_nodes, claims) {
112
130
 
113
131
  /**
114
132
  * @param {Map<string, GraphNode>} graph_nodes
115
- * @param {Record<string, MappingDefinition>} mappings
133
+ * @param {PatramConfig} patram_config
116
134
  * @param {PatramClaim[]} claims
117
135
  * @param {Map<string, string>} document_entity_keys
136
+ * @param {Map<string, number>} title_priorities
118
137
  */
119
138
  function applyNodeMappings(
120
139
  graph_nodes,
121
- mappings,
140
+ patram_config,
122
141
  claims,
123
142
  document_entity_keys,
143
+ title_priorities,
124
144
  ) {
125
145
  for (const claim of claims) {
126
- const mapping_definition = resolveMappingDefinition(mappings, claim);
146
+ const mapping_definition = resolveMappingDefinition(
147
+ patram_config.mappings,
148
+ claim,
149
+ );
127
150
 
128
151
  if (!mapping_definition?.node) {
129
152
  continue;
@@ -131,9 +154,11 @@ function applyNodeMappings(
131
154
 
132
155
  applyNodeMapping(
133
156
  graph_nodes,
157
+ patram_config,
134
158
  mapping_definition.node,
135
159
  claim,
136
160
  document_entity_keys,
161
+ title_priorities,
137
162
  );
138
163
  }
139
164
  }
@@ -143,9 +168,16 @@ function applyNodeMappings(
143
168
  * @param {Record<string, MappingDefinition>} mappings
144
169
  * @param {PatramClaim[]} claims
145
170
  * @param {Map<string, string>} document_entity_keys
171
+ * @param {Set<string>} document_paths
146
172
  * @returns {GraphEdge[]}
147
173
  */
148
- function createGraphEdges(graph_nodes, mappings, claims, document_entity_keys) {
174
+ function createGraphEdges(
175
+ graph_nodes,
176
+ mappings,
177
+ claims,
178
+ document_entity_keys,
179
+ document_paths,
180
+ ) {
149
181
  /** @type {GraphEdge[]} */
150
182
  const graph_edges = [];
151
183
  let edge_number = 0;
@@ -163,14 +195,15 @@ function createGraphEdges(graph_nodes, mappings, claims, document_entity_keys) {
163
195
  normalizeRepoRelativePath(claim.origin.path),
164
196
  );
165
197
  const target_reference = resolveTargetReference(
166
- mapping_definition.emit.target_kind,
198
+ mapping_definition.emit.target_class,
167
199
  mapping_definition.emit.target,
168
200
  claim,
169
201
  document_entity_keys,
202
+ document_paths,
170
203
  );
171
204
  const target_node = upsertNode(
172
205
  graph_nodes,
173
- mapping_definition.emit.target_kind,
206
+ mapping_definition.emit.target_class,
174
207
  target_reference.key,
175
208
  );
176
209
 
@@ -191,23 +224,117 @@ function createGraphEdges(graph_nodes, mappings, claims, document_entity_keys) {
191
224
 
192
225
  /**
193
226
  * @param {Map<string, GraphNode>} graph_nodes
194
- * @param {{ field: string, key?: 'path' | 'value', kind: string }} node_mapping
227
+ * @param {PatramConfig} patram_config
228
+ * @param {{ field: string, key?: 'path' | 'value', class: string }} node_mapping
195
229
  * @param {PatramClaim} claim
196
230
  * @param {Map<string, string>} document_entity_keys
231
+ * @param {Map<string, number>} title_priorities
197
232
  */
198
233
  function applyNodeMapping(
199
234
  graph_nodes,
235
+ patram_config,
200
236
  node_mapping,
201
237
  claim,
202
238
  document_entity_keys,
239
+ title_priorities,
203
240
  ) {
204
241
  const source_key = normalizeRepoRelativePath(claim.origin.path);
205
242
  const node_key = resolveNodeKey(node_mapping, claim, document_entity_keys);
206
- const graph_node = upsertNode(graph_nodes, node_mapping.kind, node_key);
243
+ const graph_node = upsertNode(graph_nodes, node_mapping.class, node_key);
244
+ const field_value = getNodeFieldValue(claim);
207
245
 
208
246
  setNonDocumentPath(graph_node, source_key);
247
+ validateNodeFieldMapping(
248
+ patram_config,
249
+ node_mapping.class,
250
+ node_mapping.field,
251
+ );
209
252
 
210
- graph_node[node_mapping.field] = getNodeFieldValue(claim);
253
+ if (node_mapping.field === 'title') {
254
+ setNodeTitle(
255
+ graph_node,
256
+ title_priorities,
257
+ field_value,
258
+ claim.type === 'document.title'
259
+ ? DERIVED_TITLE_PRIORITY
260
+ : EXPLICIT_TITLE_PRIORITY,
261
+ );
262
+ return;
263
+ }
264
+
265
+ setNodeFieldValue(
266
+ graph_node,
267
+ node_mapping.field,
268
+ field_value,
269
+ getFieldDefinition(patram_config, node_mapping.field),
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Validate one mapped node field against the configured field model.
275
+ *
276
+ * @param {PatramConfig} patram_config
277
+ * @param {string} node_class
278
+ * @param {string} field_name
279
+ */
280
+ function validateNodeFieldMapping(patram_config, node_class, field_name) {
281
+ const validation_error = getNodeFieldValidationError(
282
+ patram_config,
283
+ node_class,
284
+ field_name,
285
+ );
286
+
287
+ if (validation_error) {
288
+ throw new Error(validation_error);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * @param {PatramConfig} patram_config
294
+ * @param {string} node_class
295
+ * @param {string} field_name
296
+ * @returns {string | null}
297
+ */
298
+ function getNodeFieldValidationError(patram_config, node_class, field_name) {
299
+ if (isStructuralFieldName(field_name) || field_name === 'title') {
300
+ return null;
301
+ }
302
+
303
+ const field_definition = getFieldDefinition(patram_config, field_name);
304
+
305
+ if (!field_definition) {
306
+ return `Node class "${node_class}" maps to unknown field "${field_name}".`;
307
+ }
308
+
309
+ const class_schema = getClassSchema(patram_config, node_class);
310
+ const class_field_rule = class_schema?.fields?.[field_name];
311
+
312
+ if (isForbiddenClassField(class_field_rule)) {
313
+ return `Field "${field_name}" is forbidden for class "${node_class}".`;
314
+ }
315
+
316
+ if (isUndeclaredClassField(class_schema, class_field_rule)) {
317
+ return `Field "${field_name}" is not declared for class "${node_class}".`;
318
+ }
319
+
320
+ return null;
321
+ }
322
+
323
+ /**
324
+ * @param {{ presence: 'required' | 'optional' | 'forbidden' } | undefined} class_field_rule
325
+ * @returns {boolean}
326
+ */
327
+ function isForbiddenClassField(class_field_rule) {
328
+ return class_field_rule?.presence === 'forbidden';
329
+ }
330
+
331
+ /**
332
+ * @param {{ fields?: Record<string, { presence: 'required' | 'optional' | 'forbidden' }>, unknown_fields?: 'ignore' | 'error' } | undefined} class_schema
333
+ * @param {{ presence: 'required' | 'optional' | 'forbidden' } | undefined} class_field_rule
334
+ * @returns {boolean}
335
+ */
336
+ function isUndeclaredClassField(class_schema, class_field_rule) {
337
+ return class_schema?.unknown_fields === 'error' && !class_field_rule;
211
338
  }
212
339
 
213
340
  /**
@@ -233,23 +360,26 @@ function upsertNode(graph_nodes, kind_name, node_key) {
233
360
 
234
361
  /**
235
362
  * @param {string} node_id
236
- * @param {string} kind_name
363
+ * @param {string} class_name
237
364
  * @param {string} node_key
238
365
  * @returns {GraphNode}
239
366
  */
240
- function createNode(node_id, kind_name, node_key) {
241
- if (kind_name === 'document') {
367
+ function createNode(node_id, class_name, node_key) {
368
+ if (class_name === 'document') {
242
369
  return {
370
+ $class: class_name,
371
+ $id: node_id,
372
+ $path: node_key,
243
373
  id: node_id,
244
- kind: kind_name,
245
374
  path: node_key,
246
375
  };
247
376
  }
248
377
 
249
378
  return {
379
+ $class: class_name,
380
+ $id: node_id,
250
381
  id: node_id,
251
382
  key: node_key,
252
- kind: kind_name,
253
383
  };
254
384
  }
255
385
 
@@ -278,11 +408,247 @@ function getNodeFieldValue(claim) {
278
408
  throw new Error(`Claim "${claim.id}" does not carry a string value.`);
279
409
  }
280
410
 
411
+ /**
412
+ * @param {Map<string, GraphNode>} graph_nodes
413
+ * @param {Map<string, number>} title_priorities
414
+ */
415
+ function applyFallbackTitles(graph_nodes, title_priorities) {
416
+ for (const graph_node of graph_nodes.values()) {
417
+ if (graph_node.title !== undefined) {
418
+ continue;
419
+ }
420
+
421
+ const fallback_title = getFallbackTitle(graph_node);
422
+
423
+ graph_node.title = fallback_title;
424
+ title_priorities.set(graph_node.id, 0);
425
+ }
426
+ }
427
+
428
+ /**
429
+ * @param {GraphNode} graph_node
430
+ * @returns {string}
431
+ */
432
+ function getFallbackTitle(graph_node) {
433
+ if (graph_node.$path) {
434
+ return posix.basename(graph_node.$path);
435
+ }
436
+
437
+ return getNodeIdKey(graph_node.$id ?? graph_node.id);
438
+ }
439
+
440
+ /**
441
+ * @param {GraphNode} graph_node
442
+ * @param {Map<string, number>} title_priorities
443
+ * @param {string} title_value
444
+ * @param {number} source_priority
445
+ */
446
+ function setNodeTitle(
447
+ graph_node,
448
+ title_priorities,
449
+ title_value,
450
+ source_priority,
451
+ ) {
452
+ const current_priority = title_priorities.get(graph_node.id);
453
+
454
+ if (current_priority === undefined) {
455
+ graph_node.title = title_value;
456
+ title_priorities.set(graph_node.id, source_priority);
457
+ return;
458
+ }
459
+
460
+ if (source_priority > current_priority) {
461
+ graph_node.title = title_value;
462
+ title_priorities.set(graph_node.id, source_priority);
463
+ return;
464
+ }
465
+
466
+ if (
467
+ source_priority === current_priority &&
468
+ graph_node.title !== title_value
469
+ ) {
470
+ throw new Error(
471
+ `Node "${graph_node.id}" has conflicting title values "${graph_node.title}" and "${title_value}".`,
472
+ );
473
+ }
474
+ }
475
+
476
+ /**
477
+ * @param {GraphNode} graph_node
478
+ * @param {string} field_name
479
+ * @param {string} field_value
480
+ * @param {MetadataFieldConfig | undefined} field_definition
481
+ */
482
+ function setNodeFieldValue(
483
+ graph_node,
484
+ field_name,
485
+ field_value,
486
+ field_definition,
487
+ ) {
488
+ if (
489
+ field_name === '$id' ||
490
+ field_name === '$class' ||
491
+ field_name === '$path'
492
+ ) {
493
+ setStructuralFieldValue(graph_node, field_name, field_value);
494
+ return;
495
+ }
496
+
497
+ if (!field_definition || field_definition.multiple !== true) {
498
+ setSingleValueField(graph_node, field_name, field_value);
499
+ return;
500
+ }
501
+
502
+ setMultiValueField(graph_node, field_name, field_value);
503
+ }
504
+
505
+ /**
506
+ * @param {GraphNode} graph_node
507
+ * @param {'$id' | '$class' | '$path'} field_name
508
+ * @param {string} field_value
509
+ */
510
+ function setStructuralFieldValue(graph_node, field_name, field_value) {
511
+ const current_value = graph_node[field_name];
512
+
513
+ if (current_value === undefined) {
514
+ assignStructuralFieldValue(graph_node, field_name, field_value);
515
+ return;
516
+ }
517
+
518
+ if (
519
+ field_name === '$class' &&
520
+ graph_node.id.startsWith('doc:') &&
521
+ current_value === 'document'
522
+ ) {
523
+ assignStructuralFieldValue(graph_node, field_name, field_value);
524
+ return;
525
+ }
526
+
527
+ if (current_value !== field_value) {
528
+ throw new Error(
529
+ `Node "${graph_node.id}" has conflicting structural values for "${field_name}".`,
530
+ );
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Keep legacy mirrors in sync while the rest of the codebase still reads them.
536
+ *
537
+ * @param {GraphNode} graph_node
538
+ * @param {'$id' | '$class' | '$path'} field_name
539
+ * @param {string} field_value
540
+ */
541
+ function assignStructuralFieldValue(graph_node, field_name, field_value) {
542
+ graph_node[field_name] = field_value;
543
+
544
+ if (field_name === '$path') {
545
+ graph_node.path = field_value;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * @param {GraphNode} graph_node
551
+ * @param {string} field_name
552
+ * @param {string} field_value
553
+ */
554
+ function setSingleValueField(graph_node, field_name, field_value) {
555
+ const current_value = graph_node[field_name];
556
+
557
+ if (current_value === undefined) {
558
+ graph_node[field_name] = field_value;
559
+ return;
560
+ }
561
+
562
+ if (current_value !== field_value) {
563
+ throw new Error(
564
+ `Node "${graph_node.id}" has conflicting values for field "${field_name}": "${current_value}" and "${field_value}".`,
565
+ );
566
+ }
567
+ }
568
+
569
+ /**
570
+ * @param {GraphNode} graph_node
571
+ * @param {string} field_name
572
+ * @param {string} field_value
573
+ */
574
+ function setMultiValueField(graph_node, field_name, field_value) {
575
+ const current_value = graph_node[field_name];
576
+
577
+ if (current_value === undefined) {
578
+ graph_node[field_name] = [field_value];
579
+ return;
580
+ }
581
+
582
+ if (Array.isArray(current_value)) {
583
+ if (current_value.includes(field_value)) {
584
+ return;
585
+ }
586
+
587
+ graph_node[field_name] = [...current_value, field_value].sort(
588
+ compareFieldValues,
589
+ );
590
+ return;
591
+ }
592
+
593
+ graph_node[field_name] = [current_value, field_value].sort(
594
+ compareFieldValues,
595
+ );
596
+ }
597
+
598
+ /**
599
+ * @param {string} left_value
600
+ * @param {string} right_value
601
+ * @returns {number}
602
+ */
603
+ function compareFieldValues(left_value, right_value) {
604
+ return left_value.localeCompare(right_value, DETERMINISTIC_LOCALE);
605
+ }
606
+
607
+ /**
608
+ * @param {PatramConfig} patram_config
609
+ * @param {string} field_name
610
+ * @returns {MetadataFieldConfig | undefined}
611
+ */
612
+ function getFieldDefinition(patram_config, field_name) {
613
+ return patram_config.fields?.[field_name];
614
+ }
615
+
616
+ /**
617
+ * @param {PatramConfig} patram_config
618
+ * @param {string} class_name
619
+ * @returns {{ fields?: Record<string, { presence: 'required' | 'optional' | 'forbidden' }>, unknown_fields?: 'ignore' | 'error' } | undefined}
620
+ */
621
+ function getClassSchema(patram_config, class_name) {
622
+ return patram_config.class_schemas?.[class_name];
623
+ }
624
+
625
+ /**
626
+ * @param {string} field_name
627
+ * @returns {field_name is '$class' | '$id' | '$path'}
628
+ */
629
+ function isStructuralFieldName(field_name) {
630
+ return STRUCTURAL_FIELD_NAMES.has(field_name);
631
+ }
632
+
281
633
  /**
282
634
  * @param {[string, GraphNode]} left_entry
283
635
  * @param {[string, GraphNode]} right_entry
284
636
  * @returns {number}
285
637
  */
286
638
  function compareNodeEntries(left_entry, right_entry) {
287
- return left_entry[0].localeCompare(right_entry[0], 'en');
639
+ return left_entry[0].localeCompare(right_entry[0], DETERMINISTIC_LOCALE);
640
+ }
641
+
642
+ /**
643
+ * @param {string} node_id
644
+ * @returns {string}
645
+ */
646
+ function getNodeIdKey(node_id) {
647
+ const separator_index = node_id.indexOf(':');
648
+
649
+ if (separator_index < 0) {
650
+ return node_id;
651
+ }
652
+
653
+ return node_id.slice(separator_index + 1);
288
654
  }
@@ -1,12 +1,15 @@
1
1
  import type { ClaimOrigin } from './parse-claims.types.ts';
2
2
 
3
3
  export interface GraphNode {
4
+ $class?: string;
5
+ $id?: string;
6
+ $path?: string;
4
7
  id: string;
5
- kind: string;
8
+ kind?: string;
6
9
  key?: string;
7
10
  path?: string;
8
11
  title?: string;
9
- [field: string]: string | undefined;
12
+ [field: string]: string | string[] | undefined;
10
13
  }
11
14
 
12
15
  export interface GraphEdge {