patram 0.0.2 → 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 (67) hide show
  1. package/bin/patram.js +25 -147
  2. package/lib/build-graph-identity.js +270 -0
  3. package/lib/build-graph.js +156 -77
  4. package/lib/check-graph.js +23 -7
  5. package/lib/claim-helpers.js +55 -0
  6. package/lib/cli-help-metadata.js +552 -0
  7. package/lib/command-output.js +83 -0
  8. package/lib/derived-summary.js +278 -0
  9. package/lib/format-derived-summary-row.js +9 -0
  10. package/lib/format-node-header.js +19 -0
  11. package/lib/format-output-item-block.js +22 -0
  12. package/lib/format-output-metadata.js +62 -0
  13. package/lib/layout-stored-queries.js +361 -0
  14. package/lib/list-queries.js +18 -0
  15. package/lib/list-source-files.js +50 -15
  16. package/lib/load-patram-config.js +505 -18
  17. package/lib/load-patram-config.types.ts +40 -0
  18. package/lib/load-project-graph.js +124 -0
  19. package/lib/output-view.types.ts +88 -0
  20. package/lib/parse-claims.js +38 -158
  21. package/lib/parse-claims.types.ts +7 -0
  22. package/lib/parse-cli-arguments-helpers.js +446 -0
  23. package/lib/parse-cli-arguments.js +266 -0
  24. package/lib/parse-cli-arguments.types.ts +69 -0
  25. package/lib/parse-cli-color-options.js +44 -0
  26. package/lib/parse-cli-query-pagination.js +49 -0
  27. package/lib/parse-jsdoc-blocks.js +184 -0
  28. package/lib/parse-jsdoc-claims.js +280 -0
  29. package/lib/parse-jsdoc-prose.js +111 -0
  30. package/lib/parse-markdown-claims.js +242 -0
  31. package/lib/parse-markdown-directives.js +136 -0
  32. package/lib/parse-where-clause.js +707 -0
  33. package/lib/parse-where-clause.types.ts +70 -0
  34. package/lib/patram-cli.js +464 -0
  35. package/lib/patram-config.js +3 -1
  36. package/lib/patram-config.types.ts +2 -1
  37. package/lib/patram.js +6 -0
  38. package/lib/query-graph.js +368 -0
  39. package/lib/query-inspection.js +523 -0
  40. package/lib/render-check-output.js +315 -0
  41. package/lib/render-cli-help.js +419 -0
  42. package/lib/render-json-output.js +161 -0
  43. package/lib/render-output-view.js +222 -0
  44. package/lib/render-plain-output.js +182 -0
  45. package/lib/render-rich-output.js +240 -0
  46. package/lib/render-rich-source.js +1333 -0
  47. package/lib/resolve-check-target.js +190 -0
  48. package/lib/resolve-output-mode.js +60 -0
  49. package/lib/resolve-patram-graph-config.js +88 -0
  50. package/lib/resolve-where-clause.js +66 -0
  51. package/lib/show-document.js +311 -0
  52. package/lib/source-file-defaults.js +28 -0
  53. package/lib/tagged-fenced-block-error.js +17 -0
  54. package/lib/tagged-fenced-block-markdown.js +111 -0
  55. package/lib/tagged-fenced-block-metadata.js +97 -0
  56. package/lib/tagged-fenced-block-parser.js +292 -0
  57. package/lib/tagged-fenced-blocks.js +100 -0
  58. package/lib/tagged-fenced-blocks.types.ts +38 -0
  59. package/lib/write-paged-output.js +87 -0
  60. package/package.json +28 -12
  61. package/bin/patram.test.js +0 -184
  62. package/lib/build-graph.test.js +0 -141
  63. package/lib/check-graph.test.js +0 -103
  64. package/lib/list-source-files.test.js +0 -101
  65. package/lib/load-patram-config.test.js +0 -211
  66. package/lib/parse-claims.test.js +0 -113
  67. package/lib/patram-config.test.js +0 -147
