patram 0.0.2 → 0.1.1

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 (51) hide show
  1. package/bin/patram.js +25 -147
  2. package/lib/build-graph-identity.js +238 -0
  3. package/lib/build-graph.js +143 -77
  4. package/lib/check-graph.js +23 -7
  5. package/lib/claim-helpers.js +55 -0
  6. package/lib/command-output.js +83 -0
  7. package/lib/layout-stored-queries.js +213 -0
  8. package/lib/list-queries.js +18 -0
  9. package/lib/list-source-files.js +50 -15
  10. package/lib/load-patram-config.js +106 -18
  11. package/lib/load-patram-config.types.ts +9 -0
  12. package/lib/load-project-graph.js +124 -0
  13. package/lib/output-view.types.ts +73 -0
  14. package/lib/parse-claims.js +38 -158
  15. package/lib/parse-claims.types.ts +7 -0
  16. package/lib/parse-cli-arguments-helpers.js +273 -0
  17. package/lib/parse-cli-arguments.js +114 -0
  18. package/lib/parse-cli-arguments.types.ts +24 -0
  19. package/lib/parse-cli-color-options.js +44 -0
  20. package/lib/parse-cli-query-pagination.js +49 -0
  21. package/lib/parse-jsdoc-blocks.js +184 -0
  22. package/lib/parse-jsdoc-claims.js +280 -0
  23. package/lib/parse-jsdoc-prose.js +111 -0
  24. package/lib/parse-markdown-claims.js +242 -0
  25. package/lib/parse-markdown-directives.js +136 -0
  26. package/lib/parse-where-clause.js +312 -0
  27. package/lib/patram-cli.js +337 -0
  28. package/lib/patram-config.js +3 -1
  29. package/lib/patram-config.types.ts +2 -1
  30. package/lib/query-graph.js +256 -0
  31. package/lib/render-check-output.js +315 -0
  32. package/lib/render-json-output.js +108 -0
  33. package/lib/render-output-view.js +193 -0
  34. package/lib/render-plain-output.js +237 -0
  35. package/lib/render-rich-output.js +293 -0
  36. package/lib/render-rich-source.js +1333 -0
  37. package/lib/resolve-check-target.js +190 -0
  38. package/lib/resolve-output-mode.js +60 -0
  39. package/lib/resolve-patram-graph-config.js +88 -0
  40. package/lib/resolve-where-clause.js +51 -0
  41. package/lib/show-document.js +311 -0
  42. package/lib/source-file-defaults.js +28 -0
  43. package/lib/write-paged-output.js +87 -0
  44. package/package.json +21 -10
  45. package/bin/patram.test.js +0 -184
  46. package/lib/build-graph.test.js +0 -141
  47. package/lib/check-graph.test.js +0 -103
  48. package/lib/list-source-files.test.js +0 -101
  49. package/lib/load-patram-config.test.js +0 -211
  50. package/lib/parse-claims.test.js +0 -113
  51. package/lib/patram-config.test.js +0 -147
