patram 0.1.1 → 0.2.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 (35) hide show
  1. package/lib/build-graph-identity.js +39 -7
  2. package/lib/build-graph.js +14 -1
  3. package/lib/cli-help-metadata.js +552 -0
  4. package/lib/derived-summary.js +278 -0
  5. package/lib/format-derived-summary-row.js +9 -0
  6. package/lib/format-node-header.js +19 -0
  7. package/lib/format-output-item-block.js +22 -0
  8. package/lib/format-output-metadata.js +62 -0
  9. package/lib/layout-stored-queries.js +150 -2
  10. package/lib/load-patram-config.js +401 -2
  11. package/lib/load-patram-config.types.ts +31 -0
  12. package/lib/output-view.types.ts +15 -0
  13. package/lib/parse-cli-arguments-helpers.js +263 -90
  14. package/lib/parse-cli-arguments.js +160 -8
  15. package/lib/parse-cli-arguments.types.ts +48 -3
  16. package/lib/parse-where-clause.js +604 -209
  17. package/lib/parse-where-clause.types.ts +70 -0
  18. package/lib/patram-cli.js +144 -17
  19. package/lib/patram.js +6 -0
  20. package/lib/query-graph.js +231 -119
  21. package/lib/query-inspection.js +523 -0
  22. package/lib/render-check-output.js +1 -1
  23. package/lib/render-cli-help.js +419 -0
  24. package/lib/render-json-output.js +57 -4
  25. package/lib/render-output-view.js +37 -8
  26. package/lib/render-plain-output.js +31 -86
  27. package/lib/render-rich-output.js +34 -87
  28. package/lib/resolve-where-clause.js +18 -3
  29. package/lib/tagged-fenced-block-error.js +17 -0
  30. package/lib/tagged-fenced-block-markdown.js +111 -0
  31. package/lib/tagged-fenced-block-metadata.js +97 -0
  32. package/lib/tagged-fenced-block-parser.js +292 -0
  33. package/lib/tagged-fenced-blocks.js +100 -0
  34. package/lib/tagged-fenced-blocks.types.ts +38 -0
  35. package/package.json +8 -3
@@ -10,6 +10,8 @@ import process from 'node:process';
10
10
  import { z } from 'zod';
11
11
 
12
12
  import { parsePatramConfig } from './patram-config.js';
13
+ import { parseWhereClause } from './parse-where-clause.js';
14
+ import { resolvePatramGraphConfig } from './resolve-patram-graph-config.js';
13
15
  import { DEFAULT_INCLUDE_PATTERNS } from './source-file-defaults.js';
14
16
 
