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.
- package/bin/patram.js +25 -147
- package/lib/build-graph-identity.js +270 -0
- package/lib/build-graph.js +156 -77
- package/lib/check-graph.js +23 -7
- package/lib/claim-helpers.js +55 -0
- package/lib/cli-help-metadata.js +552 -0
- package/lib/command-output.js +83 -0
- package/lib/derived-summary.js +278 -0
- package/lib/format-derived-summary-row.js +9 -0
- package/lib/format-node-header.js +19 -0
- package/lib/format-output-item-block.js +22 -0
- package/lib/format-output-metadata.js +62 -0
- package/lib/layout-stored-queries.js +361 -0
- package/lib/list-queries.js +18 -0
- package/lib/list-source-files.js +50 -15
- package/lib/load-patram-config.js +505 -18
- package/lib/load-patram-config.types.ts +40 -0
- package/lib/load-project-graph.js +124 -0
- package/lib/output-view.types.ts +88 -0
- package/lib/parse-claims.js +38 -158
- package/lib/parse-claims.types.ts +7 -0
- package/lib/parse-cli-arguments-helpers.js +446 -0
- package/lib/parse-cli-arguments.js +266 -0
- package/lib/parse-cli-arguments.types.ts +69 -0
- package/lib/parse-cli-color-options.js +44 -0
- package/lib/parse-cli-query-pagination.js +49 -0
- package/lib/parse-jsdoc-blocks.js +184 -0
- package/lib/parse-jsdoc-claims.js +280 -0
- package/lib/parse-jsdoc-prose.js +111 -0
- package/lib/parse-markdown-claims.js +242 -0
- package/lib/parse-markdown-directives.js +136 -0
- package/lib/parse-where-clause.js +707 -0
- package/lib/parse-where-clause.types.ts +70 -0
- package/lib/patram-cli.js +464 -0
- package/lib/patram-config.js +3 -1
- package/lib/patram-config.types.ts +2 -1
- package/lib/patram.js +6 -0
- package/lib/query-graph.js +368 -0
- package/lib/query-inspection.js +523 -0
- package/lib/render-check-output.js +315 -0
- package/lib/render-cli-help.js +419 -0
- package/lib/render-json-output.js +161 -0
- package/lib/render-output-view.js +222 -0
- package/lib/render-plain-output.js +182 -0
- package/lib/render-rich-output.js +240 -0
- package/lib/render-rich-source.js +1333 -0
- package/lib/resolve-check-target.js +190 -0
- package/lib/resolve-output-mode.js +60 -0
- package/lib/resolve-patram-graph-config.js +88 -0
- package/lib/resolve-where-clause.js +66 -0
- package/lib/show-document.js +311 -0
- package/lib/source-file-defaults.js +28 -0
- package/lib/tagged-fenced-block-error.js +17 -0
- package/lib/tagged-fenced-block-markdown.js +111 -0
- package/lib/tagged-fenced-block-metadata.js +97 -0
- package/lib/tagged-fenced-block-parser.js +292 -0
- package/lib/tagged-fenced-blocks.js +100 -0
- package/lib/tagged-fenced-blocks.types.ts +38 -0
- package/lib/write-paged-output.js +87 -0
- package/package.json +28 -12
- package/bin/patram.test.js +0 -184
- package/lib/build-graph.test.js +0 -141
- package/lib/check-graph.test.js +0 -103
- package/lib/list-source-files.test.js +0 -101
- package/lib/load-patram-config.test.js +0 -211
- package/lib/parse-claims.test.js +0 -113
- 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
|
+
}
|