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,6 +1,12 @@
1
1
  /* eslint-disable max-lines */
2
2
  /**
3
- * @import { LoadPatramConfigResult, PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
3
+ * @import {
4
+ * ClassSchemaConfig,
5
+ * LoadPatramConfigResult,
6
+ * MetadataFieldConfig,
7
+ * PatramDiagnostic,
8
+ * PatramRepoConfig,
9
+ * } from './load-patram-config.types.ts';
4
10
  */
5
11
 
6
12
  import { readFile } from 'node:fs/promises';
@@ -10,6 +16,9 @@ import process from 'node:process';
10
16
  import { z } from 'zod';
11
17
 
12
18
  import { parsePatramConfig } from './patram-config.js';
19
+ import { parseWhereClause } from './parse-where-clause.js';
20
+ import { getQuerySemanticDiagnostics } from './query-inspection.js';
21
+ import { resolvePatramGraphConfig } from './resolve-patram-graph-config.js';
13
22
  import { DEFAULT_INCLUDE_PATTERNS } from './source-file-defaults.js';
14
23
 
15
24
  /**
@@ -29,6 +38,7 @@ import { DEFAULT_INCLUDE_PATTERNS } from './source-file-defaults.js';
29
38
  */
30
39
 
31
40
  const CONFIG_FILE_NAME = '.patram.json';
41
+ const RESERVED_STRUCTURAL_FIELD_NAMES = new Set(['$class', '$id', '$path']);
32
42
 
33
43
  const stored_query_schema = z
34
44
  .object({
@@ -36,18 +46,168 @@ const stored_query_schema = z
36
46
  })
37
47
  .strict();
38
48
 
49
+ const derived_summary_scalar_schema = z.union([
50
+ z.boolean(),
51
+ z.number(),
52
+ z.string(),
53
+ z.null(),
54
+ ]);
55
+
56
+ const derived_summary_count_schema = z
57
+ .object({
58
+ traversal: z
59
+ .string()
60
+ .min(1, 'Derived summary count "traversal" must not be empty.'),
61
+ where: z
62
+ .string()
63
+ .min(1, 'Derived summary count "where" must not be empty.'),
64
+ })
65
+ .strict();
66
+
67
+ const derived_summary_select_case_schema = z
68
+ .object({
69
+ value: derived_summary_scalar_schema,
70
+ when: z.string().min(1, 'Derived summary select "when" must not be empty.'),
71
+ })
72
+ .strict();
73
+
74
+ const derived_summary_field_schema = z
75
+ .object({
76
+ count: derived_summary_count_schema.optional(),
77
+ default: derived_summary_scalar_schema.optional(),
78
+ name: z
79
+ .string()
80
+ .regex(
81
+ /^[a-z][a-z0-9_]*$/du,
82
+ 'Derived summary field names must use lower_snake_case.',
83
+ ),
84
+ select: z.array(derived_summary_select_case_schema).optional(),
85
+ })
86
+ .strict()
87
+ .superRefine(validateDerivedSummaryFieldDefinition);
88
+
89
+ const derived_summary_schema = z
90
+ .object({
91
+ classes: z
92
+ .array(z.string().min(1))
93
+ .min(1, 'Derived summary "classes" must contain at least one class.'),
94
+ fields: z
95
+ .array(derived_summary_field_schema)
96
+ .min(1, 'Derived summary "fields" must contain at least one field.'),
97
+ })
98
+ .strict()
99
+ .superRefine(validateDerivedSummaryDefinition);
100
+
101
+ const field_display_schema = z
102
+ .object({
103
+ hidden: z.boolean().optional(),
104
+ order: z.number().optional(),
105
+ })
106
+ .strict();
107
+
108
+ const field_query_schema = z
109
+ .object({
110
+ contains: z.boolean().optional(),
111
+ prefix: z.boolean().optional(),
112
+ })
113
+ .strict();
114
+
115
+ const field_base_shape = {
116
+ display: field_display_schema.optional(),
117
+ multiple: z.boolean().optional(),
118
+ path_class: z.string().min(1).optional(),
119
+ };
120
+
121
+ const metadata_field_schema = z.discriminatedUnion('type', [
122
+ z
123
+ .object({
124
+ ...field_base_shape,
125
+ query: field_query_schema.optional(),
126
+ type: z.literal('string'),
127
+ })
128
+ .strict(),
129
+ z
130
+ .object({
131
+ ...field_base_shape,
132
+ type: z.literal('integer'),
133
+ })
134
+ .strict(),
135
+ z
136
+ .object({
137
+ ...field_base_shape,
138
+ type: z.literal('enum'),
139
+ values: z
140
+ .array(z.string().min(1, 'Field enum values must not be empty.'))
141
+ .min(1, 'Field enum values must contain at least one value.'),
142
+ })
143
+ .strict(),
144
+ z
145
+ .object({
146
+ ...field_base_shape,
147
+ type: z.literal('path'),
148
+ })
149
+ .strict(),
150
+ z
151
+ .object({
152
+ ...field_base_shape,
153
+ type: z.literal('glob'),
154
+ })
155
+ .strict(),
156
+ z
157
+ .object({
158
+ ...field_base_shape,
159
+ type: z.literal('date'),
160
+ })
161
+ .strict(),
162
+ z
163
+ .object({
164
+ ...field_base_shape,
165
+ type: z.literal('date_time'),
166
+ })
167
+ .strict(),
168
+ ]);
169
+
170
+ const class_field_rule_schema = z
171
+ .object({
172
+ presence: z.enum(['required', 'optional', 'forbidden']),
173
+ })
174
+ .strict();
175
+
176
+ const class_schema_schema = z
177
+ .object({
178
+ document_path_class: z.string().min(1).optional(),
179
+ fields: z.record(z.string().min(1), class_field_rule_schema).default({}),
180
+ unknown_fields: z.enum(['ignore', 'error']).optional(),
181
+ })
182
+ .strict();
183
+
184
+ const path_class_schema = z
185
+ .object({
186
+ prefixes: z
187
+ .array(z.string().min(1, 'Path class prefixes must not be empty.'))
188
+ .min(1, 'Path classes must contain at least one prefix.'),
189
+ })
190
+ .strict();
191
+
39
192
  const patram_repo_config_schema = z