15
17
  /**
@@ -36,8 +38,63 @@ const stored_query_schema = z
36
38
  })
37
39
  .strict();
38
40
 
41
+ const derived_summary_scalar_schema = z.union([
42
+ z.boolean(),
43
+ z.number(),
44
+ z.string(),
45
+ z.null(),
46
+ ]);
47
+
48
+ const derived_summary_count_schema = z
49
+ .object({
50
+ traversal: z
51
+ .string()
52
+ .min(1, 'Derived summary count "traversal" must not be empty.'),
53
+ where: z
54
+ .string()
55
+ .min(1, 'Derived summary count "where" must not be empty.'),
56
+ })
57
+ .strict();
58
+
59
+ const derived_summary_select_case_schema = z
60
+ .object({
61
+ value: derived_summary_scalar_schema,
62
+ when: z.string().min(1, 'Derived summary select "when" must not be empty.'),
63
+ })
64
+ .strict();
65
+
66
+ const derived_summary_field_schema = z
67
+ .object({
68
+ count: derived_summary_count_schema.optional(),
69
+ default: derived_summary_scalar_schema.optional(),
70
+ name: z
71
+ .string()
72
+ .regex(
73
+ /^[a-z][a-z0-9_]*$/du,
74
+ 'Derived summary field names must use lower_snake_case.',
75
+ ),
76
+ select: z.array(derived_summary_select_case_schema).optional(),
77
+ })
78
+ .strict()
79
+ .superRefine(validateDerivedSummaryFieldDefinition);
80
+
81
+ const derived_summary_schema = z
82
+ .object({
83
+ fields: z
84
+ .array(derived_summary_field_schema)
85
+ .min(1, 'Derived summary "fields" must contain at least one field.'),
86
+ kinds: z
87
+ .array(z.string().min(1))
88
+ .min(1, 'Derived summary "kinds" must contain at least one kind.'),
89
+ })
90
+ .strict()
91
+ .superRefine(validateDerivedSummaryDefinition);
92
+
39
93
  const patram_repo_config_schema = z
40
94
  .object({
95
+ derived_summaries: z
96
+ .record(z.string().min(1), derived_summary_schema)
97
+ .optional(),
41
98
  include: z
42
99
  .array(z.string().min(1, 'Include globs must not be empty.'))
43
100
  .min(1, 'Include must contain at least one glob.')
@@ -84,7 +141,15 @@ export async function loadPatramConfig(project_directory = process.cwd()) {
84
141
  return createLoadResult(null, graph_schema_diagnostics);
85
142
  }
86
143
 
87
- return createLoadResult(normalizeRepoConfig(config_result.data), []);
144
+ const normalized_config = normalizeRepoConfig(config_result.data);
145
+ const derived_summary_diagnostics =
146
+ validateDerivedSummaries(normalized_config);
147
+
148
+ if (derived_summary_diagnostics.length > 0) {
149
+ return createLoadResult(null, derived_summary_diagnostics);
150
+ }
151
+
152
+ return createLoadResult(normalized_config, []);
88
153
  }
89
154
 
90
155
  /**
@@ -184,6 +249,88 @@ function createValidationDiagnostic(issue) {
184
249
  };
185
250
  }
186
251
 
252
+ /**
253
+ * @param {{ count?: unknown, default?: unknown, select?: unknown }} field_definition
254
+ * @param {import('zod').RefinementCtx} refinement_context
255
+ */
256
+ function validateDerivedSummaryFieldDefinition(
257
+ field_definition,
258
+ refinement_context,
259
+ ) {
260
+ const evaluator_count =
261
+ Number(field_definition.count !== undefined) +
262
+ Number(field_definition.select !== undefined);
263
+
264
+ if (evaluator_count !== 1) {
265
+ refinement_context.addIssue({
266
+ code: 'custom',
267
+ message:
268
+ 'Derived summary fields must define exactly one of "count" or "select".',
269
+ });
270
+ }
271
+
272
+ if (
273
+ field_definition.count !== undefined &&
274
+ field_definition.default !== undefined
275
+ ) {
276
+ refinement_context.addIssue({
277
+ code: 'custom',
278
+ message: 'Derived summary count fields must not define "default".',
279
+ path: ['default'],
280
+ });
281
+ }
282
+
283
+ if (field_definition.select === undefined) {
284
+ return;
285
+ }
286
+
287
+ if (
288
+ Array.isArray(field_definition.select) &&
289
+ field_definition.select.length === 0
290
+ ) {
291
+ refinement_context.addIssue({
292
+ code: 'custom',
293
+ message: 'Derived summary "select" must contain at least one case.',
294
+ path: ['select'],
295
+ });
296
+ }
297
+
298
+ if (field_definition.default === undefined) {
299
+ refinement_context.addIssue({
300
+ code: 'custom',
301
+ message: 'Derived summary select fields must define "default".',
302
+ path: ['default'],
303
+ });
304
+ }
305
+ }
306
+
307
+ /**
308
+ * @param {{ fields: Array<{ name: string }> }} summary_definition
309
+ * @param {import('zod').RefinementCtx} refinement_context
310
+ */
311
+ function validateDerivedSummaryDefinition(
312
+ summary_definition,
313
+ refinement_context,
314
+ ) {
315
+ const seen_field_names = new Set();
316
+
317
+ for (const [
318
+ field_index,
319
+ field_definition,
320
+ ] of summary_definition.fields.entries()) {
321
+ if (!seen_field_names.has(field_definition.name)) {
322
+ seen_field_names.add(field_definition.name);
323
+ continue;
324
+ }
325
+
326
+ refinement_context.addIssue({
327
+ code: 'custom',
328
+ message: `Duplicate derived summary field "${field_definition.name}".`,
329
+ path: ['fields', field_index, 'name'],
330
+ });
331
+ }
332
+ }
333
+
187
334
  /**
188
335
  * @param {{ include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
189
336
  * @returns {PatramDiagnostic[]}
@@ -214,6 +361,41 @@ function validateGraphSchema(repo_config) {
214
361
  return [];
215
362
  }
216
363
 
364
+ /**
365
+ * @param {PatramRepoConfig} repo_config
366
+ * @returns {PatramDiagnostic[]}
367
+ */
368
+ function validateDerivedSummaries(repo_config) {
369
+ if (!repo_config.derived_summaries) {
370
+ return [];
371
+ }
372
+
373
+ const graph_config = resolvePatramGraphConfig(repo_config);
374
+ const known_relation_names = new Set(Object.keys(graph_config.relations));
375
+ /** @type {PatramDiagnostic[]} */
376
+ const diagnostics = [];
377
+ const kind_coverage = new Map();
378
+
379
+ for (const [summary_name, summary_definition] of Object.entries(
380
+ repo_config.derived_summaries,
381
+ )) {
382
+ collectDuplicateKindDiagnostics(
383
+ diagnostics,
384
+ kind_coverage,
385
+ summary_definition.kinds,
386
+ summary_name,
387
+ );
388
+ collectDerivedSummaryFieldDiagnostics(
389
+ diagnostics,
390
+ known_relation_names,
391
+ summary_name,
392
+ summary_definition.fields,
393
+ );
394
+ }
395
+
396
+ return diagnostics;
397
+ }
398
+
217
399
  /**
218
400
  * @returns {PatramRepoConfig}
219
401
  */