@@ -0,0 +1,337 @@
1
+ /* eslint-disable max-lines */
2
+ /**
3
+ * @import { ParsedCliArguments } from './parse-cli-arguments.types.ts';
4
+ */
5
+
6
+ import process from 'node:process';
7
+
8
+ import { checkGraph } from './check-graph.js';
9
+ import {
10
+ shouldPageCommandOutput,
11
+ writeCommandOutput,
12
+ } from './command-output.js';
13
+ import { listRepoFiles } from './list-source-files.js';
14
+ import { listQueries } from './list-queries.js';
15
+ import { loadPatramConfig } from './load-patram-config.js';
16
+ import { loadProjectGraph } from './load-project-graph.js';
17
+ import { parseCliArguments } from './parse-cli-arguments.js';
18
+ import { DEFAULT_QUERY_LIMIT, queryGraph } from './query-graph.js';
19
+ import {
20
+ renderCheckDiagnostics,
21
+ renderCheckSuccess,
22
+ } from './render-check-output.js';
23
+ import {
24
+ resolveCheckTarget,
25
+ selectCheckTargetDiagnostics,
26
+ selectCheckTargetSourceFiles,
27
+ } from './resolve-check-target.js';
28
+ import {
29
+ createOutputView,
30
+ createShowOutputView,
31
+ } from './render-output-view.js';
32
+ import { resolveWhereClause } from './resolve-where-clause.js';
33
+ import { resolveOutputMode } from './resolve-output-mode.js';
34
+ import { loadShowOutput } from './show-document.js';
35
+
36
+ /**
37
+ * Patram command execution flow.
38
+ *
39
+ * Loads repo state and routes `check`, `query`, `queries`, and `show` through
40
+ * the shared output pipeline.
41
+ *
42
+ * Kind: cli
43
+ * Status: active
44
+ * Implements Command: ../docs/reference/commands/check.md
45
+ * Implements Command: ../docs/reference/commands/query.md
46
+ * Implements Command: ../docs/reference/commands/queries.md
47
+ * Implements Command: ../docs/reference/commands/show.md
48
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
49
+ * Decided by: ../docs/decisions/cli-output-architecture.md
50
+ * Decided by: ../docs/decisions/cli-argument-parser.md
51
+ * @patram
52
+ * @see {@link ./parse-cli-arguments.js}
53
+ * @see {@link ./render-output-view.js}
54
+ */
55
+
56
+ /**
57
+ * Run the Patram CLI.
58
+ *
59
+ * @param {string[]} cli_arguments
60
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
61
+ * @returns {Promise<number>}
62
+ */
63
+ export async function main(cli_arguments, io_context) {
64
+ const parsed_arguments = parseCliArguments(cli_arguments);
65
+
66
+ if (!parsed_arguments.success) {
67
+ io_context.stderr.write(`${parsed_arguments.message}\n`);
68
+
69
+ return 1;
70
+ }
71
+
72
+ const parsed_command = parsed_arguments.value;
73
+
74
+ if (parsed_command.command_name === 'check') {
75
+ return runCheckCommand(parsed_command, io_context);
76
+ }
77
+
78
+ if (parsed_command.command_name === 'query') {
79
+ return runQueryCommand(parsed_command, io_context);
80
+ }
81
+
82
+ if (parsed_command.command_name === 'queries') {
83
+ return runQueriesCommand(parsed_command, io_context);
84
+ }
85
+
86
+ if (parsed_command.command_name === 'show') {
87
+ return runShowCommand(parsed_command, io_context);
88
+ }
89
+
90
+ io_context.stderr.write('Unknown command.\n');
91
+
92
+ return 1;
93
+ }
94
+
95
+ /**
96
+ * @param {ParsedCliArguments} parsed_command
97
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
98
+ * @returns {Promise<number>}
99
+ */
100
+ async function runCheckCommand(parsed_command, io_context) {
101
+ const output_mode = resolveOutputMode(parsed_command, {
102
+ is_tty: io_context.stdout.isTTY === true,
103
+ no_color: process.env.NO_COLOR !== undefined,
104
+ term: process.env.TERM,
105
+ });
106
+ const resolved_target = await resolveCheckTarget(
107
+ parsed_command.command_arguments[0],
108
+ );
109
+ const project_graph_result = await loadProjectGraph(
110
+ resolved_target.project_directory,
111
+ );
112
+ const repo_file_paths = await listRepoFiles(
113
+ resolved_target.project_directory,
114
+ );
115
+ const selected_source_file_paths = selectCheckTargetSourceFiles(
116
+ project_graph_result.source_file_paths,
117
+ resolved_target,
118
+ );
119
+
120
+ if (project_graph_result.diagnostics.length > 0) {
121
+ io_context.stderr.write(
122
+ renderCheckDiagnostics(project_graph_result.diagnostics, output_mode),
123
+ );
124
+
125
+ return 1;
126
+ }
127
+
128
+ const diagnostics = checkGraph(project_graph_result.graph, repo_file_paths);
129
+ const selected_diagnostics = selectCheckTargetDiagnostics(
130
+ diagnostics,
131
+ resolved_target,
132
+ );
133
+
134
+ if (selected_diagnostics.length > 0) {
135
+ io_context.stderr.write(
136
+ renderCheckDiagnostics(selected_diagnostics, output_mode),
137
+ );
138
+
139
+ return 1;
140
+ }
141
+
142
+ io_context.stdout.write(
143
+ renderCheckSuccess(selected_source_file_paths.length, output_mode),
144
+ );
145
+
146
+ return 0;
147
+ }
148
+
149
+ /**
150
+ * @param {ParsedCliArguments} parsed_command
151
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
152
+ * @returns {Promise<number>}
153
+ */
154
+ async function runQueryCommand(parsed_command, io_context) {
155
+ const use_pager = shouldPageCommandOutput(parsed_command, io_context.stdout);
156
+ const project_graph_result = await loadProjectGraph(process.cwd());
157
+
158
+ if (project_graph_result.diagnostics.length > 0) {
159
+ writeDiagnostics(io_context.stderr, project_graph_result.diagnostics);
160
+
161
+ return 1;
162
+ }
163
+
164
+ const where_clause = resolveWhereClause(
165
+ project_graph_result.config,
166
+ parsed_command.command_arguments,
167
+ );
168
+
169
+ if (!where_clause.success) {
170
+ io_context.stderr.write(`${where_clause.message}\n`);
171
+
172
+ return 1;
173
+ }
174
+
175
+ const query_result = queryGraph(
176
+ project_graph_result.graph,
177
+ where_clause.value,
178
+ createQueryPaginationOptions(parsed_command, use_pager),
179
+ );
180
+
181
+ if (query_result.diagnostics.length > 0) {
182
+ writeDiagnostics(io_context.stderr, query_result.diagnostics);
183
+
184
+ return 1;
185
+ }
186
+
187
+ await writeCommandOutput(
188
+ io_context,
189
+ parsed_command,
190
+ createOutputView(
191
+ 'query',
192
+ query_result.nodes,
193
+ createQueryOutputOptions(parsed_command, query_result, use_pager),
194
+ ),
195
+ );
196
+
197
+ return 0;
198
+ }
199
+
200
+ /**
201
+ * @param {ParsedCliArguments} parsed_command
202
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
203
+ * @returns {Promise<number>}
204
+ */
205
+ async function runQueriesCommand(parsed_command, io_context) {
206
+ const load_result = await loadPatramConfig(process.cwd());
207
+
208
+ if (load_result.diagnostics.length > 0) {
209
+ writeDiagnostics(io_context.stderr, load_result.diagnostics);
210
+
211
+ return 1;
212
+ }
213
+
214
+ const repo_config = load_result.config;
215
+
216
+ if (!repo_config) {
217
+ throw new Error('Expected a valid Patram repo config.');
218
+ }
219
+
220
+ await writeCommandOutput(
221
+ io_context,
222
+ parsed_command,
223
+ createOutputView('queries', listQueries(repo_config.queries)),
224
+ );
225
+
226
+ return 0;
227
+ }
228
+
229
+ /**
230
+ * @param {ParsedCliArguments} parsed_command
231
+ * @param {{ stderr: { write(chunk: string): boolean }, stdout: { isTTY?: boolean, write(chunk: string): boolean }, write_paged_output?: (output_text: string) => Promise<void> }} io_context
232
+ * @returns {Promise<number>}
233
+ */
234
+ async function runShowCommand(parsed_command, io_context) {
235
+ const project_graph_result = await loadProjectGraph(process.cwd());
236
+
237
+ if (project_graph_result.diagnostics.length > 0) {
238
+ writeDiagnostics(io_context.stderr, project_graph_result.diagnostics);
239
+
240
+ return 1;
241
+ }
242
+
243
+ const show_output = await loadShowOutput(
244
+ parsed_command.command_arguments[0],
245
+ process.cwd(),
246
+ project_graph_result.graph,
247
+ );
248
+
249
+ if (!show_output.success) {
250
+ writeDiagnostics(io_context.stderr, [show_output.diagnostic]);
251
+
252
+ return 1;
253
+ }
254
+
255
+ await writeCommandOutput(
256
+ io_context,
257
+ parsed_command,
258
+ createShowOutputView(show_output.value),
259
+ );
260
+
261
+ return 0;
262
+ }
263
+
264
+ /**
265
+ * @param {{ write(chunk: string): boolean }} output_stream
266
+ * @param {import('./load-patram-config.types.ts').PatramDiagnostic[]} diagnostics
267
+ */
268
+ function writeDiagnostics(output_stream, diagnostics) {
269
+ for (const diagnostic of diagnostics) {
270
+ output_stream.write(formatDiagnostic(diagnostic));
271
+ }
272
+ }
273
+
274
+ /**
275
+ * @param {ParsedCliArguments} parsed_command
276
+ * @param {{ total_count: number, nodes: import('./build-graph.types.ts').GraphNode[] }} query_result
277
+ * @param {boolean} use_pager
278
+ * @returns {{ hints: string[], limit: number, offset: number, total_count: number }}
279
+ */
280
+ function createQueryOutputOptions(parsed_command, query_result, use_pager) {
281
+ /** @type {string[]} */
282
+ const hints = [];
283
+ const limit =
284
+ parsed_command.query_limit ??
285
+ (use_pager ? query_result.nodes.length : DEFAULT_QUERY_LIMIT);
286
+ const offset = parsed_command.query_offset ?? 0;
287
+
288
+ if (query_result.total_count === 0) {
289
+ hints.push('Try: patram query --where "kind=task"');
290
+ }
291
+
292
+ if (
293
+ !use_pager &&
294
+ parsed_command.query_limit === undefined &&
295
+ parsed_command.query_offset === undefined &&
296
+ query_result.total_count > DEFAULT_QUERY_LIMIT
297
+ ) {
298
+ hints.push(
299
+ 'Hint: use --offset <n> or --limit <n> to page through more matches.',
300
+ );
301
+ }
302
+
303
+ return {
304
+ hints,
305
+ limit,
306
+ offset,
307
+ total_count: query_result.total_count,
308
+ };
309
+ }
310
+
311
+ /**
312
+ * @param {ParsedCliArguments} parsed_command
313
+ * @param {boolean} use_pager
314
+ * @returns {{ limit?: number, offset: number }}
315
+ */
316
+ function createQueryPaginationOptions(parsed_command, use_pager) {
317
+ /** @type {{ limit?: number, offset: number }} */
318
+ const pagination_options = {
319
+ offset: parsed_command.query_offset ?? 0,
320
+ };
321
+
322
+ if (parsed_command.query_limit !== undefined) {
323
+ pagination_options.limit = parsed_command.query_limit;
324
+ } else if (!use_pager) {
325
+ pagination_options.limit = DEFAULT_QUERY_LIMIT;
326
+ }
327
+
328
+ return pagination_options;
329
+ }
330
+
331
+ /**
332
+ * @param {import('./load-patram-config.types.ts').PatramDiagnostic} diagnostic
333
+ * @returns {string}
334
+ */
335
+ function formatDiagnostic(diagnostic) {
336
+ return `${diagnostic.path}:${diagnostic.line}:${diagnostic.column} ${diagnostic.level} ${diagnostic.code} ${diagnostic.message}\n`;
337
+ }
@@ -8,7 +8,8 @@ import { z } from 'zod';
8
8
  const KIND_NAME_SCHEMA = z.string().min(1);
