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
@@ -0,0 +1,278 @@
1
+ /**
2
+ * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
3
+ * @import { OutputDerivedSummary } from './output-view.types.ts';
4
+ * @import { DerivedSummaryConfig, DerivedSummaryFieldConfig, DerivedSummaryScalar, PatramRepoConfig } from './load-patram-config.types.ts';
5
+ */
6
+
7
+ import { queryGraph } from './query-graph.js';
8
+
9
+ /**
10
+ * Derived summary evaluation.
11
+ *
12
+ * Evaluates repo-configured output-only metadata for graph nodes without
13
+ * mutating graph state.
14
+ *
15
+ * Kind: output
16
+ * Status: active
17
+ * Tracked in: ../docs/plans/v0/declarative-derived-summaries.md
18
+ * Decided by: ../docs/decisions/declarative-derived-summary-config.md
19
+ * Decided by: ../docs/decisions/declarative-derived-summary-side-effects.md
20
+ * @patram
21
+ * @see {@link ./load-patram-config.js}
22
+ * @see {@link ./render-output-view.js}
23
+ */
24
+
25
+ /**
26
+ * @typedef {{
27
+ * evaluate: (graph_node: GraphNode) => OutputDerivedSummary | null,
28
+ * }} DerivedSummaryEvaluator
29
+ */
30
+
31
+ /**
32
+ * @param {PatramRepoConfig} repo_config
33
+ * @param {BuildGraphResult} graph
34
+ * @returns {DerivedSummaryEvaluator}
35
+ */
36
+ export function createDerivedSummaryEvaluator(repo_config, graph) {
37
+ const summary_by_kind = createSummaryByKind(repo_config.derived_summaries);
38
+ /** @type {Map<string, Set<string>>} */
39
+ const matching_node_id_cache = new Map();
40
+
41
+ return {
42
+ evaluate(graph_node) {
43
+ const configured_summary = summary_by_kind.get(graph_node.kind);
44
+
45
+ if (!configured_summary) {
46
+ return null;
47
+ }
48
+
49
+ return {
50
+ fields: configured_summary.definition.fields.map(
51
+ (field_definition) => ({
52
+ name: field_definition.name,
53
+ value: evaluateFieldValue(
54
+ field_definition,
55
+ configured_summary.definition,
56
+ graph,
57
+ graph_node,
58
+ matching_node_id_cache,
59
+ ),
60
+ }),
61
+ ),
62
+ name: configured_summary.name,
63
+ };
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * @param {DerivedSummaryFieldConfig} field_definition
70
+ * @param {DerivedSummaryConfig} summary_definition
71
+ * @param {BuildGraphResult} graph
72
+ * @param {GraphNode} graph_node
73
+ * @param {Map<string, Set<string>>} matching_node_id_cache
74
+ * @returns {DerivedSummaryScalar}
75
+ */
76
+ function evaluateFieldValue(
77
+ field_definition,
78
+ summary_definition,
79
+ graph,
80
+ graph_node,
81
+ matching_node_id_cache,
82
+ ) {
83
+ if ('count' in field_definition) {
84
+ return countMatchingTraversalNodes(
85
+ graph,
86
+ graph_node,
87
+ field_definition.count.traversal,
88
+ field_definition.count.where,
89
+ matching_node_id_cache,
90
+ );
91
+ }
92
+
93
+ return selectFieldValue(
94
+ graph,
95
+ graph_node,
96
+ summary_definition,
97
+ field_definition,
98
+ matching_node_id_cache,
99
+ );
100
+ }
101
+
102
+ /**
103
+ * @param {BuildGraphResult} graph
104
+ * @param {GraphNode} graph_node
105
+ * @param {string} traversal_text
106
+ * @param {string} where_clause
107
+ * @param {Map<string, Set<string>>} matching_node_id_cache
108
+ * @returns {number}
109
+ */
110
+ function countMatchingTraversalNodes(
111
+ graph,
112
+ graph_node,
113
+ traversal_text,
114
+ where_clause,
115
+ matching_node_id_cache,
116
+ ) {
117
+ const target_node_ids = getTraversedNodeIds(
118
+ graph,
119
+ graph_node.id,
120
+ traversal_text,
121
+ );
122
+
123
+ if (target_node_ids.size === 0) {
124
+ return 0;
125
+ }
126
+
127
+ const matching_node_ids = getMatchingNodeIds(
128
+ graph,
129
+ where_clause,
130
+ matching_node_id_cache,
131
+ );
132
+ let matching_count = 0;
133
+
134
+ for (const target_node_id of target_node_ids) {
135
+ if (matching_node_ids.has(target_node_id)) {
136
+ matching_count += 1;
137
+ }
138
+ }
139
+
140
+ return matching_count;
141
+ }
142
+
143
+ /**
144
+ * @param {BuildGraphResult} graph
145
+ * @param {GraphNode} graph_node
146
+ * @param {DerivedSummaryConfig} summary_definition
147
+ * @param {Extract<DerivedSummaryFieldConfig, { select: unknown }>} field_definition
148
+ * @param {Map<string, Set<string>>} matching_node_id_cache
149
+ * @returns {DerivedSummaryScalar}
150
+ */
151
+ function selectFieldValue(
152
+ graph,
153
+ graph_node,
154
+ summary_definition,
155
+ field_definition,
156
+ matching_node_id_cache,
157
+ ) {
158
+ for (const select_case of field_definition.select) {
159
+ const matching_node_ids = getMatchingNodeIds(
160
+ graph,
161
+ select_case.when,
162
+ matching_node_id_cache,
163
+ );
164
+
165
+ if (matching_node_ids.has(graph_node.id)) {
166
+ return select_case.value;
167
+ }
168
+ }
169
+
170
+ return field_definition.default;
171
+ }
172
+
173
+ /**
174
+ * @param {BuildGraphResult} graph
175
+ * @param {string} where_clause
176
+ * @param {Map<string, Set<string>>} matching_node_id_cache
177
+ * @returns {Set<string>}
178
+ */
179
+ function getMatchingNodeIds(graph, where_clause, matching_node_id_cache) {
180
+ const cached_node_ids = matching_node_id_cache.get(where_clause);
181
+
182
+ if (cached_node_ids) {
183
+ return cached_node_ids;
184
+ }
185
+
186
+ const query_result = queryGraph(graph, where_clause);
187
+
188
+ if (query_result.diagnostics.length > 0) {
189
+ throw new Error(
190
+ `Expected derived summary query "${where_clause}" to be valid.`,
191
+ );
192
+ }
193
+
194
+ const matching_node_ids = new Set(
195
+ query_result.nodes.map((matching_node) => matching_node.id),
196
+ );
197
+
198
+ matching_node_id_cache.set(where_clause, matching_node_ids);
199
+
200
+ return matching_node_ids;
201
+ }
202
+
203
+ /**
204
+ * @param {BuildGraphResult} graph
205
+ * @param {string} node_id
206
+ * @param {string} traversal_text
207
+ * @returns {Set<string>}
208
+ */
209
+ function getTraversedNodeIds(graph, node_id, traversal_text) {
210
+ const traversal = parseTraversal(traversal_text);
211
+ /** @type {Set<string>} */
212
+ const target_node_ids = new Set();
213
+
214
+ for (const graph_edge of graph.edges) {
215
+ if (graph_edge.relation !== traversal.relation_name) {
216
+ continue;
217
+ }
218
+
219
+ if (traversal.direction === 'in' && graph_edge.to === node_id) {
220
+ target_node_ids.add(graph_edge.from);
221
+ }
222
+
223
+ if (traversal.direction === 'out' && graph_edge.from === node_id) {
224
+ target_node_ids.add(graph_edge.to);
225
+ }
226
+ }
227
+
228
+ return target_node_ids;
229
+ }
230
+
231
+ /**
232
+ * @param {string} traversal_text
233
+ * @returns {{ direction: 'in' | 'out', relation_name: string }}
234
+ */
235
+ function parseTraversal(traversal_text) {
236
+ const traversal_match =
237
+ /^(?<direction>in|out):(?<relation_name>[a-zA-Z0-9_]+)$/du.exec(
238
+ traversal_text,
239
+ );
240
+
241
+ if (
242
+ !traversal_match?.groups?.direction ||
243
+ !traversal_match.groups.relation_name
244
+ ) {
245
+ throw new Error(`Invalid derived summary traversal "${traversal_text}".`);
246
+ }
247
+
248
+ return {
249
+ direction: /** @type {'in' | 'out'} */ (traversal_match.groups.direction),
250
+ relation_name: traversal_match.groups.relation_name,
251
+ };
252
+ }
253
+
254
+ /**
255
+ * @param {PatramRepoConfig['derived_summaries']} derived_summaries
256
+ * @returns {Map<string, { definition: DerivedSummaryConfig, name: string }>}
257
+ */
258
+ function createSummaryByKind(derived_summaries) {
259
+ /** @type {Map<string, { definition: DerivedSummaryConfig, name: string }>} */
260
+ const summary_by_kind = new Map();
261
+
262
+ if (!derived_summaries) {
263
+ return summary_by_kind;
264
+ }
265
+
266
+ for (const [summary_name, summary_definition] of Object.entries(
267
+ derived_summaries,
268
+ )) {
269
+ for (const kind_name of summary_definition.kinds) {
270
+ summary_by_kind.set(kind_name, {
271
+ definition: summary_definition,
272
+ name: summary_name,
273
+ });
274
+ }
275
+ }
276
+
277
+ return summary_by_kind;
278
+ }
@@ -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,19 @@
1
+ /**
2
+ * @param {import('./output-view.types.ts').OutputNodeItem} output_item
3
+ * @returns {string}
4
+ */
5
+ export function formatNodeHeader(output_item) {
6
+ if (isDocumentNode(output_item)) {
7
+ return `document ${output_item.path}`;
8
+ }
9
+
10
+ return `${output_item.node_kind} ${output_item.id}`;
11
+ }
12
+
13
+ /**
14
+ * @param {import('./output-view.types.ts').OutputNodeItem} output_item
15
+ * @returns {boolean}
16
+ */
17
+ export function isDocumentNode(output_item) {
18
+ return output_item.id === `doc:${output_item.path}`;
19
+ }
@@ -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,62 @@
1
+ import { formatDerivedSummaryRow } from './format-derived-summary-row.js';
2
+ import { isDocumentNode } from './format-node-header.js';
3
+
4
+ /**
5
+ * @param {import('./output-view.types.ts').OutputNodeItem} output_item
6
+ * @returns {string[]}
7
+ */
8
+ export function formatOutputNodeMetadataRows(output_item) {
9
+ /** @type {string[]} */
10
+ const metadata_rows = [];
11
+ /** @type {string[]} */
12
+ const stored_metadata_fields = [];
13
+
14
+ if (isDocumentNode(output_item)) {
15
+ stored_metadata_fields.push(`kind: ${output_item.node_kind}`);
16
+ } else {
17
+ stored_metadata_fields.push(`path: ${output_item.path}`);
18
+ }
19
+
20
+ if (output_item.status) {
21
+ stored_metadata_fields.push(`status: ${output_item.status}`);
22
+ }
23
+
24
+ if (stored_metadata_fields.length > 0) {
25
+ metadata_rows.push(stored_metadata_fields.join(' '));
26
+ }
27
+
28
+ if (output_item.derived_summary) {
29
+ metadata_rows.push(formatDerivedSummaryRow(output_item.derived_summary));
30
+ }
31
+
32
+ return metadata_rows;
33
+ }
34
+
35
+ /**
36
+ * @param {import('./output-view.types.ts').OutputResolvedLinkTarget} target
37
+ * @returns {string[]}
38
+ */
39
+ export function formatResolvedLinkMetadataRows(target) {
40
+ /** @type {string[]} */
41
+ const metadata_rows = [];
42
+ /** @type {string[]} */
43
+ const stored_metadata_fields = [];
44
+
45
+ if (target.kind) {
46
+ stored_metadata_fields.push(`kind: ${target.kind}`);
47
+ }
48
+
49
+ if (target.status) {
50
+ stored_metadata_fields.push(`status: ${target.status}`);
51
+ }
52
+
53
+ if (stored_metadata_fields.length > 0) {
54
+ metadata_rows.push(stored_metadata_fields.join(' '));
55
+ }
56
+
57
+ if (target.derived_summary) {
58
+ metadata_rows.push(formatDerivedSummaryRow(target.derived_summary));
59
+ }
60
+
61
+ return metadata_rows;
62
+ }
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines */
1
2
  /**
2
3
  * @import { OutputStoredQueryItem } from './output-view.types.ts';
3
4
  */
@@ -97,7 +98,22 @@ function createStoredQueryPhrases(where_clause) {
97
98
  }
98
99
 
99
100
  /**
100
- * @param {{ is_negated: boolean, term: { kind: 'field', field_name: 'id' | 'kind' | 'path' | 'status' | 'title', operator: '=' | '^=' | '~', value: string } | { kind: 'relation', relation_name: string } | { kind: 'relation_target', relation_name: string, target_id: string } }} clause
101
+ * @param {{
102
+ * is_negated: boolean,
103
+ * term:
104
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }
105
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }
106
+ * | { kind: 'relation', relation_name: string }
107
+ * | { kind: 'relation_target', relation_name: string, target_id: string }
108
+ * | {
109
+ * aggregate_name: 'any' | 'count' | 'none',
110
+ * clauses: unknown[],
111
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
112
+ * kind: 'aggregate',
113
+ * traversal: { direction: 'in' | 'out', relation_name: string },
114
+ * value?: number,
115
+ * },
116
+ * }} clause
101
117
  * @param {boolean} should_prefix_and
102
118
  * @returns {StoredQuerySegment[]}
103
119
  */
@@ -121,10 +137,27 @@ function createClausePhrase(clause, should_prefix_and) {
121
137
  }
122
138
 
123
139
  /**
124
- * @param {{ kind: 'field', field_name: 'id' | 'kind' | 'path' | 'status' | 'title', operator: '=' | '^=' | '~', value: string } | { kind: 'relation', relation_name: string } | { kind: 'relation_target', relation_name: string, target_id: string }} term
140
+ * @param {
141
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }
142
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }
143
+ * | { kind: 'relation', relation_name: string }
144
+ * | { kind: 'relation_target', relation_name: string, target_id: string }
145
+ * | {
146
+ * aggregate_name: 'any' | 'count' | 'none',
147
+ * clauses: { is_negated: boolean, term: unknown }[],
148
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
149
+ * kind: 'aggregate',
150
+ * traversal: { direction: 'in' | 'out', relation_name: string },
151
+ * value?: number,
152
+ * }
153
+ * } term
125
154
  * @returns {StoredQuerySegment[]}
126
155
  */
127
156
  function createTermSegments(term) {
157
+ if (term.kind === 'aggregate') {
158
+ return createAggregateSegments(term);
159
+ }
160
+
128
161
  if (term.kind === 'field') {
129
162
  return [
130
163
  { kind: 'field_name', text: term.field_name },
@@ -133,6 +166,10 @@ function createTermSegments(term) {
133
166
  ];
134
167
  }
135
168
 
169
+ if (term.kind === 'field_set') {
170
+ return createFieldSetSegments(term);
171
+ }
172
+
136
173
  if (term.kind === 'relation_target') {
137
174
  return [
138
175
  { kind: 'field_name', text: term.relation_name },
@@ -147,6 +184,117 @@ function createTermSegments(term) {
147
184
  ];
148
185
  }
149
186
 
187
+ /**
188
+ * @param {{ field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }} term
189
+ * @returns {StoredQuerySegment[]}
190
+ */
191
+ function createFieldSetSegments(term) {
192
+ return [
193
+ { kind: 'field_name', text: term.field_name },
194
+ { kind: 'plain', text: ' ' },
195
+ { kind: 'operator', text: term.operator },
196
+ { kind: 'plain', text: ' ' },
197
+ { kind: 'operator', text: '[' },
198
+ ...createListSegments(term.values),
199
+ { kind: 'operator', text: ']' },
200
+ ];
201
+ }
202
+
203
+ /**
204
+ * @param {{
205
+ * aggregate_name: 'any' | 'count' | 'none',
206
+ * clauses: { is_negated: boolean, term: unknown }[],
207
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
208
+ * kind: 'aggregate',
209
+ * traversal: { direction: 'in' | 'out', relation_name: string },
210
+ * value?: number,
211
+ * }} term
212
+ * @returns {StoredQuerySegment[]}
213
+ */
214
+ function createAggregateSegments(term) {
215
+ /** @type {StoredQuerySegment[]} */
216
+ const segments = [
217
+ { kind: 'field_name', text: term.aggregate_name },
218
+ { kind: 'operator', text: '(' },
219
+ ...createTraversalSegments(term.traversal),
220
+ { kind: 'operator', text: ', ' },
221
+ ...createNestedClauseSegments(term.clauses),
222
+ { kind: 'operator', text: ')' },
223
+ ];
224
+
225
+ if (term.aggregate_name === 'count') {
226
+ segments.push({ kind: 'plain', text: ' ' });
227
+ segments.push({ kind: 'operator', text: term.comparison ?? '=' });
228
+ segments.push({ kind: 'plain', text: ' ' });
229
+ segments.push({ kind: 'literal', text: String(term.value ?? 0) });
230
+ }
231
+
232
+ return segments;
233
+ }
234
+
235
+ /**
236
+ * @param {{ direction: 'in' | 'out', relation_name: string }} traversal
237
+ * @returns {StoredQuerySegment[]}
238
+ */
239
+ function createTraversalSegments(traversal) {
240
+ return [
241
+ { kind: 'field_name', text: traversal.direction },
242
+ { kind: 'operator', text: ':' },
243
+ { kind: 'field_name', text: traversal.relation_name },
244
+ ];
245
+ }
246
+
247
+ /**
248
+ * @param {{ is_negated: boolean, term: unknown }[]} clauses
249
+ * @returns {StoredQuerySegment[]}
250
+ */
251
+ function createNestedClauseSegments(clauses) {
252
+ return clauses.flatMap((clause, clause_index) => {
253
+ const clause_phrase = createClausePhrase(
254
+ /** @type {{
255
+ * is_negated: boolean,
256
+ * term:
257
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }
258
+ * | { field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }
259
+ * | { kind: 'relation', relation_name: string }
260
+ * | { kind: 'relation_target', relation_name: string, target_id: string }
261
+ * | {
262
+ * aggregate_name: 'any' | 'count' | 'none',
263
+ * clauses: { is_negated: boolean, term: unknown }[],
264
+ * comparison?: '!=' | '<' | '<=' | '=' | '>' | '>=',
265
+ * kind: 'aggregate',
266
+ * traversal: { direction: 'in' | 'out', relation_name: string },
267
+ * value?: number,
268
+ * },
269
+ * }} */ (clause),
270
+ clause_index > 0,
271
+ );
272
+
273
+ if (clause_index === 0) {
274
+ return clause_phrase;
275
+ }
276
+
277
+ return [{ kind: 'plain', text: ' ' }, ...clause_phrase];
278
+ });
279
+ }
280
+
281
+ /**
282
+ * @param {string[]} values
283
+ * @returns {StoredQuerySegment[]}
284
+ */
285
+ function createListSegments(values) {
286
+ return values.flatMap((value, value_index) => {
287
+ if (value_index === 0) {
288
+ return [{ kind: 'literal', text: value }];
289
+ }
290
+
291
+ return [
292
+ { kind: 'operator', text: ', ' },
293
+ { kind: 'literal', text: value },
294
+ ];
295
+ });
296
+ }
297
+
150
298
  /**
151
299
  * @param {string} where_clause
152
300
  * @returns {StoredQuerySegment[][]}