@@ -225,7 +407,7 @@ function createDefaultRepoConfig() {
225
407
  }
226
408
 
227
409
  /**
228
- * @param {{ include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
410
+ * @param {{ derived_summaries?: unknown, include: string[], queries: Record<string, { where: string }>, kinds?: unknown, mappings?: unknown, relations?: unknown }} repo_config
229
411
  * @returns {PatramRepoConfig}
230
412
  */
231
413
  function normalizeRepoConfig(repo_config) {
@@ -235,6 +417,16 @@ function normalizeRepoConfig(repo_config) {
235
417
  queries: { ...repo_config.queries },
236
418
  };
237
419
 
420
+ if (
421
+ repo_config.derived_summaries !== undefined &&
422
+ repo_config.derived_summaries !== null
423
+ ) {
424
+ normalized_config.derived_summaries =
425
+ /** @type {PatramRepoConfig['derived_summaries']} */ (
426
+ repo_config.derived_summaries
427
+ );
428
+ }
429
+
238
430
  if (repo_config.kinds !== undefined && repo_config.kinds !== null) {
239
431
  normalized_config.kinds = /** @type {PatramRepoConfig['kinds']} */ (
240
432
  repo_config.kinds
@@ -256,6 +448,213 @@ function normalizeRepoConfig(repo_config) {
256
448
  return normalized_config;
257
449
  }
258
450
 
451
+ /**
452
+ * @param {PatramDiagnostic[]} diagnostics
453
+ * @param {Map<string, string>} kind_coverage
454
+ * @param {string[]} kind_names
455
+ * @param {string} summary_name
456
+ */
457
+ function collectDuplicateKindDiagnostics(
458
+ diagnostics,
459
+ kind_coverage,
460
+ kind_names,
461
+ summary_name,
462
+ ) {
463
+ for (const kind_name of kind_names) {
464
+ const existing_summary_name = kind_coverage.get(kind_name);
465
+
466
+ if (!existing_summary_name) {
467
+ kind_coverage.set(kind_name, summary_name);
468
+ continue;
469
+ }
470
+
471
+ diagnostics.push(
472
+ createConfigDiagnostic(
473
+ `derived_summaries.${summary_name}.kinds`,
474
+ `Kind "${kind_name}" is already covered by derived summary "${existing_summary_name}".`,
475
+ ),
476
+ );
477
+ }
478
+ }
479
+
480
+ /**
481
+ * @param {PatramDiagnostic[]} diagnostics
482
+ * @param {Set<string>} known_relation_names
483
+ * @param {string} summary_name
484
+ * @param {import('./load-patram-config.types.ts').DerivedSummaryFieldConfig[]} field_definitions
485
+ */
486
+ function collectDerivedSummaryFieldDiagnostics(
487
+ diagnostics,
488
+ known_relation_names,
489
+ summary_name,
490
+ field_definitions,
491
+ ) {
492
+ for (const [field_index, field_definition] of field_definitions.entries()) {
493
+ if ('count' in field_definition) {
494
+ collectTraversalDiagnostic(
495
+ diagnostics,
496
+ field_definition.count.traversal,
497
+ known_relation_names,
498
+ `derived_summaries.${summary_name}.fields.${field_index}.count.traversal`,
499
+ );
500
+ collectWhereClauseDiagnostics(
501
+ diagnostics,
502
+ field_definition.count.where,
503
+ known_relation_names,
504
+ `derived_summaries.${summary_name}.fields.${field_index}.count.where`,
505
+ );
506
+ continue;
507
+ }
508
+
509
+ for (const [case_index, select_case] of field_definition.select.entries()) {
510
+ collectWhereClauseDiagnostics(
511
+ diagnostics,
512
+ select_case.when,
513
+ known_relation_names,
514
+ `derived_summaries.${summary_name}.fields.${field_index}.select.${case_index}.when`,
515
+ );
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * @param {PatramDiagnostic[]} diagnostics
522
+ * @param {string} traversal_text
523
+ * @param {Set<string>} known_relation_names
524
+ * @param {string} diagnostic_path
525
+ */
526
+ function collectTraversalDiagnostic(
527
+ diagnostics,
528
+ traversal_text,
529
+ known_relation_names,
530
+ diagnostic_path,
531
+ ) {
532
+ const traversal_match =
533
+ /^(?<direction>in|out):(?<relation_name>[a-zA-Z0-9_]+)$/du.exec(
534
+ traversal_text,
535
+ );
536
+
537
+ if (!traversal_match?.groups?.relation_name) {
538
+ diagnostics.push(
539
+ createConfigDiagnostic(
540
+ diagnostic_path,
541
+ 'Derived summary traversal must use "in:<relation>" or "out:<relation>".',
542
+ ),
543
+ );
544
+
545
+ return;
546
+ }
547
+
548
+ if (known_relation_names.has(traversal_match.groups.relation_name)) {
549
+ return;
550
+ }
551
+
552
+ diagnostics.push(
553
+ createConfigDiagnostic(
554
+ diagnostic_path,
555
+ `Unknown relation "${traversal_match.groups.relation_name}" in derived summary traversal.`,
556
+ ),
557
+ );
558
+ }
559
+
560
+ /**
561
+ * @param {PatramDiagnostic[]} diagnostics
562
+ * @param {string} where_clause
563
+ * @param {Set<string>} known_relation_names
564
+ * @param {string} diagnostic_path
565
+ */
566
+ function collectWhereClauseDiagnostics(
567
+ diagnostics,
568
+ where_clause,
569
+ known_relation_names,
570
+ diagnostic_path,
571
+ ) {
572
+ const parse_result = parseWhereClause(where_clause);
573
+
574
+ if (!parse_result.success) {
575
+ diagnostics.push(
576
+ createConfigDiagnostic(diagnostic_path, parse_result.diagnostic.message),
577
+ );
578
+
579
+ return;
580
+ }
581
+
582
+ for (const clause of parse_result.clauses) {
583
+ collectClauseRelationDiagnostics(
584
+ diagnostics,
585
+ clause.term,
586
+ known_relation_names,
587
+ diagnostic_path,
588
+ );
589
+ }
590
+ }
591
+
592
+ /**
593
+ * @param {PatramDiagnostic[]} diagnostics
594
+ * @param {import('./parse-where-clause.types.ts').ParsedTerm} term
595
+ * @param {Set<string>} known_relation_names
596
+ * @param {string} diagnostic_path
597
+ */
598
+ function collectClauseRelationDiagnostics(
599
+ diagnostics,
600
+ term,
601
+ known_relation_names,
602
+ diagnostic_path,
603
+ ) {
604
+ if (term.kind === 'aggregate') {
605
+ if (!known_relation_names.has(term.traversal.relation_name)) {
606
+ diagnostics.push(
607
+ createConfigDiagnostic(
608
+ diagnostic_path,
609
+ `Unknown relation "${term.traversal.relation_name}" in traversal clause.`,
610
+ ),
611
+ );
612
+ }
613
+
614
+ for (const nested_clause of term.clauses) {
615
+ collectClauseRelationDiagnostics(
616
+ diagnostics,
617
+ nested_clause.term,
618
+ known_relation_names,
619
+ diagnostic_path,
620
+ );
621
+ }
622
+
623
+ return;
624
+ }
625
+
626
+ if (term.kind !== 'relation' && term.kind !== 'relation_target') {
627
+ return;
628
+ }
629
+
630
+ if (known_relation_names.has(term.relation_name)) {
631
+ return;
632
+ }
633
+
634
+ diagnostics.push(
635
+ createConfigDiagnostic(
636
+ diagnostic_path,
637
+ `Unknown relation "${term.relation_name}" in relation clause.`,
638
+ ),
639
+ );
640
+ }
641
+
642
+ /**
643
+ * @param {string} issue_path
644
+ * @param {string} message
645
+ * @returns {PatramDiagnostic}
646
+ */
647
+ function createConfigDiagnostic(issue_path, message) {
648
+ return {
649
+ code: 'config.invalid',
650
+ column: 1,
651
+ level: 'error',
652
+ line: 1,
653
+ message: `Invalid config at "${issue_path}": ${message}`,
654
+ path: CONFIG_FILE_NAME,
655
+ };
656
+ }
657
+
259
658
  /**
260
659
  * @param {unknown} error
261
660
  * @returns {error is NodeJS.ErrnoException}
@@ -8,7 +8,38 @@ export interface StoredQueryConfig {
8
8
  where: string;
9
9
  }
10
10
 
11
+ export type DerivedSummaryScalar = boolean | number | string | null;
12
+
13
+ export interface DerivedSummaryCountFieldConfig {
14
+ count: {
15
+ traversal: string;
16
+ where: string;
17
+ };
18
+ name: string;
19
+ }
20
+
21
+ export interface DerivedSummarySelectCaseConfig {
22
+ value: DerivedSummaryScalar;
23
+ when: string;
24
+ }
25
+
26
+ export interface DerivedSummarySelectFieldConfig {
27
+ default: DerivedSummaryScalar;
28
+ name: string;
29
+ select: DerivedSummarySelectCaseConfig[];
30
+ }
31
+
32
+ export type DerivedSummaryFieldConfig =
33
+ | DerivedSummaryCountFieldConfig
34
+ | DerivedSummarySelectFieldConfig;
35
+
36
+ export interface DerivedSummaryConfig {
37
+ fields: DerivedSummaryFieldConfig[];
38
+ kinds: string[];
39
+ }
40
+
11
41
  export interface PatramRepoConfig {
42
+ derived_summaries?: Record<string, DerivedSummaryConfig>;
12
43
  include: string[];
13
44
  kinds?: Record<string, KindDefinition>;
14
45
  mappings?: Record<string, MappingDefinition>;
@@ -1,5 +1,17 @@
1
1
  import type { GraphNode } from './build-graph.types.ts';
2
2
 
3
+ export type OutputDerivedValue = boolean | number | string | null;
4
+
5
+ export interface OutputDerivedField {
6
+ name: string;
7
+ value: OutputDerivedValue;
8
+ }
9
+
10
+ export interface OutputDerivedSummary {
11
+ fields: OutputDerivedField[];
12
+ name: string;
13
+ }
14
+
3
15
  export interface OutputViewSummary {
4
16
  count: number;
5
17
  kind: 'resolved_link_list' | 'result_list' | 'stored_query_list';
@@ -13,6 +25,7 @@ export interface QueryOutputViewSummary extends OutputViewSummary {
13
25
  }
14
26
 
15
27
  export interface OutputNodeItem {
28
+ derived_summary?: OutputDerivedSummary;
16
29
  id: string;
17
30
  kind: 'node';
18
31
  node_kind: GraphNode['kind'];
@@ -28,6 +41,7 @@ export interface OutputStoredQueryItem {
28
41
  }
29
42
 
30
43
  export interface OutputResolvedLinkTarget {
44
+ derived_summary?: OutputDerivedSummary;
31
45
  kind?: string;
32
46
  path: string;
33
47
  status?: string;
@@ -57,6 +71,7 @@ export interface QueriesOutputView {
57
71
 
58
72
  export interface ShowOutputView {
59
73
  command: 'show';
74
+ document?: OutputNodeItem;
60
75
  hints: string[];
61
76
  items: OutputResolvedLinkItem[];
62
77
  path: string;