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
@@ -1,6 +1,16 @@
1
+ /* eslint-disable max-lines */
1
2
  /**
2
3
  * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
3
4
  * @import { PatramDiagnostic } from './load-patram-config.types.ts';
5
+ * @import {
6
+ * ParsedAggregateTerm,
7
+ * ParsedClause,
8
+ * ParsedFieldSetTerm,
9
+ * ParsedFieldTerm,
10
+ * ParsedRelationTargetTerm,
11
+ * ParsedTerm,
12
+ * ParsedTraversalTerm,
13
+ * } from './parse-where-clause.types.ts';
4
14
  */
5
15
 
6
16
  import { parseWhereClause } from './parse-where-clause.js';
@@ -26,7 +36,21 @@ import { parseWhereClause } from './parse-where-clause.js';
26
36
  export const DEFAULT_QUERY_LIMIT = 25;
27
37
 
28
38
  /**
29
- * Filter graph nodes with the v0 query language.
39
+ * @typedef {{
40
+ * incoming: Map<string, Map<string, Set<string>>>,
41
+ * outgoing: Map<string, Map<string, Set<string>>>,
42
+ * }} RelationIndexes
43
+ */
44
+
45
+ /**
46
+ * @typedef {{
47
+ * nodes: BuildGraphResult['nodes'],
48
+ * relation_indexes: RelationIndexes,
49
+ * }} EvaluationContext
50
+ */
51
+
52
+ /**
53
+ * Filter graph nodes with the query language.
30
54
  *
31
55
  * @param {BuildGraphResult} graph
32
56
  * @param {string} where_clause
@@ -44,11 +68,13 @@ export function queryGraph(graph, where_clause, pagination_options = {}) {
44
68
  };
45
69
  }
46
70
 
47
- const predicates = parse_result.clauses.map(createPredicate);
48
- const relation_index = createRelationIndex(graph.edges);
71
+ const evaluation_context = {
72
+ nodes: graph.nodes,
73
+ relation_indexes: createRelationIndexes(graph.edges),
74
+ };
49
75
  const graph_nodes = Object.values(graph.nodes).sort(compareGraphNodes);
50
76
  const matching_nodes = graph_nodes.filter((graph_node) =>
51
- predicates.every((predicate) => predicate(graph_node, relation_index)),
77
+ matchesClauses(graph_node, parse_result.clauses, evaluation_context),
52
78
  );
53
79
  const paginated_nodes = paginateNodes(matching_nodes, pagination_options);
54
80
 
@@ -60,178 +86,264 @@ export function queryGraph(graph, where_clause, pagination_options = {}) {
60
86
  }
61
87
 
62
88
  /**
63
- * @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
64
- * @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
89
+ * @param {GraphNode} graph_node
90
+ * @param {ParsedClause[]} clauses
91
+ * @param {EvaluationContext} evaluation_context
92
+ * @returns {boolean}
65
93
  */
