patram 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/lib/build-graph-identity.js +86 -99
  2. package/lib/build-graph.js +536 -31
  3. package/lib/build-graph.types.ts +6 -2
  4. package/lib/check-directive-metadata.js +534 -0
  5. package/lib/check-directive-value.js +291 -0
  6. package/lib/check-graph.js +23 -5
  7. package/lib/cli-help-metadata.js +56 -16
  8. package/lib/command-output.js +16 -1
  9. package/lib/derived-summary.js +10 -8
  10. package/lib/directive-diagnostics.js +38 -0
  11. package/lib/directive-type-rules.js +133 -0
  12. package/lib/discover-fields.js +435 -0
  13. package/lib/discover-fields.types.ts +52 -0
  14. package/lib/document-node-identity.js +317 -0
  15. package/lib/format-node-header.js +9 -7
  16. package/lib/format-output-metadata.js +15 -23
  17. package/lib/layout-stored-queries.js +124 -85
  18. package/lib/load-patram-config.js +433 -96
  19. package/lib/load-patram-config.types.ts +98 -3
  20. package/lib/load-project-graph.js +4 -1
  21. package/lib/output-view.types.ts +14 -6
  22. package/lib/parse-cli-arguments.types.ts +1 -1
  23. package/lib/parse-where-clause.js +344 -107
  24. package/lib/parse-where-clause.types.ts +25 -8
  25. package/lib/patram-cli.js +68 -4
  26. package/lib/patram-config.js +31 -31
  27. package/lib/patram-config.types.ts +10 -4
  28. package/lib/query-graph.js +269 -40
  29. package/lib/query-inspection.js +440 -60
  30. package/lib/render-field-discovery.js +184 -0
  31. package/lib/render-json-output.js +21 -22
  32. package/lib/render-output-view.js +301 -34
  33. package/lib/render-plain-output.js +1 -1
  34. package/lib/render-rich-output.js +1 -1
  35. package/lib/render-rich-source.js +245 -14
  36. package/lib/resolve-patram-graph-config.js +15 -9
  37. package/lib/show-document.js +66 -9
  38. package/package.json +5 -5
