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
@@ -0,0 +1,427 @@
1
+ /* eslint-disable max-lines, max-lines-per-function */
2
+ /**
3
+ * @import { ClaimOrigin, PatramClaim } from './parse-claims.types.ts';
4
+ * @import {
5
+ * DiscoveredFieldMultiplicity,
6
+ * DiscoveredFieldTypeName,
7
+ * FieldDiscoveryClassUsage,
8
+ * FieldDiscoveryEvidenceReference,
9
+ * FieldDiscoveryMultiplicitySuggestion,
10
+ * FieldDiscoveryResult,
11
+ * FieldDiscoverySuggestion,
12
+ * FieldDiscoveryTypeSuggestion,
13
+ * } from './discover-fields.types.ts';
14
+ */
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import process from 'node:process';
18
+ import { resolve } from 'node:path';
19
+
20
+ import { DEFAULT_INCLUDE_PATTERNS } from './source-file-defaults.js';
21
+ import { listSourceFiles } from './list-source-files.js';
22
+ import { parseSourceFile } from './parse-claims.js';
23
+
24
+ /**
25
+ * Field discovery from source claims.
26
+ *
27
+ * Scans the repository source files directly, infers likely metadata fields,
28
+ * and reports advisory suggestions without requiring repo config to load.
29
+ *
30
+ * Kind: discovery
31
+ * Status: active
32
+ * Tracked in: ../docs/plans/v1/field-model-redesign.md
33
+ * Decided by: ../docs/decisions/field-discovery-workflow.md
34
+ * @patram
35
+ * @see {@link ./render-field-discovery.js}
36
+ */
37
+
38
+ const TYPE_NAME_ORDER = /** @type {const} */ ([
39
+ 'integer',
40
+ 'date_time',
41
+ 'date',
42
+ 'glob',
43
+ 'path',
44
+ 'enum',
45
+ 'string',
46
+ ]);
47
+
48
+ const INTEGER_PATTERN = /^-?\d+$/du;
49
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/du;
50
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/du;
51
+ const ENUM_PATTERN = /^[a-z0-9_][a-z0-9_-]*$/du;
52
+ const PATH_PATTERN = /^[a-z0-9_.-]+\.[a-z0-9]+$/du;
53
+
54
+ /**
55
+ * @typedef {FieldDiscoveryClassUsage & { confidence: number }} InferredFieldClassUsage
56
+ */
57
+
58
+ /**
59
+ * @typedef {(value: string) => number} FieldTypeScorer
60
+ */
61
+
62
+ /**
63
+ * Discover likely field schema from source files.
64
+ *
65
+ * @param {string} [project_directory]
66
+ * @returns {Promise<FieldDiscoveryResult>}
67
+ */
68
+ export async function discoverFields(project_directory = process.cwd()) {
69
+ const source_file_paths = await listSourceFiles(
70
+ DEFAULT_INCLUDE_PATTERNS,
71
+ project_directory,
72
+ );
73
+ const parse_results = await Promise.all(
74
+ source_file_paths.map(async (source_file_path) => {
75
+ const source_text = await readFile(
76
+ resolve(project_directory, source_file_path),
77
+ 'utf8',
78
+ );
79
+
80
+ return {
81
+ claims: parseSourceFile({
82
+ path: source_file_path,
83
+ source: source_text,
84
+ }).claims,
85
+ path: source_file_path,
86
+ };
87
+ }),
88
+ );
89
+ /** @type {FieldObservation[]} */
90
+ const field_observations = parse_results.flatMap((parse_result) => {
91
+ /** @type {Set<string>} */
92
+ const document_classes = new Set();
93
+
94
+ for (const claim of parse_result.claims) {
95
+ if (
96
+ claim.type === 'directive' &&
97
+ claim.name === 'kind' &&
98
+ typeof claim.value === 'string' &&
99
+ claim.value.length > 0
100
+ ) {
101
+ document_classes.add(claim.value);
102
+ }
103
+ }
104
+
105
+ return parse_result.claims.flatMap((claim) => {
106
+ if (
107
+ claim.type !== 'directive' ||
108
+ !claim.name ||
109
+ claim.name.startsWith('$') ||
110
+ typeof claim.value !== 'string' ||
111
+ claim.value.length === 0
112
+ ) {
113
+ return [];
114
+ }
115
+
116
+ return [
117
+ {
118
+ class_names: new Set(document_classes),
119
+ document_id: claim.document_id,
120
+ name: claim.name,
121
+ origin: claim.origin,
122
+ value: claim.value,
123
+ },
124
+ ];
125
+ });
126
+ });
127
+ /** @type {Map<string, FieldBucket>} */
128
+ const field_buckets = field_observations.reduce(
129
+ (buckets, field_observation) => {
130
+ const bucket = buckets.get(field_observation.name) ?? {
131
+ name: field_observation.name,
132
+ observations: [],
133
+ };
134
+
135
+ bucket.observations.push(field_observation);
136
+ buckets.set(field_observation.name, bucket);
137
+ return buckets;
138
+ },
139
+ new Map(),
140
+ );
141
+ const fields = [...field_buckets.values()]
142
+ .map(buildFieldSuggestion)
143
+ .sort((left_suggestion, right_suggestion) =>
144
+ left_suggestion.confidence !== right_suggestion.confidence
145
+ ? right_suggestion.confidence - left_suggestion.confidence
146
+ : left_suggestion.name.localeCompare(right_suggestion.name, 'en'),
147
+ );
148
+
149
+ return {
150
+ fields,
151
+ summary: {
152
+ claim_count: parse_results.reduce(
153
+ (sum, parse_result) => sum + parse_result.claims.length,
154
+ 0,
155
+ ),
156
+ count: fields.length,
157
+ source_file_count: source_file_paths.length,
158
+ },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * @param {FieldBucket} field_bucket
164
+ * @returns {FieldDiscoverySuggestion}
165
+ */
166
+ function buildFieldSuggestion(field_bucket) {
167
+ const type_result = inferFieldType(field_bucket.observations);
168
+ const multiplicity_result = inferFieldMultiplicity(field_bucket.observations);
169
+ const class_usage_result = inferFieldClassUsage(field_bucket.observations);
170
+ const evidence_references = buildEvidenceReferences(
171
+ field_bucket.observations,
172
+ );
173
+ const conflicting_evidence = buildEvidenceReferences(
174
+ field_bucket.observations.filter(
175
+ (field_observation) =>
176
+ scoreFieldValue(field_observation.value, type_result.name) === 0,
177
+ ),
178
+ );
179
+
180
+ return {
181
+ confidence:
182
+ Math.round(
183
+ ((type_result.confidence +
184
+ multiplicity_result.confidence +
185
+ class_usage_result.confidence) /
186
+ 3) *
187
+ 100,
188
+ ) / 100,
189
+ conflicting_evidence,
190
+ evidence_references,
191
+ likely_class_usage: {
192
+ classes: class_usage_result.classes,
193
+ },
194
+ likely_multiplicity: multiplicity_result,
195
+ likely_type: type_result,
196
+ name: field_bucket.name,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * @param {FieldObservation[]} observations
202
+ * @returns {FieldDiscoveryEvidenceReference[]}
203
+ */
204
+ function buildEvidenceReferences(observations) {
205
+ return observations
206
+ .map((observation) => ({
207
+ column: observation.origin.column,
208
+ line: observation.origin.line,
209
+ path: observation.origin.path,
210
+ value: observation.value,
211
+ }))
212
+ .sort(compareEvidenceReferences);
213
+ }
214
+
215
+ /**
216
+ * @param {FieldObservation[]} observations
217
+ * @returns {FieldDiscoveryMultiplicitySuggestion}
218
+ */
219
+ function inferFieldMultiplicity(observations) {
220
+ /** @type {Map<string, Set<string>>} */
221
+ const values_by_document = observations.reduce((values, observation) => {
222
+ const current_values = values.get(observation.document_id);
223
+
224
+ if (current_values) {
225
+ current_values.add(observation.value);
226
+ } else {
227
+ values.set(observation.document_id, new Set([observation.value]));
228
+ }
229
+
230
+ return values;
231
+ }, new Map());
232
+ const repeated_identical_documents = [...values_by_document.values()].reduce(
233
+ (count, values) => {
234
+ if (values.size > 1) {
235
+ return Infinity;
236
+ }
237
+
238
+ return values.size === 1 ? count + 1 : count;
239
+ },
240
+ 0,
241
+ );
242
+
243
+ if (repeated_identical_documents === Infinity) {
244
+ return {
245
+ confidence: 1,
246
+ name: 'multiple',
247
+ };
248
+ }
249
+
250
+ return {
251
+ confidence:
252
+ Math.round(
253
+ (values_by_document.size > 1 && repeated_identical_documents > 0
254
+ ? 0.9
255
+ : 0.8) * 100,
256
+ ) / 100,
257
+ name: 'single',
258
+ };
259
+ }
260
+
261
+ /**
262
+ * @param {FieldObservation[]} observations
263
+ * @returns {InferredFieldClassUsage}
264
+ */
265
+ function inferFieldClassUsage(observations) {
266
+ /** @type {Map<string, number>} */
267
+ const class_counts = new Map();
268
+ let documented_observation_count = 0;
269
+
270
+ for (const observation of observations) {
271
+ if (observation.class_names.size === 0) {
272
+ continue;
273
+ }
274
+
275
+ documented_observation_count += 1;
276
+
277
+ for (const class_name of observation.class_names) {
278
+ class_counts.set(class_name, (class_counts.get(class_name) ?? 0) + 1);
279
+ }
280
+ }
281
+
282
+ if (class_counts.size === 0) {
283
+ return {
284
+ confidence: 0.2,
285
+ classes: ['document'],
286
+ };
287
+ }
288
+
289
+ return {
290
+ confidence:
291
+ Math.round(
292
+ (documented_observation_count / Math.max(observations.length, 1)) * 100,
293
+ ) / 100,
294
+ classes: [...class_counts.keys()].sort((left_class, right_class) =>
295
+ left_class.localeCompare(right_class, 'en'),
296
+ ),
297
+ };
298
+ }
299
+
300
+ /**
301
+ * @param {FieldObservation[]} observations
302
+ * @returns {FieldDiscoveryTypeSuggestion}
303
+ */
304
+ function inferFieldType(observations) {
305
+ /** @type {FieldDiscoveryTypeSuggestion[]} */
306
+ const type_candidates = TYPE_NAME_ORDER.map((type_name) => ({
307
+ confidence: scoreFieldType(observations, type_name),
308
+ name: type_name,
309
+ }));
310
+
311
+ type_candidates.sort((left_candidate, right_candidate) => {
312
+ if (left_candidate.confidence !== right_candidate.confidence) {
313
+ return right_candidate.confidence - left_candidate.confidence;
314
+ }
315
+
316
+ return (
317
+ TYPE_NAME_ORDER.indexOf(left_candidate.name) -
318
+ TYPE_NAME_ORDER.indexOf(right_candidate.name)
319
+ );
320
+ });
321
+
322
+ return type_candidates[0];
323
+ }
324
+
325
+ /**
326
+ * @param {FieldDiscoveryEvidenceReference} left_reference
327
+ * @param {FieldDiscoveryEvidenceReference} right_reference
328
+ * @returns {number}
329
+ */
330
+ function compareEvidenceReferences(left_reference, right_reference) {
331
+ const path_compare = left_reference.path.localeCompare(
332
+ right_reference.path,
333
+ 'en',
334
+ );
335
+
336
+ if (path_compare !== 0) {
337
+ return path_compare;
338
+ }
339
+
340
+ if (left_reference.line !== right_reference.line) {
341
+ return left_reference.line - right_reference.line;
342
+ }
343
+
344
+ if (left_reference.column !== right_reference.column) {
345
+ return left_reference.column - right_reference.column;
346
+ }
347
+
348
+ return left_reference.value.localeCompare(right_reference.value, 'en');
349
+ }
350
+
351
+ /**
352
+ * @param {FieldObservation[]} observations
353
+ * @param {DiscoveredFieldTypeName} field_type_name
354
+ * @returns {number}
355
+ */
356
+ function scoreFieldType(observations, field_type_name) {
357
+ if (observations.length === 0) {
358
+ return 0;
359
+ }
360
+
361
+ const total_score = observations.reduce(
362
+ (sum, observation) =>
363
+ sum + scoreFieldValue(observation.value, field_type_name),
364
+ 0,
365
+ );
366
+
367
+ return Math.round((total_score / observations.length) * 100) / 100;
368
+ }
369
+
370
+ /**
371
+ * @param {string} value
372
+ * @param {DiscoveredFieldTypeName} field_type_name
373
+ * @returns {number}
374
+ */
375
+ function scoreFieldValue(value, field_type_name) {
376
+ const scorer = FIELD_TYPE_SCORERS[field_type_name];
377
+ return scorer ? scorer(value) : 0;
378
+ }
379
+
380
+ /** @type {Record<DiscoveredFieldTypeName, FieldTypeScorer>} */
381
+ const FIELD_TYPE_SCORERS = {
382
+ date: (value) => (DATE_PATTERN.test(value) ? 1 : 0),
383
+ date_time: (value) => (DATE_TIME_PATTERN.test(value) ? 1 : 0),
384
+ enum: (value) =>
385
+ ENUM_PATTERN.test(value) && value.includes(' ') === false ? 1 : 0,
386
+ glob: (value) =>
387
+ value.includes('*') ||
388
+ value.includes('?') ||
389
+ value.includes('[') ||
390
+ value.includes(']')
391
+ ? 1
392
+ : 0,
393
+ integer: (value) => (INTEGER_PATTERN.test(value) ? 1 : 0),
394
+ path: (value) =>
395
+ !(
396
+ value.includes('/') ||
397
+ PATH_PATTERN.test(value) ||
398
+ value.startsWith('docs/') ||
399
+ value.startsWith('lib/') ||
400
+ value.startsWith('test/')
401
+ )
402
+ ? 0
403
+ : value.includes('*') ||
404
+ value.includes('?') ||
405
+ value.includes('[') ||
406
+ value.includes(']')
407
+ ? 0.8
408
+ : 1,
409
+ string: () => 0.5,
410
+ };
411
+
412
+ /**
413
+ * @typedef {{
414
+ * class_names: Set<string>,
415
+ * document_id: string,
416
+ * name: string,
417
+ * origin: ClaimOrigin,
418
+ * value: string,
419
+ * }} FieldObservation
420
+ */
421
+
422
+ /**
423
+ * @typedef {{
424
+ * name: string,
425
+ * observations: FieldObservation[],
426
+ * }} FieldBucket
427
+ */
@@ -0,0 +1,52 @@
1
+ export type DiscoveredFieldTypeName =
2
+ | 'date'
3
+ | 'date_time'
4
+ | 'enum'
5
+ | 'glob'
6
+ | 'integer'
7
+ | 'path'
8
+ | 'string';
9
+
10
+ export type DiscoveredFieldMultiplicity = 'multiple' | 'single';
11
+
12
+ export interface FieldDiscoveryEvidenceReference {
13
+ column: number;
14
+ line: number;
15
+ path: string;
16
+ value: string;
17
+ }
18
+
19
+ export interface FieldDiscoveryClassUsage {
20
+ classes: string[];
21
+ }
22
+
23
+ export interface FieldDiscoveryTypeSuggestion {
24
+ confidence: number;
25
+ name: DiscoveredFieldTypeName;
26
+ }
27
+
28
+ export interface FieldDiscoveryMultiplicitySuggestion {
29
+ confidence: number;
30
+ name: DiscoveredFieldMultiplicity;
31
+ }
32
+
33
+ export interface FieldDiscoverySuggestion {
34
+ confidence: number;
35
+ conflicting_evidence: FieldDiscoveryEvidenceReference[];
36
+ evidence_references: FieldDiscoveryEvidenceReference[];
37
+ likely_class_usage: FieldDiscoveryClassUsage;
38
+ likely_multiplicity: FieldDiscoveryMultiplicitySuggestion;
39
+ likely_type: FieldDiscoveryTypeSuggestion;
40
+ name: string;
41
+ }
42
+
43
+ export interface FieldDiscoverySummary {
44
+ claim_count: number;
45
+ count: number;
46
+ source_file_count: number;
47
+ }
48
+
49
+ export interface FieldDiscoveryResult {
50
+ fields: FieldDiscoverySuggestion[];
51
+ summary: FieldDiscoverySummary;
52
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @param {import('./output-view.types.ts').OutputDerivedSummary} derived_summary
3
+ * @returns {string}
4
+ */
5
+ export function formatDerivedSummaryRow(derived_summary) {
6
+ return derived_summary.fields
7
+ .map((field) => `${field.name}: ${String(field.value)}`)
8
+ .join(' ');
9
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @param {import('./output-view.types.ts').OutputNodeItem} output_item
3
+ * @returns {string}
4
+ */
5
+ export function formatNodeHeader(output_item) {
6
+ if (output_item.path) {
7
+ return `${output_item.node_kind} ${output_item.path}`;
8
+ }
9
+
10
+ return `${output_item.node_kind} ${getOutputNodeKey(output_item.id)}`;
11
+ }
12
+
13
+ /**
14
+ * @param {string} node_id
15
+ * @returns {string}
16
+ */
17
+ function getOutputNodeKey(node_id) {
18
+ return node_id.includes(':')
19
+ ? node_id.split(':').slice(1).join(':')
20
+ : node_id;
21
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @param {{ header: string, metadata_rows: string[], metadata_indent?: string, title: string, title_indent?: string }} options
3
+ * @returns {string}
4
+ */
5
+ export function formatOutputItemBlock(options) {
6
+ const metadata_indent = options.metadata_indent ?? '';
7
+ const title_indent = options.title_indent ?? ' ';
8
+ /** @type {string[]} */
9
+ const lines = [options.header];
10
+
11
+ if (options.metadata_rows.length > 0) {
12
+ lines.push(
13
+ ...options.metadata_rows.map(
14
+ (metadata_row) => `${metadata_indent}${metadata_row}`,
15
+ ),
16
+ );
17
+ }
18
+
19
+ lines.push('', `${title_indent}${options.title}`);
20
+
21
+ return lines.join('\n');
22
+ }
@@ -0,0 +1,54 @@
1
+ import { formatDerivedSummaryRow } from './format-derived-summary-row.js';
2
+
3
+ /**
4
+ * @param {import('./output-view.types.ts').OutputNodeItem} output_item
5
+ * @returns {string[]}
6
+ */
7
+ export function formatOutputNodeMetadataRows(output_item) {
8
+ /** @type {string[]} */
9
+ const metadata_rows = [];
10
+ const stored_metadata_fields =
11
+ output_item.visible_fields.map(formatMetadataField);
12
+
13
+ if (stored_metadata_fields.length > 0) {
14
+ metadata_rows.push(stored_metadata_fields.join(' '));
15
+ }
16
+
17
+ if (output_item.derived_summary) {
18
+ metadata_rows.push(formatDerivedSummaryRow(output_item.derived_summary));
19
+ }
20
+
21
+ return metadata_rows;
22
+ }
23
+
24
+ /**
25
+ * @param {import('./output-view.types.ts').OutputResolvedLinkTarget} target
26
+ * @returns {string[]}
27
+ */
28
+ export function formatResolvedLinkMetadataRows(target) {
29
+ /** @type {string[]} */
30
+ const metadata_rows = [];
31
+ const stored_metadata_fields = target.visible_fields.map(formatMetadataField);
32
+
33
+ if (stored_metadata_fields.length > 0) {
34
+ metadata_rows.push(stored_metadata_fields.join(' '));
35
+ }
36
+
37
+ if (target.derived_summary) {
38
+ metadata_rows.push(formatDerivedSummaryRow(target.derived_summary));
39
+ }
40
+
41
+ return metadata_rows;
42
+ }
43
+
44
+ /**
45
+ * @param {import('./output-view.types.ts').OutputMetadataField} output_field
46
+ * @returns {string}
47
+ */
48
+ function formatMetadataField(output_field) {
49
+ const value = Array.isArray(output_field.value)
50
+ ? output_field.value.join(', ')
51
+ : output_field.value;
52
+
53
+ return `${output_field.name}: ${value}`;
54
+ }