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