40
193
  .object({
194
+ class_schemas: z.record(z.string().min(1), class_schema_schema).optional(),
195
+ classes: z.unknown().optional(),
196
+ derived_summaries: z
197
+ .record(z.string().min(1), derived_summary_schema)
198
+ .optional(),
199
+ fields: z.record(z.string().min(1), metadata_field_schema).optional(),
41
200
  include: z
42
201
  .array(z.string().min(1, 'Include globs must not be empty.'))
43
202
  .min(1, 'Include must contain at least one glob.')
44
203
  .default(DEFAULT_INCLUDE_PATTERNS),
45
- kinds: z.unknown().optional(),
46
204
  mappings: z.unknown().optional(),
205
+ path_classes: z.record(z.string().min(1), path_class_schema).optional(),
47
206
  queries: z.record(z.string().min(1), stored_query_schema).default({}),
48
207
  relations: z.unknown().optional(),
49
208
  })
50
- .strict();
209
+ .strict()
210
+ .superRefine(validateFieldDefinitionKeys);
51
211
 
52
212
  /**
53
213
  * Load and validate the repo Patram config.
@@ -84,7 +244,27 @@ export async function loadPatramConfig(project_directory = process.cwd()) {
84
244
  return createLoadResult(null, graph_schema_diagnostics);
85
245
  }
86
246
 
87
- return createLoadResult(normalizeRepoConfig(config_result.data), []);
247
+ const normalized_config = normalizeRepoConfig(config_result.data);
248
+ const field_schema_diagnostics = validateFieldSchemaConfig(normalized_config);
249
+
250
+ if (field_schema_diagnostics.length > 0) {
251
+ return createLoadResult(null, field_schema_diagnostics);
252
+ }
253
+
254
+ const stored_query_diagnostics = validateStoredQueries(normalized_config);
255
+
256
+ if (stored_query_diagnostics.length > 0) {
257
+ return createLoadResult(null, stored_query_diagnostics);
258
+ }
259
+
260
+ const derived_summary_diagnostics =
261
+ validateDerivedSummaries(normalized_config);
262
+
263
+ if (derived_summary_diagnostics.length > 0) {
264
+ return createLoadResult(null, derived_summary_diagnostics);
265
+ }
266
+
267
+ return createLoadResult(normalized_config, []);
88
268
  }
89
269
 
90
270
  /**
@@ -185,12 +365,112 @@ function createValidationDiagnostic(issue) {
185
365
  }
186
366
 
187
367
  /**
188
- * @param {{ include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
368
+ * @param {{ fields?: Record<string, MetadataFieldConfig> }} repo_config
369
+ * @param {import('zod').RefinementCtx} refinement_context
370
+ */
371
+ function validateFieldDefinitionKeys(repo_config, refinement_context) {
372
+ for (const field_name of Object.keys(repo_config.fields ?? {})) {
373
+ if (!field_name.startsWith('$')) {
374
+ continue;
375
+ }
376
+
377
+ refinement_context.addIssue({
378
+ code: 'custom',
379
+ message: 'Metadata field names must not start with "$".',
380
+ path: ['fields', field_name],
381
+ });
382
+ }
383
+ }
384
+
385
+ /**
386
+ * @param {{ count?: unknown, default?: unknown, select?: unknown }} field_definition
387
+ * @param {import('zod').RefinementCtx} refinement_context
388
+ */
389
+ function validateDerivedSummaryFieldDefinition(
390
+ field_definition,
391
+ refinement_context,
392
+ ) {
393
+ const evaluator_count =
394
+ Number(field_definition.count !== undefined) +
395
+ Number(field_definition.select !== undefined);
396
+
397
+ if (evaluator_count !== 1) {
398
+ refinement_context.addIssue({
399
+ code: 'custom',
400
+ message:
401
+ 'Derived summary fields must define exactly one of "count" or "select".',
402
+ });
403
+ }
404
+
405
+ if (
406
+ field_definition.count !== undefined &&
407
+ field_definition.default !== undefined
408
+ ) {
409
+ refinement_context.addIssue({
410
+ code: 'custom',
411
+ message: 'Derived summary count fields must not define "default".',
412
+ path: ['default'],
413
+ });
414
+ }
415
+
416
+ if (field_definition.select === undefined) {
417
+ return;
418
+ }
419
+
420
+ if (
421
+ Array.isArray(field_definition.select) &&
422
+ field_definition.select.length === 0
423
+ ) {
424
+ refinement_context.addIssue({
425
+ code: 'custom',
426
+ message: 'Derived summary "select" must contain at least one case.',
427
+ path: ['select'],
428
+ });
429
+ }
430
+
431
+ if (field_definition.default === undefined) {
432
+ refinement_context.addIssue({
433
+ code: 'custom',
434
+ message: 'Derived summary select fields must define "default".',
435
+ path: ['default'],
436
+ });
437
+ }
438
+ }
439
+
440
+ /**
441
+ * @param {{ fields: Array<{ name: string }> }} summary_definition
442
+ * @param {import('zod').RefinementCtx} refinement_context
443
+ */
444
+ function validateDerivedSummaryDefinition(
445
+ summary_definition,
446
+ refinement_context,
447
+ ) {
448
+ const seen_field_names = new Set();
449
+
450
+ for (const [
451
+ field_index,
452
+ field_definition,
453
+ ] of summary_definition.fields.entries()) {
454
+ if (!seen_field_names.has(field_definition.name)) {
455
+ seen_field_names.add(field_definition.name);
456
+ continue;
457
+ }
458
+
459
+ refinement_context.addIssue({
460
+ code: 'custom',
461
+ message: `Duplicate derived summary field "${field_definition.name}".`,
462
+ path: ['fields', field_index, 'name'],
463
+ });
464
+ }
465
+ }
466
+
467
+ /**
468
+ * @param {{ include: string[], queries: Record<string, { where: string }>, classes?: unknown, mappings?: unknown, relations?: unknown }} repo_config
189
469
  * @returns {PatramDiagnostic[]}
190
470
  */