@@ -0,0 +1,368 @@
1
+ /* eslint-disable max-lines */
2
+ /**
3
+ * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
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';
14
+ */
15
+
16
+ import { parseWhereClause } from './parse-where-clause.js';
17
+
18
+ /**
19
+ * Query graph filtering.
20
+ *
21
+ * Applies the v0 where-clause language to graph nodes and keeps pagination
22
+ * separate from matching.
23
+ *
24
+ * Kind: graph
25
+ * Status: active
26
+ * Uses Term: ../docs/reference/terms/graph.md
27
+ * Uses Term: ../docs/reference/terms/query.md
28
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
29
+ * Decided by: ../docs/decisions/query-language.md
30
+ * Implements: ../docs/tasks/v0/query-command.md
31
+ * @patram
32
+ * @see {@link ./load-project-graph.js}
33
+ * @see {@link ../docs/decisions/query-language.md}
34
+ */
35
+
36
+ export const DEFAULT_QUERY_LIMIT = 25;
37
+
38
+ /**
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.
54
+ *
55
+ * @param {BuildGraphResult} graph
56
+ * @param {string} where_clause
57
+ * @param {{ limit?: number, offset?: number }=} pagination_options
58
+ * @returns {{ diagnostics: PatramDiagnostic[], nodes: GraphNode[], total_count: number }}
59
+ */
60
+ export function queryGraph(graph, where_clause, pagination_options = {}) {
61
+ const parse_result = parseWhereClause(where_clause);
62
+
63
+ if (!parse_result.success) {
64
+ return {
65
+ diagnostics: [parse_result.diagnostic],
66
+ nodes: [],
67
+ total_count: 0,
68
+ };
69
+ }
70
+
71
+ const evaluation_context = {
72
+ nodes: graph.nodes,
73
+ relation_indexes: createRelationIndexes(graph.edges),
74
+ };
75
+ const graph_nodes = Object.values(graph.nodes).sort(compareGraphNodes);
76
+ const matching_nodes = graph_nodes.filter((graph_node) =>
77
+ matchesClauses(graph_node, parse_result.clauses, evaluation_context),
78
+ );
79
+ const paginated_nodes = paginateNodes(matching_nodes, pagination_options);
80
+
81
+ return {
82
+ diagnostics: [],
83
+ nodes: paginated_nodes,
84
+ total_count: matching_nodes.length,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * @param {GraphNode} graph_node
90
+ * @param {ParsedClause[]} clauses
91
+ * @param {EvaluationContext} evaluation_context
92
+ * @returns {boolean}
93
+ */
94
+ function matchesClauses(graph_node, clauses, evaluation_context) {
95
+ return clauses.every((clause) =>
96
+ matchesClause(graph_node, clause, evaluation_context),
97
+ );
98
+ }
99
+
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);
108
+
109
+ return clause.is_negated ? !is_match : is_match;
110
+ }
111
+
112
+ /**
113
+ * @param {GraphNode} graph_node
114
+ * @param {ParsedTerm} term
115
+ * @param {EvaluationContext} evaluation_context
116
+ * @returns {boolean}
117
+ */
118
+ function matchesTerm(graph_node, term, evaluation_context) {
119
+ if (term.kind === 'aggregate') {
120
+ return matchesAggregateTerm(graph_node, term, evaluation_context);
121
+ }
122
+
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);
140
+ }
141
+
142
+ /**
143
+ * @param {GraphNode} graph_node
144
+ * @param {ParsedAggregateTerm} term
145
+ * @param {EvaluationContext} evaluation_context
146
+ * @returns {boolean}
147
+ */
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;
157
+
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
+ );
171
+ }
172
+
173
+ /**
174
+ * @param {GraphNode} graph_node
175
+ * @param {ParsedFieldTerm} term
176
+ * @returns {boolean}
177
+ */
178
+ function matchesFieldTerm(graph_node, term) {
179
+ const term_key = `${term.field_name}${term.operator}`;
180
+
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
+ }
189
+
190
+ if (term_key === 'id^=') {
191
+ return graph_node.id.startsWith(term.value);
192
+ }
193
+
194
+ if (term_key === 'path^=') {
195
+ return (graph_node.path ?? '').startsWith(term.value);
196
+ }
197
+
198
+ if (term_key === 'title~') {
199
+ return (graph_node.title ?? '')
200
+ .toLocaleLowerCase('en')
201
+ .includes(term.value.toLocaleLowerCase('en'));
202
+ }
203
+
204
+ throw new Error('Unsupported parsed where clause.');
205
+ }
206
+
207
+ /**
208
+ * @param {GraphNode} graph_node
209
+ * @param {ParsedFieldSetTerm} term
210
+ * @returns {boolean}
211
+ */
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;
215
+
216
+ return term.operator === 'in' ? is_member : !is_member;
217
+ }
218
+
219
+ /**
220
+ * @param {GraphNode} graph_node
221
+ * @param {ParsedRelationTargetTerm} term
222
+ * @param {EvaluationContext} evaluation_context
223
+ * @returns {boolean}
224
+ */
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);
230
+
231
+ return matching_targets?.has(term.target_id) ?? false;
232
+ }
233
+
234
+ /**
235
+ * @param {string} node_id
236
+ * @param {string} relation_name
237
+ * @param {RelationIndexes} relation_indexes
238
+ * @returns {boolean}
239
+ */
240
+ function hasOutgoingRelation(node_id, relation_name, relation_indexes) {
241
+ const relation_targets = relation_indexes.outgoing.get(node_id);
242
+
243
+ return relation_targets?.has(relation_name) ?? false;
244
+ }
245
+
246
+ /**
247
+ * @param {string} node_id
248
+ * @param {ParsedTraversalTerm} traversal
249
+ * @param {EvaluationContext} evaluation_context
250
+ * @returns {GraphNode[]}
251
+ */
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
+ }
263
+
264
+ return [...target_ids].flatMap((target_id) => {
265
+ const target_node = evaluation_context.nodes[target_id];
266
+
267
+ return target_node ? [target_node] : [];
268
+ });
269
+ }
270
+
271
+ /**
272
+ * @param {number} left_value
273
+ * @param {'!=' | '<' | '<=' | '=' | '>' | '>='} comparison
274
+ * @param {number} right_value
275
+ * @returns {boolean}
276
+ */
277
+ function compareNumbers(left_value, comparison, right_value) {
278
+ if (comparison === '!=') {
279
+ return left_value !== right_value;
280
+ }
281
+
282
+ if (comparison === '<') {
283
+ return left_value < right_value;
284
+ }
285
+
286
+ if (comparison === '<=') {
287
+ return left_value <= right_value;
288
+ }
289
+
290
+ if (comparison === '=') {
291
+ return left_value === right_value;
292
+ }
293
+
294
+ if (comparison === '>') {
295
+ return left_value > right_value;
296
+ }
297
+
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;
347
+ }
348
+
349
+ /**
350
+ * @param {GraphNode} left_node
351
+ * @param {GraphNode} right_node
352
+ * @returns {number}
353
+ */
354
+ function compareGraphNodes(left_node, right_node) {
355
+ return left_node.id.localeCompare(right_node.id, 'en');
356
+ }
357
+
358
+ /**
359
+ * @param {GraphNode[]} matching_nodes
360
+ * @param {{ limit?: number, offset?: number }} pagination_options
361
+ * @returns {GraphNode[]}
362
+ */
363
+ function paginateNodes(matching_nodes, pagination_options) {
364
+ const offset = pagination_options.offset ?? 0;
365
+ const limit = pagination_options.limit ?? matching_nodes.length;
366
+
367
+ return matching_nodes.slice(offset, offset + limit);
368
+ }