9
9
  const RELATION_NAME_SCHEMA = z.string().min(1);
10
10
  const CLAIM_TYPE_SCHEMA = z.string().min(1);
11
- const TARGET_SCHEMA = z.enum(['path']);
11
+ const KEY_SOURCE_SCHEMA = z.enum(['path', 'value']);
12
+ const TARGET_SCHEMA = z.enum(['path', 'value']);
12
13
 
13
14
  const kind_definition_schema = z
14
15
  .object({
@@ -28,6 +29,7 @@ const relation_definition_schema = z
28
29
  const mapping_node_schema = z
29
30
  .object({
30
31
  field: z.string().min(1),
32
+ key: KEY_SOURCE_SCHEMA.optional(),
31
33
  kind: KIND_NAME_SCHEMA,
32
34
  })
33
35
  .strict();
@@ -11,12 +11,13 @@ export interface RelationDefinition {
11
11
 
12
12
  export interface MappingNodeDefinition {
13
13
  field: string;
14
+ key?: 'path' | 'value';
14
15
  kind: string;
15
16
  }
16
17
 
17
18
  export interface MappingEmitDefinition {
18
19
  relation: string;
19
- target: 'path';
20
+ target: 'path' | 'value';
20
21
  target_kind: string;
21
22
  }
22
23
 
@@ -0,0 +1,256 @@
1
+ /**
2
+ * @import { BuildGraphResult, GraphNode } from './build-graph.types.ts';
3
+ * @import { PatramDiagnostic } from './load-patram-config.types.ts';
4
+ */
5
+
6
+ import { parseWhereClause } from './parse-where-clause.js';
7
+
8
+ /**
9
+ * Query graph filtering.
10
+ *
11
+ * Applies the v0 where-clause language to graph nodes and keeps pagination
12
+ * separate from matching.
13
+ *
14
+ * Kind: graph
15
+ * Status: active
16
+ * Uses Term: ../docs/reference/terms/graph.md
17
+ * Uses Term: ../docs/reference/terms/query.md
18
+ * Tracked in: ../docs/plans/v0/source-anchor-dogfooding.md
19
+ * Decided by: ../docs/decisions/query-language.md
20
+ * Implements: ../docs/tasks/v0/query-command.md
21
+ * @patram
22
+ * @see {@link ./load-project-graph.js}
23
+ * @see {@link ../docs/decisions/query-language.md}
24
+ */
25
+
26
+ export const DEFAULT_QUERY_LIMIT = 25;
27
+
28
+ /**
29
+ * Filter graph nodes with the v0 query language.
30
+ *
31
+ * @param {BuildGraphResult} graph
32
+ * @param {string} where_clause
33
+ * @param {{ limit?: number, offset?: number }=} pagination_options
34
+ * @returns {{ diagnostics: PatramDiagnostic[], nodes: GraphNode[], total_count: number }}
35
+ */
36
+ export function queryGraph(graph, where_clause, pagination_options = {}) {
37
+ const parse_result = parseWhereClause(where_clause);
38
+
39
+ if (!parse_result.success) {
40
+ return {
41
+ diagnostics: [parse_result.diagnostic],
42
+ nodes: [],
43
+ total_count: 0,
44
+ };
45
+ }
46
+
47
+ const predicates = parse_result.clauses.map(createPredicate);
48
+ const relation_index = createRelationIndex(graph.edges);
49
+ const graph_nodes = Object.values(graph.nodes).sort(compareGraphNodes);
50
+ const matching_nodes = graph_nodes.filter((graph_node) =>
51
+ predicates.every((predicate) => predicate(graph_node, relation_index)),
52
+ );
53
+ const paginated_nodes = paginateNodes(matching_nodes, pagination_options);
54
+
55
+ return {
56
+ diagnostics: [],
57
+ nodes: paginated_nodes,
58
+ total_count: matching_nodes.length,
59
+ };
60
+ }
61
+
62
+ /**
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}
65
+ */
66
+ function createPredicate(clause) {
67
+ if (clause.term.kind === 'relation') {
68
+ return createRelationPredicate(
69
+ clause.term.relation_name,
70
+ clause.is_negated,
71
+ );
72
+ }
73
+
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
+ }
81
+
82
+ return createFieldPredicateFromTerm(clause.term, clause.is_negated);
83
+ }
84
+
85
+ /**
86
+ * @param {string} relation_name
87
+ * @param {boolean} is_negated
88
+ * @returns {(graph_node: GraphNode, relation_index: Map<string, Map<string, Set<string>>>) => boolean}
89
+ */
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;
94
+
95
+ return is_negated ? !is_match : is_match;
96
+ };
97
+ }
98
+
99
+ /**
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}
104
+ */
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;
110
+
111
+ return is_negated ? !is_match : is_match;
112
+ };
113
+ }
114
+
115
+ /**
116
+ * @param {BuildGraphResult['edges']} graph_edges
117
+ * @returns {Map<string, Map<string, Set<string>>>}
118
+ */
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);
125
+
126
+ if (!relation_targets) {
127
+ relation_targets = new Map();
128
+ relation_index.set(graph_edge.from, relation_targets);
129
+ }
130
+
131
+ let target_ids = relation_targets.get(graph_edge.relation);
132
+
133
+ if (!target_ids) {
134
+ target_ids = new Set();
135
+ relation_targets.set(graph_edge.relation, target_ids);
136
+ }
137
+
138
+ target_ids.add(graph_edge.to);
139
+ }
140
+
141
+ return relation_index;
142
+ }
143
+
144
+ /**
145
+ * @param {string} field_name
146
+ * @param {string} expected_value
147
+ * @param {boolean} is_negated
148
+ * @returns {(graph_node: GraphNode) => boolean}
149
+ */
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;
154
+
155
+ return is_negated ? !is_match : is_match;
156
+ };
157
+ }
158
+
159
+ /**
160
+ * @param {string} id_prefix
161
+ * @param {boolean} is_negated
162
+ * @returns {(graph_node: GraphNode) => boolean}
163
+ */
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);
168
+
169
+ return is_negated ? !is_match : is_match;
170
+ };
171
+ }
172
+
173
+ /**
174
+ * @param {string} path_prefix
175
+ * @param {boolean} is_negated
176
+ * @returns {(graph_node: GraphNode) => boolean}
177
+ */
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);
182
+
183
+ return is_negated ? !is_match : is_match;
184
+ };
185
+ }
186
+
187
+ /**
188
+ * @param {string} title_text
189
+ * @param {boolean} is_negated
190
+ * @returns {(graph_node: GraphNode) => boolean}
191
+ */
192
+ function createTitlePredicate(title_text, is_negated) {
193
+ const normalized_title_text = title_text.toLocaleLowerCase('en');
194
+
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);
200
+
201
+ return is_negated ? !is_match : is_match;
202
+ };
203
+ }
204
+
205
+ /**
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}
209
+ */
210
+ function createFieldPredicateFromTerm(term, is_negated) {
211
+ const term_key = `${term.field_name}${term.operator}`;
212
+
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);
220
+ }
221
+
222
+ if (term_key === 'id^=') {
223
+ return createIdPrefixPredicate(term.value, is_negated);
224
+ }
225
+
226
+ if (term_key === 'path^=') {
227
+ return createPathPrefixPredicate(term.value, is_negated);
228
+ }
229
+
230
+ if (term_key === 'title~') {
231
+ return createTitlePredicate(term.value, is_negated);
232
+ }
233
+
234
+ throw new Error('Unsupported parsed where clause.');
235
+ }
236
+
237
+ /**
238
+ * @param {GraphNode} left_node
239
+ * @param {GraphNode} right_node
240
+ * @returns {number}
241
+ */
242
+ function compareGraphNodes(left_node, right_node) {
243
+ return left_node.id.localeCompare(right_node.id, 'en');
244
+ }
245
+
246
+ /**
247
+ * @param {GraphNode[]} matching_nodes
248
+ * @param {{ limit?: number, offset?: number }} pagination_options
249
+ * @returns {GraphNode[]}
250
+ */
251
+ function paginateNodes(matching_nodes, pagination_options) {
252
+ const offset = pagination_options.offset ?? 0;
253
+ const limit = pagination_options.limit ?? matching_nodes.length;
254
+
255
+ return matching_nodes.slice(offset, offset + limit);
256
+ }