191
471
  function validateGraphSchema(repo_config) {
192
472
  if (
193
- repo_config.kinds === undefined &&
473
+ repo_config.classes === undefined &&
194
474
  repo_config.mappings === undefined &&
195
475
  repo_config.relations === undefined
196
476
  ) {
@@ -199,7 +479,7 @@ function validateGraphSchema(repo_config) {
199
479
 
200
480
  try {
201
481
  parsePatramConfig({
202
- kinds: repo_config.kinds ?? {},
482
+ classes: repo_config.classes ?? {},
203
483
  mappings: repo_config.mappings ?? {},
204
484
  relations: repo_config.relations ?? {},
205
485
  });
@@ -214,6 +494,87 @@ function validateGraphSchema(repo_config) {
214
494
  return [];
215
495
  }
216
496
 
497
+ /**
498
+ * @param {PatramRepoConfig} repo_config
499
+ * @returns {PatramDiagnostic[]}
500
+ */
501
+ function validateFieldSchemaConfig(repo_config) {
502
+ const path_classes = repo_config.path_classes ?? {};
503
+ const classes = repo_config.classes ?? {};
504
+ const fields = repo_config.fields ?? {};
505
+ /** @type {PatramDiagnostic[]} */
506
+ const diagnostics = [];
507
+
508
+ collectFieldConfigDiagnostics(diagnostics, path_classes, fields);
509
+ collectClassSchemaConfigDiagnostics(
510
+ diagnostics,
511
+ path_classes,
512
+ classes,
513
+ fields,
514
+ repo_config.class_schemas,
515
+ );
516
+
517
+ return diagnostics;
518
+ }
519
+
520
+ /**
521
+ * @param {PatramRepoConfig} repo_config
522
+ * @returns {PatramDiagnostic[]}
523
+ */
524
+ function validateStoredQueries(repo_config) {
525
+ /** @type {PatramDiagnostic[]} */
526
+ const diagnostics = [];
527
+
528
+ for (const [query_name, stored_query] of Object.entries(
529
+ repo_config.queries,
530
+ )) {
531
+ collectWhereClauseDiagnostics(
532
+ diagnostics,
533
+ repo_config,
534
+ stored_query.where,
535
+ `queries.${query_name}.where`,
536
+ );
537
+ }
538
+
539
+ return diagnostics;
540
+ }
541
+
542
+ /**
543
+ * @param {PatramRepoConfig} repo_config
544
+ * @returns {PatramDiagnostic[]}
545
+ */
546
+ function validateDerivedSummaries(repo_config) {
547
+ if (!repo_config.derived_summaries) {
548
+ return [];
549
+ }
550
+
551
+ const graph_config = resolvePatramGraphConfig(repo_config);
552
+ const known_relation_names = new Set(Object.keys(graph_config.relations));
553
+ /** @type {PatramDiagnostic[]} */
554
+ const diagnostics = [];
555
+ const class_coverage = new Map();
556
+
557
+ for (const [summary_name, summary_definition] of Object.entries(
558
+ repo_config.derived_summaries,
559
+ )) {
560
+ collectDuplicateClassDiagnostics(
561
+ diagnostics,
562
+ class_coverage,
563
+ summary_definition.classes,
564
+ summary_name,
565
+ );
566
+ collectDerivedSummaryFieldDiagnostics(
567
+ diagnostics,
568
+ known_relation_names,
569
+ repo_config,
570
+ summary_name,
571
+ summary_definition.fields,
572
+ );
573
+ }
574
+
575
+ return diagnostics;
576
+ }
577
+
217
578
  /**
218
579
  * @returns {PatramRepoConfig}
219
580
  */
@@ -225,7 +586,7 @@ function createDefaultRepoConfig() {
225
586
  }
226
587
 
227
588
  /**
228
- * @param {{ include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
589
+ * @param {{ class_schemas?: unknown, classes?: unknown, derived_summaries?: unknown, fields?: unknown, include: string[], mappings?: unknown, path_classes?: unknown, queries: Record<string, { where: string }>, relations?: unknown }} repo_config
229
590
  * @returns {PatramRepoConfig}
230
591
  */
231
592
  function normalizeRepoConfig(repo_config) {
@@ -235,25 +596,400 @@ function normalizeRepoConfig(repo_config) {
235
596
  queries: { ...repo_config.queries },
236
597
  };
237
598
 
238
- if (repo_config.kinds !== undefined && repo_config.kinds !== null) {
239
- normalized_config.kinds = /** @type {PatramRepoConfig['kinds']} */ (
240
- repo_config.kinds
599
+ assignOptionalRepoConfigField(
600
+ normalized_config,
601
+ 'class_schemas',
602
+ repo_config.class_schemas,
603
+ );
604
+ assignOptionalRepoConfigField(
605
+ normalized_config,
606
+ 'classes',
607
+ repo_config.classes,
608
+ );
609
+ assignOptionalRepoConfigField(
610
+ normalized_config,
611
+ 'derived_summaries',
612
+ repo_config.derived_summaries,
613
+ );
614
+ assignOptionalRepoConfigField(
615
+ normalized_config,
616
+ 'fields',
617
+ repo_config.fields,
618
+ );
619
+ assignOptionalRepoConfigField(
620
+ normalized_config,
621
+ 'mappings',
622
+ repo_config.mappings,
623
+ );
624
+ assignOptionalRepoConfigField(
625
+ normalized_config,
626
+ 'path_classes',
627
+ repo_config.path_classes,
628
+ );
629
+ assignOptionalRepoConfigField(
630
+ normalized_config,
631
+ 'relations',
632
+ repo_config.relations,
633
+ );
634
+
635
+ return normalized_config;
636
+ }
637
+
638
+ /**
639
+ * @param {PatramDiagnostic[]} diagnostics
640
+ * @param {Map<string, string>} class_coverage
641
+ * @param {string[]} class_names
642
+ * @param {string} summary_name
643
+ */
644
+ function collectDuplicateClassDiagnostics(
645
+ diagnostics,
646
+ class_coverage,
647
+ class_names,
648
+ summary_name,
649
+ ) {
650
+ for (const class_name of class_names) {
651
+ const existing_summary_name = class_coverage.get(class_name);
652
+
653
+ if (!existing_summary_name) {
654
+ class_coverage.set(class_name, summary_name);
655
+ continue;
656
+ }
657
+
658
+ diagnostics.push(
659
+ createConfigDiagnostic(
660
+ `derived_summaries.${summary_name}.classes`,
661
+ `Class "${class_name}" is already covered by derived summary "${existing_summary_name}".`,
662
+ ),
241
663
  );
242
664
  }
665
+ }
666
+
667
+ /**
668
+ * @param {PatramDiagnostic[]} diagnostics
669
+ * @param {Set<string>} known_relation_names
670
+ * @param {PatramRepoConfig} repo_config
671
+ * @param {string} summary_name
672
+ * @param {import('./load-patram-config.types.ts').DerivedSummaryFieldConfig[]} field_definitions
673
+ */
674
+ function collectDerivedSummaryFieldDiagnostics(
675
+ diagnostics,
676
+ known_relation_names,
677
+ repo_config,
678
+ summary_name,
679
+ field_definitions,
680
+ ) {
681
+ for (const [field_index, field_definition] of field_definitions.entries()) {
682
+ if ('count' in field_definition) {
683
+ collectTraversalDiagnostic(
684
+ diagnostics,
685
+ field_definition.count.traversal,
686
+ known_relation_names,
687
+ `derived_summaries.${summary_name}.fields.${field_index}.count.traversal`,
688
+ );
689
+ collectWhereClauseDiagnostics(
690
+ diagnostics,
691
+ repo_config,
692
+ field_definition.count.where,
693
+ `derived_summaries.${summary_name}.fields.${field_index}.count.where`,
694
+ );
695
+ continue;
696
+ }
243
697
 
244
- if (repo_config.mappings !== undefined && repo_config.mappings !== null) {
245
- normalized_config.mappings = /** @type {PatramRepoConfig['mappings']} */ (
246
- repo_config.mappings
698
+ for (const [case_index, select_case] of field_definition.select.entries()) {
699
+ collectWhereClauseDiagnostics(
700
+ diagnostics,
701
+ repo_config,
702
+ select_case.when,
703
+ `derived_summaries.${summary_name}.fields.${field_index}.select.${case_index}.when`,
704
+ );
705
+ }
706
+ }
707
+ }
708
+
709
+ /**
710
+ * @param {PatramDiagnostic[]} diagnostics
711
+ * @param {string} traversal_text
712
+ * @param {Set<string>} known_relation_names
713
+ * @param {string} diagnostic_path
714
+ */
715
+ function collectTraversalDiagnostic(
716
+ diagnostics,
717
+ traversal_text,
718
+ known_relation_names,
719
+ diagnostic_path,
720
+ ) {
721
+ const traversal_match =
722
+ /^(?<direction>in|out):(?<relation_name>[a-zA-Z0-9_]+)$/du.exec(
723
+ traversal_text,
247
724
  );
725
+
726
+ if (!traversal_match?.groups?.relation_name) {
727
+ diagnostics.push(
728
+ createConfigDiagnostic(
729
+ diagnostic_path,
730
+ 'Derived summary traversal must use "in:<relation>" or "out:<relation>".',
731
+ ),
732
+ );
733
+
734
+ return;
248
735
  }
249
736
 
250
- if (repo_config.relations !== undefined && repo_config.relations !== null) {
251
- normalized_config.relations = /** @type {PatramRepoConfig['relations']} */ (
252
- repo_config.relations
737
+ if (known_relation_names.has(traversal_match.groups.relation_name)) {
738
+ return;
739
+ }
740
+
741
+ diagnostics.push(
742
+ createConfigDiagnostic(
743
+ diagnostic_path,
744
+ `Unknown relation "${traversal_match.groups.relation_name}" in derived summary traversal.`,
745
+ ),
746
+ );
747
+ }
748
+
749
+ /**
750
+ * @param {PatramDiagnostic[]} diagnostics
751
+ * @param {Record<string, { prefixes: string[] }>} path_classes
752
+ * @param {Record<string, MetadataFieldConfig>} fields
753
+ */
754
+ function collectFieldConfigDiagnostics(diagnostics, path_classes, fields) {
755
+ for (const [field_name, field_definition] of Object.entries(fields)) {
756
+ if (collectReservedFieldDiagnostic(diagnostics, field_name)) {
757
+ continue;
758
+ }
759
+
760
+ collectDisplayOrderDiagnostic(diagnostics, field_name, field_definition);
761
+ collectFieldPathClassDiagnostic(
762
+ diagnostics,
763
+ path_classes,
764
+ field_name,
765
+ field_definition,
253
766
  );
254
767
  }
768
+ }
255
769
 
256
- return normalized_config;
770
+ /**
771
+ * @param {PatramDiagnostic[]} diagnostics
772
+ * @param {string} field_name
773
+ * @returns {boolean}
774
+ */
775
+ function collectReservedFieldDiagnostic(diagnostics, field_name) {
776
+ if (
777
+ !field_name.startsWith('$') ||
778
+ !RESERVED_STRUCTURAL_FIELD_NAMES.has(field_name)
779
+ ) {
780
+ return false;
781
+ }
782
+
783
+ diagnostics.push(
784
+ createConfigDiagnostic(
785
+ `fields.${field_name}`,
786
+ 'Metadata field names must not start with "$".',
787
+ ),
788
+ );
789
+
790
+ return true;
791
+ }
792
+
793
+ /**
794
+ * @param {PatramDiagnostic[]} diagnostics
795
+ * @param {string} field_name
796
+ * @param {MetadataFieldConfig} field_definition
797
+ */
798
+ function collectDisplayOrderDiagnostic(
799
+ diagnostics,
800
+ field_name,
801
+ field_definition,
802
+ ) {
803
+ if (
804
+ field_definition.display?.order === undefined ||
805
+ (Number.isInteger(field_definition.display.order) &&
806
+ field_definition.display.order >= 0)
807
+ ) {
808
+ return;
809
+ }
810
+
811
+ diagnostics.push(
812
+ createConfigDiagnostic(
813
+ `fields.${field_name}.display.order`,
814
+ 'Display order must be a non-negative integer.',
815
+ ),
816
+ );
817
+ }
818
+
819
+ /**
820
+ * @param {PatramDiagnostic[]} diagnostics
821
+ * @param {Record<string, { prefixes: string[] }>} path_classes
822
+ * @param {string} field_name
823
+ * @param {MetadataFieldConfig} field_definition
824
+ */
825
+ function collectFieldPathClassDiagnostic(
826
+ diagnostics,
827
+ path_classes,
828
+ field_name,
829
+ field_definition,
830
+ ) {
831
+ if (
832
+ !('path_class' in field_definition) ||
833
+ field_definition.path_class === undefined
834
+ ) {
835
+ return;
836
+ }
837
+
838
+ if (field_definition.type !== 'path') {
839
+ diagnostics.push(
840
+ createConfigDiagnostic(
841
+ `fields.${field_name}.path_class`,
842
+ 'Path classes are only valid for path fields.',
843
+ ),
844
+ );
845
+
846
+ return;
847
+ }
848
+
849
+ if (path_classes[field_definition.path_class]) {
850
+ return;
851
+ }
852
+
853
+ diagnostics.push(
854
+ createConfigDiagnostic(
855
+ `fields.${field_name}.path_class`,
856
+ `Unknown path class "${field_definition.path_class}".`,
857
+ ),
858
+ );
859
+ }
860
+
861
+ /**
862
+ * @param {PatramDiagnostic[]} diagnostics
863
+ * @param {Record<string, { prefixes: string[] }>} path_classes
864
+ * @param {Record<string, unknown>} classes
865
+ * @param {Record<string, MetadataFieldConfig>} fields
866
+ * @param {PatramRepoConfig['class_schemas']} class_schemas
867
+ */
868
+ function collectClassSchemaConfigDiagnostics(
869
+ diagnostics,
870
+ path_classes,
871
+ classes,
872
+ fields,
873
+ class_schemas,
874
+ ) {
875
+ if (!class_schemas) {
876
+ return;
877
+ }
878
+
879
+ for (const class_name of Object.keys(class_schemas)) {
880
+ if (classes[class_name]) {
881
+ continue;
882
+ }
883
+
884
+ diagnostics.push(
885
+ createConfigDiagnostic(
886
+ `class_schemas.${class_name}`,
887
+ `Unknown class "${class_name}".`,
888
+ ),
889
+ );
890
+ }
891
+
892
+ for (const [class_name, schema_definition] of Object.entries(class_schemas)) {
893
+ for (const field_name of Object.keys(schema_definition.fields)) {
894
+ if (fields[field_name]) {
895
+ continue;
896
+ }
897
+
898
+ diagnostics.push(
899
+ createConfigDiagnostic(
900
+ `class_schemas.${class_name}.fields.${field_name}`,
901
+ `Unknown field "${field_name}".`,
902
+ ),
903
+ );
904
+ }
905
+ }
906
+
907
+ for (const [class_name, schema_definition] of Object.entries(class_schemas)) {
908
+ if (
909
+ schema_definition.document_path_class === undefined ||
910
+ path_classes[schema_definition.document_path_class]
911
+ ) {
912
+ continue;
913
+ }
914
+
915
+ diagnostics.push(
916
+ createConfigDiagnostic(
917
+ `class_schemas.${class_name}.document_path_class`,
918
+ `Unknown path class "${schema_definition.document_path_class}".`,
919
+ ),
920
+ );
921
+ }
922
+ }
923
+
924
+ /**
925
+ * @template {Exclude<keyof PatramRepoConfig, 'include' | 'queries'>} TKey
926
+ * @param {PatramRepoConfig} normalized_config
927
+ * @param {TKey} field_name
928
+ * @param {unknown} field_value
929
+ */
930
+ function assignOptionalRepoConfigField(
931
+ normalized_config,
932
+ field_name,
933
+ field_value,
934
+ ) {
935
+ if (field_value === undefined || field_value === null) {
936
+ return;
937
+ }
938
+
939
+ normalized_config[field_name] = /** @type {PatramRepoConfig[TKey]} */ (
940
+ field_value
941
+ );
942
+ }
943
+
944
+ /**
945
+ * @param {PatramDiagnostic[]} diagnostics
946
+ * @param {PatramRepoConfig} repo_config
947
+ * @param {string} where_clause
948
+ * @param {string} diagnostic_path
949
+ */
950
+ function collectWhereClauseDiagnostics(
951
+ diagnostics,
952
+ repo_config,
953
+ where_clause,
954
+ diagnostic_path,
955
+ ) {
956
+ const parse_result = parseWhereClause(where_clause);
957
+
958
+ if (!parse_result.success) {
959
+ diagnostics.push(
960
+ createConfigDiagnostic(diagnostic_path, parse_result.diagnostic.message),
961
+ );
962
+
963
+ return;
964
+ }
965
+
966
+ const semantic_diagnostics = getQuerySemanticDiagnostics(
967
+ repo_config,
968
+ { kind: 'ad_hoc' },
969
+ parse_result.clauses,
970
+ );
971
+
972
+ for (const semantic_diagnostic of semantic_diagnostics) {
973
+ diagnostics.push(
974
+ createConfigDiagnostic(diagnostic_path, semantic_diagnostic.message),
975
+ );
976
+ }
977
+ }
978
+
979
+ /**
980
+ * @param {string} issue_path
981
+ * @param {string} message
982
+ * @returns {PatramDiagnostic}
983
+ */
984
+ function createConfigDiagnostic(issue_path, message) {
985
+ return {
986
+ code: 'config.invalid',
987
+ column: 1,
988
+ level: 'error',
989
+ line: 1,
990
+ message: `Invalid config at "${issue_path}": ${message}`,
991
+ path: CONFIG_FILE_NAME,
992
+ };
257
993
  }
258
994
 
259
995
  /**