66
- function createPredicate(clause) {
67
- if (clause.term.kind === 'relation') {
68
- return createRelationPredicate(
69
- clause.term.relation_name,
70
- clause.is_negated,
71
- );
72
- }
94
+ function matchesClauses(graph_node, clauses, evaluation_context) {
95
+ return clauses.every((clause) =>
96
+ matchesClause(graph_node, clause, evaluation_context),
97
+ );
98
+ }
73
99
 
74
- if (clause.term.kind === 'relation_target') {
75
- return createRelationTargetPredicate(
76
- clause.term.relation_name,
77
- clause.term.target_id,
78
- clause.is_negated,
79
- );
80
- }
100
+ /**
101
+ * @param {GraphNode} graph_node
102
+ * @param {ParsedClause} clause
103
+ * @param {EvaluationContext} evaluation_context
104
+ * @returns {boolean}
105
+ */
106
+ function matchesClause(graph_node, clause, evaluation_context) {
107
+ const is_match = matchesTerm(graph_node, clause.term, evaluation_context);
81
108
 
82
- return createFieldPredicateFromTerm(clause.term, clause.is_negated);
109
+ return clause.is_negated ? !is_match : is_match;
83
110
  }
84
111
 
85
112
  /**
86
- * @param {string} relation_name
87
- * @param {boolean} is_negated
88
- * @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
113
+ * @param {GraphNode} graph_node
114
+ * @param {ParsedTerm} term
115
+ * @param {EvaluationContext} evaluation_context
116
+ * @returns {boolean}
89
117
  */
90
- function createRelationPredicate(relation_name, is_negated) {
91
- return (graph_node, relation_index) => {
92
- const relation_targets = relation_index.get(graph_node.id);
93
- const is_match = relation_targets?.has(relation_name) ?? false;
118
+ function matchesTerm(graph_node, term, evaluation_context) {
119
+ if (term.kind === 'aggregate') {
120
+ return matchesAggregateTerm(graph_node, term, evaluation_context);
121
+ }
94
122
 
95
- return is_negated ? !is_match : is_match;
96
- };
123
+ if (term.kind === 'field') {
124
+ return matchesFieldTerm(graph_node, term);
125
+ }
126
+
127
+ if (term.kind === 'field_set') {
128
+ return matchesFieldSetTerm(graph_node, term);
129
+ }
130
+
131
+ if (term.kind === 'relation') {
132
+ return hasOutgoingRelation(
133
+ graph_node.id,
134
+ term.relation_name,
135
+ evaluation_context.relation_indexes,
136
+ );
137
+ }
138
+
139
+ return matchesRelationTargetTerm(graph_node, term, evaluation_context);
97
140
  }
98
141
 
99
142
  /**
100
- * @param {string} relation_name
101
- * @param {string} target_id
102
- * @param {boolean} is_negated
103
- * @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
143
+ * @param {GraphNode} graph_node
144
+ * @param {ParsedAggregateTerm} term
145
+ * @param {EvaluationContext} evaluation_context
146
+ * @returns {boolean}
104
147
  */
105
- function createRelationTargetPredicate(relation_name, target_id, is_negated) {
106
- return (graph_node, relation_index) => {
107
- const relation_targets = relation_index.get(graph_node.id);
108
- const matching_targets = relation_targets?.get(relation_name);
109
- const is_match = matching_targets?.has(target_id) ?? false;
148
+ function matchesAggregateTerm(graph_node, term, evaluation_context) {
149
+ const related_nodes = getRelatedNodes(
150
+ graph_node.id,
151
+ term.traversal,
152
+ evaluation_context,
153
+ );
154
+ const matching_count = related_nodes.filter((related_node) =>
155
+ matchesClauses(related_node, term.clauses, evaluation_context),
156
+ ).length;
110
157
 
111
- return is_negated ? !is_match : is_match;
112
- };
158
+ if (term.aggregate_name === 'any') {
159
+ return matching_count > 0;
160
+ }
161
+
162
+ if (term.aggregate_name === 'none') {
163
+ return matching_count === 0;
164
+ }
165
+
166
+ return compareNumbers(
167
+ matching_count,
168
+ term.comparison ?? '=',
169
+ term.value ?? 0,
170
+ );
113
171
  }
114
172
 
115
173
  /**
116
- * @param {BuildGraphResult['edges']} graph_edges
117
- * @returns {Map<string, Map<string, Set<string>>>}
174
+ * @param {GraphNode} graph_node
175
+ * @param {ParsedFieldTerm} term
176
+ * @returns {boolean}
118
177
  */
119
- function createRelationIndex(graph_edges) {
120
- /** @type {Map<string, Map<string, Set<string>>>} */
121
- const relation_index = new Map();
122
-
123
- for (const graph_edge of graph_edges) {
124
- let relation_targets = relation_index.get(graph_edge.from);
178
+ function matchesFieldTerm(graph_node, term) {
179
+ const term_key = `${term.field_name}${term.operator}`;
125
180
 
126
- if (!relation_targets) {
127
- relation_targets = new Map();
128
- relation_index.set(graph_edge.from, relation_targets);
129
- }
181
+ if (
182
+ term_key === 'id=' ||
183
+ term_key === 'kind=' ||
184
+ term_key === 'path=' ||
185
+ term_key === 'status='
186
+ ) {
187
+ return graph_node[term.field_name] === term.value;
188
+ }
130
189
 
131
- let target_ids = relation_targets.get(graph_edge.relation);
190
+ if (term_key === 'id^=') {
191
+ return graph_node.id.startsWith(term.value);
192
+ }
132
193
 
133
- if (!target_ids) {
134
- target_ids = new Set();
135
- relation_targets.set(graph_edge.relation, target_ids);
136
- }
194
+ if (term_key === 'path^=') {
195
+ return (graph_node.path ?? '').startsWith(term.value);
196
+ }
137
197
 
138
- target_ids.add(graph_edge.to);
198
+ if (term_key === 'title~') {
199
+ return (graph_node.title ?? '')
200
+ .toLocaleLowerCase('en')
201
+ .includes(term.value.toLocaleLowerCase('en'));
139
202
  }
140
203
 
141
- return relation_index;
204
+ throw new Error('Unsupported parsed where clause.');
142
205
  }
143
206
 
144
207
  /**
145
- * @param {string} field_name
146
- * @param {string} expected_value
147
- * @param {boolean} is_negated
148
- * @returns {(graph_node: GraphNode) => boolean}
208
+ * @param {GraphNode} graph_node
209
+ * @param {ParsedFieldSetTerm} term
210
+ * @returns {boolean}
149
211
  */
150
- function createFieldPredicate(field_name, expected_value, is_negated) {
151
- return (graph_node) => {
152
- const actual_value = graph_node[field_name];
153
- const is_match = actual_value === expected_value;
212
+ function matchesFieldSetTerm(graph_node, term) {
213
+ const actual_value = graph_node[term.field_name];
214
+ const is_member = actual_value ? term.values.includes(actual_value) : false;
154
215
 
155
- return is_negated ? !is_match : is_match;
156
- };
216
+ return term.operator === 'in' ? is_member : !is_member;
157
217
  }
158
218
 
159
219
  /**
160
- * @param {string} id_prefix
161
- * @param {boolean} is_negated
162
- * @returns {(graph_node: GraphNode) => boolean}
220
+ * @param {GraphNode} graph_node
221
+ * @param {ParsedRelationTargetTerm} term
222
+ * @param {EvaluationContext} evaluation_context
223
+ * @returns {boolean}
163
224
  */
164
- function createIdPrefixPredicate(id_prefix, is_negated) {
165
- return (graph_node) => {
166
- const id_value = graph_node.id;
167
- const is_match = id_value.startsWith(id_prefix);
225
+ function matchesRelationTargetTerm(graph_node, term, evaluation_context) {
226
+ const relation_targets = evaluation_context.relation_indexes.outgoing.get(
227
+ graph_node.id,
228
+ );
229
+ const matching_targets = relation_targets?.get(term.relation_name);
168
230
 
169
- return is_negated ? !is_match : is_match;
170
- };
231
+ return matching_targets?.has(term.target_id) ?? false;
171
232
  }
172
233
 
173
234
  /**
174
- * @param {string} path_prefix
175
- * @param {boolean} is_negated
176
- * @returns {(graph_node: GraphNode) => boolean}
235
+ * @param {string} node_id
236
+ * @param {string} relation_name
237
+ * @param {RelationIndexes} relation_indexes
238
+ * @returns {boolean}
177
239
  */
178
- function createPathPrefixPredicate(path_prefix, is_negated) {
179
- return (graph_node) => {
180
- const path_value = graph_node.path ?? '';
181
- const is_match = path_value.startsWith(path_prefix);
240
+ function hasOutgoingRelation(node_id, relation_name, relation_indexes) {
241
+ const relation_targets = relation_indexes.outgoing.get(node_id);
182
242
 
183
- return is_negated ? !is_match : is_match;
184
- };
243
+ return relation_targets?.has(relation_name) ?? false;
185
244
  }
186
245
 
187
246
  /**
188
- * @param {string} title_text
189
- * @param {boolean} is_negated
190
- * @returns {(graph_node: GraphNode) => boolean}
247
+ * @param {string} node_id
248
+ * @param {ParsedTraversalTerm} traversal
249
+ * @param {EvaluationContext} evaluation_context
250
+ * @returns {GraphNode[]}
191
251
  */
192
- function createTitlePredicate(title_text, is_negated) {
193
- const normalized_title_text = title_text.toLocaleLowerCase('en');
252
+ function getRelatedNodes(node_id, traversal, evaluation_context) {
253
+ const relation_index =
254
+ traversal.direction === 'in'
255
+ ? evaluation_context.relation_indexes.incoming
256
+ : evaluation_context.relation_indexes.outgoing;
257
+ const relation_targets = relation_index.get(node_id);
258
+ const target_ids = relation_targets?.get(traversal.relation_name);
259
+
260
+ if (!target_ids) {
261
+ return [];
262
+ }
194
263
 
195
- return (graph_node) => {
196
- const title_value = graph_node.title ?? '';
197
- const is_match = title_value
198
- .toLocaleLowerCase('en')
199
- .includes(normalized_title_text);
264
+ return [...target_ids].flatMap((target_id) => {
265
+ const target_node = evaluation_context.nodes[target_id];
200
266
 
201
- return is_negated ? !is_match : is_match;
202
- };
267
+ return target_node ? [target_node] : [];
268
+ });
203
269
  }
204
270
 
205
271
  /**
206
- * @param {{ field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field', operator: '=' | '^=' | '~', value: string }} term
207
- * @param {boolean} is_negated
208
- * @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
272
+ * @param {number} left_value
273
+ * @param {'!=' | '<' | '<=' | '=' | '>' | '>='} comparison
274
+ * @param {number} right_value
275
+ * @returns {boolean}
209
276
  */
210
- function createFieldPredicateFromTerm(term, is_negated) {
211
- const term_key = `${term.field_name}${term.operator}`;
277
+ function compareNumbers(left_value, comparison, right_value) {
278
+ if (comparison === '!=') {
279
+ return left_value !== right_value;
280
+ }
212
281
 
213
- if (
214
- term_key === 'id=' ||
215
- term_key === 'kind=' ||
216
- term_key === 'status=' ||
217
- term_key === 'path='
218
- ) {
219
- return createFieldPredicate(term.field_name, term.value, is_negated);
282
+ if (comparison === '<') {
283
+ return left_value < right_value;
220
284
  }
221
285
 
222
- if (term_key === 'id^=') {
223
- return createIdPrefixPredicate(term.value, is_negated);
286
+ if (comparison === '<=') {
287
+ return left_value <= right_value;
224
288
  }
225
289
 
226
- if (term_key === 'path^=') {
227
- return createPathPrefixPredicate(term.value, is_negated);
290
+ if (comparison === '=') {
291
+ return left_value === right_value;
228
292
  }
229
293
 
230
- if (term_key === 'title~') {
231
- return createTitlePredicate(term.value, is_negated);
294
+ if (comparison === '>') {
295
+ return left_value > right_value;
232
296
  }
233
297
 
234
- throw new Error('Unsupported parsed where clause.');
298
+ if (comparison === '>=') {
299
+ return left_value >= right_value;
300
+ }
301
+
302
+ throw new Error('Unsupported aggregate comparison.');
303
+ }
304
+
305
+ /**
306
+ * @param {BuildGraphResult['edges']} graph_edges
307
+ * @returns {RelationIndexes}
308
+ */
309
+ function createRelationIndexes(graph_edges) {
310
+ return {
311
+ incoming: createDirectionalRelationIndex(graph_edges, 'to', 'from'),
312
+ outgoing: createDirectionalRelationIndex(graph_edges, 'from', 'to'),
313
+ };
314
+ }
315
+
316
+ /**
317
+ * @param {BuildGraphResult['edges']} graph_edges
318
+ * @param {'from' | 'to'} source_key
319
+ * @param {'from' | 'to'} target_key
320
+ * @returns {Map<string, Map<string, Set<string>>>}
321
+ */
322
+ function createDirectionalRelationIndex(graph_edges, source_key, target_key) {
323
+ /** @type {Map<string, Map<string, Set<string>>>} */
324
+ const relation_index = new Map();
325
+
326
+ for (const graph_edge of graph_edges) {
327
+ const source_id = graph_edge[source_key];
328
+ const target_id = graph_edge[target_key];
329
+ let relation_targets = relation_index.get(source_id);
330
+
331
+ if (!relation_targets) {
332
+ relation_targets = new Map();
333
+ relation_index.set(source_id, relation_targets);
334
+ }
335
+
336
+ let target_ids = relation_targets.get(graph_edge.relation);
337
+
338
+ if (!target_ids) {
339
+ target_ids = new Set();
340
+ relation_targets.set(graph_edge.relation, target_ids);
341
+ }
342
+
343
+ target_ids.add(target_id);
344
+ }
345
+
346
+ return relation_index;
235
347
  }
236
348
 
237
349
  /**