@@ -1,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 {
@@ -18,6 +21,7 @@ export interface GraphEdge {
18
21
  }
19
22
 
20
23
  export interface BuildGraphResult {
24
+ document_node_ids?: Record<string, string>;
21
25
  edges: GraphEdge[];
22
26
  nodes: Record<string, GraphNode>;
23
27
  }
@@ -0,0 +1,534 @@
1
+ /* eslint-disable max-lines */
2
+ /**
3
+ * @import { BuildGraphResult } from './build-graph.types.ts';
4
+ * @import { ClassFieldRuleConfig, MetadataFieldConfig, MetadataSchemaConfig, PatramDiagnostic, PatramRepoConfig } from './load-patram-config.types.ts';
5
+ * @import { PatramClaim } from './parse-claims.types.ts';
6
+ * @import { MappingDefinition } from './patram-config.types.ts';
7
+ */
8
+
9
+ import {
10
+ collectDocumentEntityKeys,
11
+ collectDocumentNodeReferences,
12
+ normalizeRepoRelativePath,
13
+ resolveDocumentNodeId,
14
+ } from './build-graph-identity.js';
15
+ import { checkDirectiveValue } from './check-directive-value.js';
16
+ import {
17
+ createDocumentDiagnostic,
18
+ createOriginDiagnostic,
19
+ } from './directive-diagnostics.js';
20
+ import { resolvePatramGraphConfig } from './resolve-patram-graph-config.js';
21
+
22
+ /**
23
+ * Directive and placement validation.
24
+ *
25
+ * Kind: graph
26
+ * Status: active
27
+ * Tracked in: ../docs/plans/v0/directive-type-validation.md
28
+ * Decided by: ../docs/decisions/directive-type-validation.md
29
+ * @patram
30
+ * @see {@link ./check-graph.js}
31
+ * @see {@link ../docs/decisions/directive-type-validation.md}
32
+ */
33
+
34
+ /**
35
+ * @param {BuildGraphResult} graph
36
+ * @param {PatramRepoConfig} repo_config
37
+ * @param {PatramClaim[]} claims
38
+ * @param {string[]} existing_file_paths
39
+ * @returns {PatramDiagnostic[]}
40
+ */
41
+ export function checkDirectiveMetadata(
42
+ graph,
43
+ repo_config,
44
+ claims,
45
+ existing_file_paths,
46
+ ) {
47
+ if (
48
+ claims.length === 0 ||
49
+ (repo_config.fields === undefined &&
50
+ repo_config.class_schemas === undefined)
51
+ ) {
52
+ return [];
53
+ }
54
+
55
+ const graph_config = resolvePatramGraphConfig(repo_config);
56
+ const document_entity_keys = collectDocumentEntityKeys(
57
+ graph_config.mappings,
58
+ claims,
59
+ );
60
+ const document_node_references = collectDocumentNodeReferences(
61
+ graph_config.mappings,
62
+ claims,
63
+ );
64
+ const document_paths = new Set(
65
+ existing_file_paths.map((file_path) =>
66
+ normalizeRepoRelativePath(file_path),
67
+ ),
68
+ );
69
+ const directive_claims_by_document = groupDirectiveClaimsByDocument(claims);
70
+ /** @type {PatramDiagnostic[]} */
71
+ const diagnostics = [];
72
+
73
+ for (const document_path of [...directive_claims_by_document.keys()].sort()) {
74
+ const document_claims = directive_claims_by_document.get(document_path);
75
+
76
+ if (!document_claims) {
77
+ continue;
78
+ }
79
+
80
+ collectDocumentDiagnostics(
81
+ diagnostics,
82
+ graph,
83
+ graph_config.mappings,
84
+ repo_config,
85
+ document_path,
86
+ document_claims,
87
+ document_entity_keys,
88
+ document_node_references,
89
+ document_paths,
90
+ );
91
+ }
92
+
93
+ return diagnostics;
94
+ }
95
+
96
+ /**
97
+ * @param {PatramClaim[]} claims
98
+ * @returns {Map<string, PatramClaim[]>}
99
+ */
100
+ function groupDirectiveClaimsByDocument(claims) {
101
+ /** @type {Map<string, PatramClaim[]>} */
102
+ const directive_claims_by_document = new Map();
103
+
104
+ for (const claim of claims) {
105
+ if (claim.type !== 'directive') {
106
+ continue;
107
+ }
108
+
109
+ const document_path = normalizeRepoRelativePath(claim.origin.path);
110
+ let document_claims = directive_claims_by_document.get(document_path);
111
+
112
+ if (!document_claims) {
113
+ document_claims = [];
114
+ directive_claims_by_document.set(document_path, document_claims);
115
+ }
116
+
117
+ document_claims.push(claim);
118
+ }
119
+
120
+ return directive_claims_by_document;
121
+ }
122
+
123
+ /**
124
+ * @param {PatramDiagnostic[]} diagnostics
125
+ * @param {BuildGraphResult} graph
126
+ * @param {Record<string, MappingDefinition>} mappings
127
+ * @param {PatramRepoConfig} repo_config
128
+ * @param {string} document_path
129
+ * @param {PatramClaim[]} document_claims
130
+ * @param {Map<string, string>} document_entity_keys
131
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
132
+ * @param {Set<string>} document_paths
133
+ */
134
+ function collectDocumentDiagnostics(
135
+ diagnostics,
136
+ graph,
137
+ mappings,
138
+ repo_config,
139
+ document_path,
140
+ document_claims,
141
+ document_entity_keys,
142
+ document_node_references,
143
+ document_paths,
144
+ ) {
145
+ const document_kind = resolveDocumentKind(graph, document_path);
146
+ const schema_definition = repo_config.class_schemas?.[document_kind];
147
+ /** @type {Map<string, number>} */
148
+ const directive_counts = new Map();
149
+
150
+ for (const claim of document_claims) {
151
+ if (!claim.name) {
152
+ continue;
153
+ }
154
+
155
+ diagnostics.push(
156
+ ...collectClaimDiagnostics(
157
+ claim,
158
+ mappings,
159
+ repo_config,
160
+ document_kind,
161
+ schema_definition,
162
+ directive_counts,
163
+ document_entity_keys,
164
+ document_node_references,
165
+ document_paths,
166
+ ),
167
+ );
168
+ }
169
+
170
+ diagnostics.push(
171
+ ...collectDocumentSummaryDiagnostics(
172
+ repo_config,
173
+ document_path,
174
+ document_kind,
175
+ schema_definition,
176
+ directive_counts,
177
+ ),
178
+ );
179
+ }
180
+
181
+ /**
182
+ * @param {PatramClaim} claim
183
+ * @param {Record<string, MappingDefinition>} mappings
184
+ * @param {PatramRepoConfig} repo_config
185
+ * @param {string} document_kind
186
+ * @param {MetadataSchemaConfig | undefined} schema_definition
187
+ * @param {Map<string, number>} directive_counts
188
+ * @param {Map<string, string>} document_entity_keys
189
+ * @param {Map<string, import('./document-node-identity.js').DocumentNodeReference>} document_node_references
190
+ * @param {Set<string>} document_paths
191
+ * @returns {PatramDiagnostic[]}
192
+ */
193
+ function collectClaimDiagnostics(
194
+ claim,
195
+ mappings,
196
+ repo_config,
197
+ document_kind,
198
+ schema_definition,
199
+ directive_counts,
200
+ document_entity_keys,
201
+ document_node_references,
202
+ document_paths,
203
+ ) {
204
+ if (!claim.name) {
205
+ return [];
206
+ }
207
+
208
+ const mapping_definition = resolveDirectiveMapping(mappings, claim);
209
+ const validation_field_name = getDirectiveValidationFieldName(
210
+ claim.name,
211
+ mapping_definition,
212
+ );
213
+ const has_node_mapping = mapping_definition?.node !== undefined;
214
+ const next_count = (directive_counts.get(claim.name) ?? 0) + 1;
215
+
216
+ directive_counts.set(claim.name, next_count);
217
+
218
+ return [
219
+ ...collectPresenceDiagnostics(
220
+ claim,
221
+ claim.name,
222
+ validation_field_name,
223
+ isEmitOnlyDirective(mapping_definition),
224
+ has_node_mapping,
225
+ repo_config,
226
+ document_kind,
227
+ schema_definition,
228
+ next_count,
229
+ ),
230
+ ...checkDirectiveValue(
231
+ claim,
232
+ claim.name,
233
+ mappings,
234
+ repo_config,
235
+ schema_definition?.fields[claim.name],
236
+ document_entity_keys,
237
+ document_node_references,
238
+ document_paths,
239
+ ),
240
+ ];
241
+ }
242
+
243
+ /**
244
+ * @param {PatramRepoConfig} repo_config
245
+ * @param {string} document_path
246
+ * @param {string} document_kind
247
+ * @param {MetadataSchemaConfig | undefined} schema_definition
248
+ * @param {Map<string, number>} directive_counts
249
+ * @returns {PatramDiagnostic[]}
250
+ */
251
+ function collectDocumentSummaryDiagnostics(
252
+ repo_config,
253
+ document_path,
254
+ document_kind,
255
+ schema_definition,
256
+ directive_counts,
257
+ ) {
258
+ return [
259
+ ...collectMissingDirectiveDiagnostics(
260
+ document_path,
261
+ document_kind,
262
+ schema_definition,
263
+ directive_counts,
264
+ ),
265
+ ...collectPlacementDiagnostics(
266
+ repo_config,
267
+ document_path,
268
+ document_kind,
269
+ schema_definition,
270
+ ),
271
+ ];
272
+ }
273
+
274
+ /**
275
+ * @param {PatramClaim} claim
276
+ * @param {string} directive_name
277
+ * @param {string} validation_field_name
278
+ * @param {boolean} emit_only
279
+ * @param {boolean} has_node_mapping
280
+ * @param {PatramRepoConfig} repo_config
281
+ * @param {string} document_kind
282
+ * @param {MetadataSchemaConfig | undefined} schema_definition
283
+ * @param {number} directive_count
284
+ * @returns {PatramDiagnostic[]}
285
+ */
286
+ function collectPresenceDiagnostics(
287
+ claim,
288
+ directive_name,
289
+ validation_field_name,
290
+ emit_only,
291
+ has_node_mapping,
292
+ repo_config,
293
+ document_kind,
294
+ schema_definition,
295
+ directive_count,
296
+ ) {
297
+ const presence_context = resolveDirectivePresenceContext(
298
+ validation_field_name,
299
+ emit_only,
300
+ has_node_mapping,
301
+ repo_config,
302
+ schema_definition,
303
+ );
304
+
305
+ if (presence_context === null) {
306
+ return [];
307
+ }
308
+
309
+ const { directive_rule, field_definition } = presence_context;
310
+
311
+ if (isForbiddenDirective(directive_rule, schema_definition)) {
312
+ return [
313
+ createOriginDiagnostic(
314
+ claim,
315
+ 'directive.forbidden',
316
+ `Directive "${directive_name}" is forbidden for class "${document_kind}".`,
317
+ ),
318
+ ];
319
+ }
320
+
321
+ if (field_definition?.multiple === true || directive_count <= 1) {
322
+ return [];
323
+ }
324
+
325
+ return [
326
+ createOriginDiagnostic(
327
+ claim,
328
+ 'directive.duplicate',
329
+ `Directive "${directive_name}" must appear at most once for class "${document_kind}".`,
330
+ ),
331
+ ];
332
+ }
333
+
334
+ /**
335
+ * @param {{ presence: 'required' | 'optional' | 'forbidden' } | undefined} directive_rule
336
+ * @param {MetadataSchemaConfig | undefined} schema_definition
337
+ * @returns {boolean}
338
+ */
339
+ function isForbiddenDirective(directive_rule, schema_definition) {
340
+ return (
341
+ directive_rule?.presence === 'forbidden' ||
342
+ (directive_rule === undefined &&
343
+ schema_definition?.unknown_fields === 'error')
344
+ );
345
+ }
346
+
347
+ /**
348
+ * @param {string} validation_field_name
349
+ * @param {boolean} emit_only
350
+ * @param {boolean} has_node_mapping
351
+ * @param {PatramRepoConfig} repo_config
352
+ * @param {MetadataSchemaConfig | undefined} schema_definition
353
+ * @returns {{ directive_rule: ClassFieldRuleConfig | undefined, field_definition: MetadataFieldConfig | undefined } | null}
354
+ */
355
+ function resolveDirectivePresenceContext(
356
+ validation_field_name,
357
+ emit_only,
358
+ has_node_mapping,
359
+ repo_config,
360
+ schema_definition,
361
+ ) {
362
+ if (emit_only) {
363
+ return null;
364
+ }
365
+
366
+ const directive_rule = schema_definition?.fields[validation_field_name];
367
+ const field_definition = getDirectiveFieldDefinition(
368
+ repo_config,
369
+ validation_field_name,
370
+ );
371
+
372
+ if (
373
+ !has_node_mapping &&
374
+ directive_rule === undefined &&
375
+ field_definition === undefined
376
+ ) {
377
+ return null;
378
+ }
379
+
380
+ return {
381
+ directive_rule,
382
+ field_definition,
383
+ };
384
+ }
385
+
386
+ /**
387
+ * @param {PatramRepoConfig} repo_config
388
+ * @param {string} validation_field_name
389
+ * @returns {MetadataFieldConfig | undefined}
390
+ */
391
+ function getDirectiveFieldDefinition(repo_config, validation_field_name) {
392
+ if (
393
+ validation_field_name.startsWith('$') ||
394
+ validation_field_name === 'title'
395
+ ) {
396
+ return undefined;
397
+ }
398
+
399
+ return repo_config.fields?.[validation_field_name];
400
+ }
401
+
402
+ /**
403
+ * @param {BuildGraphResult} graph
404
+ * @param {string} document_path
405
+ * @returns {string}
406
+ */
407
+ function resolveDocumentKind(graph, document_path) {
408
+ const document_node_id = resolveDocumentNodeId(
409
+ graph.document_node_ids,
410
+ document_path,
411
+ );
412
+
413
+ return (
414
+ graph.nodes[document_node_id]?.$class ??
415
+ graph.nodes[document_node_id]?.kind ??
416
+ 'document'
417
+ );
418
+ }
419
+
420
+ /**
421
+ * @param {Record<string, MappingDefinition>} mappings
422
+ * @param {PatramClaim} claim
423
+ * @returns {MappingDefinition | null}
424
+ */
425
+ function resolveDirectiveMapping(mappings, claim) {
426
+ if (!claim.name || !claim.parser) {
427
+ return null;
428
+ }
429
+
430
+ return mappings[`${claim.parser}.directive.${claim.name}`] ?? null;
431
+ }
432
+
433
+ /**
434
+ * @param {string} directive_name
435
+ * @param {MappingDefinition | null} mapping_definition
436
+ * @returns {string}
437
+ */
438
+ function getDirectiveValidationFieldName(directive_name, mapping_definition) {
439
+ if (mapping_definition?.node?.field) {
440
+ return mapping_definition.node.field;
441
+ }
442
+
443
+ return directive_name;
444
+ }
445
+
446
+ /**
447
+ * @param {MappingDefinition | null} mapping_definition
448
+ * @returns {boolean}
449
+ */
450
+ function isEmitOnlyDirective(mapping_definition) {
451
+ return (
452
+ mapping_definition?.emit !== undefined &&
453
+ mapping_definition.node === undefined
454
+ );
455
+ }
456
+
457
+ /**
458
+ * @param {string} document_path
459
+ * @param {string} document_kind
460
+ * @param {MetadataSchemaConfig | undefined} schema_definition
461
+ * @param {Map<string, number>} directive_counts
462
+ * @returns {PatramDiagnostic[]}
463
+ */
464
+ function collectMissingDirectiveDiagnostics(
465
+ document_path,
466
+ document_kind,
467
+ schema_definition,
468
+ directive_counts,
469
+ ) {
470
+ if (!schema_definition) {
471
+ return [];
472
+ }
473
+
474
+ /** @type {PatramDiagnostic[]} */
475
+ const diagnostics = [];
476
+
477
+ for (const [directive_name, directive_rule] of Object.entries(
478
+ schema_definition.fields,
479
+ )) {
480
+ if (
481
+ directive_rule.presence === 'required' &&
482
+ (directive_counts.get(directive_name) ?? 0) === 0
483
+ ) {
484
+ diagnostics.push(
485
+ createDocumentDiagnostic(
486
+ document_path,
487
+ 'directive.missing_required',
488
+ `Missing required directive "${directive_name}" for class "${document_kind}".`,
489
+ ),
490
+ );
491
+ }
492
+ }
493
+
494
+ return diagnostics;
495
+ }
496
+
497
+ /**
498
+ * @param {PatramRepoConfig} repo_config
499
+ * @param {string} document_path
500
+ * @param {string} document_kind
501
+ * @param {MetadataSchemaConfig | undefined} schema_definition
502
+ * @returns {PatramDiagnostic[]}
503
+ */
504
+ function collectPlacementDiagnostics(
505
+ repo_config,
506
+ document_path,
507
+ document_kind,
508
+ schema_definition,
509
+ ) {
510
+ const document_path_class = schema_definition?.document_path_class;
511
+
512
+ if (!document_path_class) {
513
+ return [];
514
+ }
515
+
516
+ const path_class_definition = repo_config.path_classes?.[document_path_class];
517
+
518
+ if (
519
+ !path_class_definition ||
520
+ path_class_definition.prefixes.some((prefix) =>
521
+ document_path.startsWith(prefix),
522
+ )
523
+ ) {
524
+ return [];
525
+ }
526
+
527
+ return [
528
+ createDocumentDiagnostic(
529
+ document_path,
530
+ 'document.invalid_placement',
531
+ `Document class "${document_kind}" must be placed in path class "${document_path_class}".`,
532
+ ),
533
+ ];
534
+ }