patram 0.2.0 → 0.3.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.
@@ -0,0 +1,427 @@
1
+ /* eslint-disable max-lines, max-lines-per-function */
2
+ /**
3
+ * @import { ClaimOrigin, PatramClaim } from './parse-claims.types.ts';
4
+ * @import {
5
+ * DiscoveredFieldMultiplicity,
6
+ * DiscoveredFieldTypeName,
7
+ * FieldDiscoveryClassUsage,
8
+ * FieldDiscoveryEvidenceReference,
9
+ * FieldDiscoveryMultiplicitySuggestion,
10
+ * FieldDiscoveryResult,
11
+ * FieldDiscoverySuggestion,
12
+ * FieldDiscoveryTypeSuggestion,
13
+ * } from './discover-fields.types.ts';
14
+ */
15
+
16
+ import { readFile } from 'node:fs/promises';
17
+ import process from 'node:process';
18
+ import { resolve } from 'node:path';
19
+
20
+ import { DEFAULT_INCLUDE_PATTERNS } from './source-file-defaults.js';
21
+ import { listSourceFiles } from './list-source-files.js';
22
+ import { parseSourceFile } from './parse-claims.js';
23
+
24
+ /**
25
+ * Field discovery from source claims.
26
+ *
27
+ * Scans the repository source files directly, infers likely metadata fields,
28
+ * and reports advisory suggestions without requiring repo config to load.
29
+ *
30
+ * Kind: discovery
31
+ * Status: active
32
+ * Tracked in: ../docs/plans/v1/field-model-redesign.md
33
+ * Decided by: ../docs/decisions/field-discovery-workflow.md
34
+ * @patram
35
+ * @see {@link ./render-field-discovery.js}
36
+ */
37
+
38
+ const TYPE_NAME_ORDER = /** @type {const} */ ([
39
+ 'integer',
40
+ 'date_time',
41
+ 'date',
42
+ 'glob',
43
+ 'path',
44
+ 'enum',
45
+ 'string',
46
+ ]);
47
+
48
+ const INTEGER_PATTERN = /^-?\d+$/du;
49
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/du;
50
+ const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/du;
51
+ const ENUM_PATTERN = /^[a-z0-9_][a-z0-9_-]*$/du;
52
+ const PATH_PATTERN = /^[a-z0-9_.-]+\.[a-z0-9]+$/du;
53
+
54
+ /**
55
+ * @typedef {FieldDiscoveryClassUsage & { confidence: number }} InferredFieldClassUsage
56
+ */
57
+
58
+ /**
59
+ * @typedef {(value: string) => number} FieldTypeScorer
60
+ */
61
+
62
+ /**
63
+ * Discover likely field schema from source files.
64
+ *
65
+ * @param {string} [project_directory]
66
+ * @returns {Promise<FieldDiscoveryResult>}
67
+ */
68
+ export async function discoverFields(project_directory = process.cwd()) {
69
+ const source_file_paths = await listSourceFiles(
70
+ DEFAULT_INCLUDE_PATTERNS,
71
+ project_directory,
72
+ );
73
+ const parse_results = await Promise.all(
74
+ source_file_paths.map(async (source_file_path) => {
75
+ const source_text = await readFile(
76
+ resolve(project_directory, source_file_path),
77
+ 'utf8',
78
+ );
79
+
80
+ return {
81
+ claims: parseSourceFile({
82
+ path: source_file_path,
83
+ source: source_text,
84
+ }).claims,
85
+ path: source_file_path,
86
+ };
87
+ }),
88
+ );
89
+ /** @type {FieldObservation[]} */
90
+ const field_observations = parse_results.flatMap((parse_result) => {
91
+ /** @type {Set<string>} */
92
+ const document_classes = new Set();
93
+
94
+ for (const claim of parse_result.claims) {
95
+ if (
96
+ claim.type === 'directive' &&
97
+ claim.name === 'kind' &&
98
+ typeof claim.value === 'string' &&
99
+ claim.value.length > 0
100
+ ) {
101
+ document_classes.add(claim.value);
102
+ }
103
+ }
104
+
105
+ return parse_result.claims.flatMap((claim) => {
106
+ if (
107
+ claim.type !== 'directive' ||
108
+ !claim.name ||
109
+ claim.name.startsWith('$') ||
110
+ typeof claim.value !== 'string' ||
111
+ claim.value.length === 0
112
+ ) {
113
+ return [];
114
+ }
115
+
116
+ return [
117
+ {
118
+ class_names: new Set(document_classes),
119
+ document_id: claim.document_id,
120
+ name: claim.name,
121
+ origin: claim.origin,
122
+ value: claim.value,
123
+ },
124
+ ];
125
+ });
126
+ });
127
+ /** @type {Map<string, FieldBucket>} */
128
+ const field_buckets = field_observations.reduce(
129
+ (buckets, field_observation) => {
130
+ const bucket = buckets.get(field_observation.name) ?? {
131
+ name: field_observation.name,
132
+ observations: [],
133
+ };
134
+
135
+ bucket.observations.push(field_observation);
136
+ buckets.set(field_observation.name, bucket);
137
+ return buckets;
138
+ },
139
+ new Map(),
140
+ );
141
+ const fields = [...field_buckets.values()]
142
+ .map(buildFieldSuggestion)
143
+ .sort((left_suggestion, right_suggestion) =>
144
+ left_suggestion.confidence !== right_suggestion.confidence
145
+ ? right_suggestion.confidence - left_suggestion.confidence
146
+ : left_suggestion.name.localeCompare(right_suggestion.name, 'en'),
147
+ );
148
+
149
+ return {
150
+ fields,
151
+ summary: {
152
+ claim_count: parse_results.reduce(
153
+ (sum, parse_result) => sum + parse_result.claims.length,
154
+ 0,
155
+ ),
156
+ count: fields.length,
157
+ source_file_count: source_file_paths.length,
158
+ },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * @param {FieldBucket} field_bucket
164
+ * @returns {FieldDiscoverySuggestion}
165
+ */
166
+ function buildFieldSuggestion(field_bucket) {
167
+ const type_result = inferFieldType(field_bucket.observations);
168
+ const multiplicity_result = inferFieldMultiplicity(field_bucket.observations);
169
+ const class_usage_result = inferFieldClassUsage(field_bucket.observations);
170
+ const evidence_references = buildEvidenceReferences(
171
+ field_bucket.observations,
172
+ );
173
+ const conflicting_evidence = buildEvidenceReferences(
174
+ field_bucket.observations.filter(
175
+ (field_observation) =>
176
+ scoreFieldValue(field_observation.value, type_result.name) === 0,
177
+ ),
178
+ );
179
+
180
+ return {
181
+ confidence:
182
+ Math.round(
183
+ ((type_result.confidence +
184
+ multiplicity_result.confidence +
185
+ class_usage_result.confidence) /
186
+ 3) *
187
+ 100,
188
+ ) / 100,
189
+ conflicting_evidence,
190
+ evidence_references,
191
+ likely_class_usage: {
192
+ classes: class_usage_result.classes,
193
+ },
194
+ likely_multiplicity: multiplicity_result,
195
+ likely_type: type_result,
196
+ name: field_bucket.name,
197
+ };
198
+ }
199
+
200
+ /**
201
+ * @param {FieldObservation[]} observations
202
+ * @returns {FieldDiscoveryEvidenceReference[]}
203
+ */
204
+ function buildEvidenceReferences(observations) {
205
+ return observations
206
+ .map((observation) => ({
207
+ column: observation.origin.column,
208
+ line: observation.origin.line,
209
+ path: observation.origin.path,
210
+ value: observation.value,
211
+ }))
212
+ .sort(compareEvidenceReferences);
213
+ }
214
+
215
+ /**
216
+ * @param {FieldObservation[]} observations
217
+ * @returns {FieldDiscoveryMultiplicitySuggestion}
218
+ */
219
+ function inferFieldMultiplicity(observations) {
220
+ /** @type {Map<string, Set<string>>} */
221
+ const values_by_document = observations.reduce((values, observation) => {
222
+ const current_values = values.get(observation.document_id);
223
+
224
+ if (current_values) {
225
+ current_values.add(observation.value);
226
+ } else {
227
+ values.set(observation.document_id, new Set([observation.value]));
228
+ }
229
+
230
+ return values;
231
+ }, new Map());
232
+ const repeated_identical_documents = [...values_by_document.values()].reduce(
233
+ (count, values) => {
234
+ if (values.size > 1) {
235
+ return Infinity;
236
+ }
237
+
238
+ return values.size === 1 ? count + 1 : count;
239
+ },
240
+ 0,
241
+ );
242
+
243
+ if (repeated_identical_documents === Infinity) {
244
+ return {
245
+ confidence: 1,
246
+ name: 'multiple',
247
+ };
248
+ }
249
+
250
+ return {
251
+ confidence:
252
+ Math.round(
253
+ (values_by_document.size > 1 && repeated_identical_documents > 0
254
+ ? 0.9
255
+ : 0.8) * 100,
256
+ ) / 100,
257
+ name: 'single',
258
+ };
259
+ }
260
+
261
+ /**
262
+ * @param {FieldObservation[]} observations
263
+ * @returns {InferredFieldClassUsage}
264
+ */
265
+ function inferFieldClassUsage(observations) {
266
+ /** @type {Map<string, number>} */
267
+ const class_counts = new Map();
268
+ let documented_observation_count = 0;
269
+
270
+ for (const observation of observations) {
271
+ if (observation.class_names.size === 0) {
272
+ continue;
273
+ }
274
+
275
+ documented_observation_count += 1;
276
+
277
+ for (const class_name of observation.class_names) {
278
+ class_counts.set(class_name, (class_counts.get(class_name) ?? 0) + 1);
279
+ }
280
+ }
281
+
282
+ if (class_counts.size === 0) {
283
+ return {
284
+ confidence: 0.2,
285
+ classes: ['document'],
286
+ };
287
+ }
288
+
289
+ return {
290
+ confidence:
291
+ Math.round(
292
+ (documented_observation_count / Math.max(observations.length, 1)) * 100,
293
+ ) / 100,
294
+ classes: [...class_counts.keys()].sort((left_class, right_class) =>
295
+ left_class.localeCompare(right_class, 'en'),
296
+ ),
297
+ };
298
+ }
299
+
300
+ /**
301
+ * @param {FieldObservation[]} observations
302
+ * @returns {FieldDiscoveryTypeSuggestion}
303
+ */
304
+ function inferFieldType(observations) {
305
+ /** @type {FieldDiscoveryTypeSuggestion[]} */
306
+ const type_candidates = TYPE_NAME_ORDER.map((type_name) => ({
307
+ confidence: scoreFieldType(observations, type_name),
308
+ name: type_name,
309
+ }));
310
+
311
+ type_candidates.sort((left_candidate, right_candidate) => {
312
+ if (left_candidate.confidence !== right_candidate.confidence) {
313
+ return right_candidate.confidence - left_candidate.confidence;
314
+ }
315
+
316
+ return (
317
+ TYPE_NAME_ORDER.indexOf(left_candidate.name) -
318
+ TYPE_NAME_ORDER.indexOf(right_candidate.name)
319
+ );
320
+ });
321
+
322
+ return type_candidates[0];
323
+ }
324
+
325
+ /**
326
+ * @param {FieldDiscoveryEvidenceReference} left_reference
327
+ * @param {FieldDiscoveryEvidenceReference} right_reference
328
+ * @returns {number}
329
+ */
330
+ function compareEvidenceReferences(left_reference, right_reference) {
331
+ const path_compare = left_reference.path.localeCompare(
332
+ right_reference.path,
333
+ 'en',
334
+ );
335
+
336
+ if (path_compare !== 0) {
337
+ return path_compare;
338
+ }
339
+
340
+ if (left_reference.line !== right_reference.line) {
341
+ return left_reference.line - right_reference.line;
342
+ }
343
+
344
+ if (left_reference.column !== right_reference.column) {
345
+ return left_reference.column - right_reference.column;
346
+ }
347
+
348
+ return left_reference.value.localeCompare(right_reference.value, 'en');
349
+ }
350
+
351
+ /**
352
+ * @param {FieldObservation[]} observations
353
+ * @param {DiscoveredFieldTypeName} field_type_name
354
+ * @returns {number}
355
+ */
356
+ function scoreFieldType(observations, field_type_name) {
357
+ if (observations.length === 0) {
358
+ return 0;
359
+ }
360
+
361
+ const total_score = observations.reduce(
362
+ (sum, observation) =>
363
+ sum + scoreFieldValue(observation.value, field_type_name),
364
+ 0,
365
+ );
366
+
367
+ return Math.round((total_score / observations.length) * 100) / 100;
368
+ }
369
+
370
+ /**
371
+ * @param {string} value
372
+ * @param {DiscoveredFieldTypeName} field_type_name
373
+ * @returns {number}
374
+ */
375
+ function scoreFieldValue(value, field_type_name) {
376
+ const scorer = FIELD_TYPE_SCORERS[field_type_name];
377
+ return scorer ? scorer(value) : 0;
378
+ }
379
+
380
+ /** @type {Record<DiscoveredFieldTypeName, FieldTypeScorer>} */
381
+ const FIELD_TYPE_SCORERS = {
382
+ date: (value) => (DATE_PATTERN.test(value) ? 1 : 0),
383
+ date_time: (value) => (DATE_TIME_PATTERN.test(value) ? 1 : 0),
384
+ enum: (value) =>
385
+ ENUM_PATTERN.test(value) && value.includes(' ') === false ? 1 : 0,
386
+ glob: (value) =>
387
+ value.includes('*') ||
388
+ value.includes('?') ||
389
+ value.includes('[') ||
390
+ value.includes(']')
391
+ ? 1
392
+ : 0,
393
+ integer: (value) => (INTEGER_PATTERN.test(value) ? 1 : 0),
394
+ path: (value) =>
395
+ !(
396
+ value.includes('/') ||
397
+ PATH_PATTERN.test(value) ||
398
+ value.startsWith('docs/') ||
399
+ value.startsWith('lib/') ||
400
+ value.startsWith('test/')
401
+ )
402
+ ? 0
403
+ : value.includes('*') ||
404
+ value.includes('?') ||
405
+ value.includes('[') ||
406
+ value.includes(']')
407
+ ? 0.8
408
+ : 1,
409
+ string: () => 0.5,
410
+ };
411
+
412
+ /**
413
+ * @typedef {{
414
+ * class_names: Set<string>,
415
+ * document_id: string,
416
+ * name: string,
417
+ * origin: ClaimOrigin,
418
+ * value: string,
419
+ * }} FieldObservation
420
+ */
421
+
422
+ /**
423
+ * @typedef {{
424
+ * name: string,
425
+ * observations: FieldObservation[],
426
+ * }} FieldBucket
427
+ */
@@ -0,0 +1,52 @@
1
+ export type DiscoveredFieldTypeName =
2
+ | 'date'
3
+ | 'date_time'
4
+ | 'enum'
5
+ | 'glob'
6
+ | 'integer'
7
+ | 'path'
8
+ | 'string';
9
+
10
+ export type DiscoveredFieldMultiplicity = 'multiple' | 'single';
11
+
12
+ export interface FieldDiscoveryEvidenceReference {
13
+ column: number;
14
+ line: number;
15
+ path: string;
16
+ value: string;
17
+ }
18
+
19
+ export interface FieldDiscoveryClassUsage {
20
+ classes: string[];
21
+ }
22
+
23
+ export interface FieldDiscoveryTypeSuggestion {
24
+ confidence: number;
25
+ name: DiscoveredFieldTypeName;
26
+ }
27
+
28
+ export interface FieldDiscoveryMultiplicitySuggestion {
29
+ confidence: number;
30
+ name: DiscoveredFieldMultiplicity;
31
+ }
32
+
33
+ export interface FieldDiscoverySuggestion {
34
+ confidence: number;
35
+ conflicting_evidence: FieldDiscoveryEvidenceReference[];
36
+ evidence_references: FieldDiscoveryEvidenceReference[];
37
+ likely_class_usage: FieldDiscoveryClassUsage;
38
+ likely_multiplicity: FieldDiscoveryMultiplicitySuggestion;
39
+ likely_type: FieldDiscoveryTypeSuggestion;
40
+ name: string;
41
+ }
42
+
43
+ export interface FieldDiscoverySummary {
44
+ claim_count: number;
45
+ count: number;
46
+ source_file_count: number;
47
+ }
48
+
49
+ export interface FieldDiscoveryResult {
50
+ fields: FieldDiscoverySuggestion[];
51
+ summary: FieldDiscoverySummary;
52
+ }
@@ -3,17 +3,19 @@
3
3
  * @returns {string}
4
4
  */
5
5
  export function formatNodeHeader(output_item) {
6
- if (isDocumentNode(output_item)) {
7
- return `document ${output_item.path}`;
6
+ if (output_item.path) {
7
+ return `${output_item.node_kind} ${output_item.path}`;
8
8
  }
9
9
 
10
- return `${output_item.node_kind} ${output_item.id}`;
10
+ return `${output_item.node_kind} ${getOutputNodeKey(output_item.id)}`;
11
11
  }
12
12
 
13
13
  /**
14
- * @param {import('./output-view.types.ts').OutputNodeItem} output_item
15
- * @returns {boolean}
14
+ * @param {string} node_id
15
+ * @returns {string}
16
16
  */
17
- export function isDocumentNode(output_item) {
18
- return output_item.id === `doc:${output_item.path}`;
17
+ function getOutputNodeKey(node_id) {
18
+ return node_id.includes(':')
19
+ ? node_id.split(':').slice(1).join(':')
20
+ : node_id;
19
21
  }
@@ -1,5 +1,4 @@
1
1
  import { formatDerivedSummaryRow } from './format-derived-summary-row.js';
2
- import { isDocumentNode } from './format-node-header.js';
3
2
 
4
3
  /**
5
4
  * @param {import('./output-view.types.ts').OutputNodeItem} output_item
@@ -8,18 +7,8 @@ import { isDocumentNode } from './format-node-header.js';
8
7
  export function formatOutputNodeMetadataRows(output_item) {
9
8
  /** @type {string[]} */
10
9
  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
- }
10
+ const stored_metadata_fields =
11
+ output_item.visible_fields.map(formatMetadataField);
23
12
 
24
13
  if (stored_metadata_fields.length > 0) {
25
14
  metadata_rows.push(stored_metadata_fields.join(' '));
@@ -39,16 +28,7 @@ export function formatOutputNodeMetadataRows(output_item) {
39
28
  export function formatResolvedLinkMetadataRows(target) {
40
29
  /** @type {string[]} */
41
30
  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
- }
31
+ const stored_metadata_fields = target.visible_fields.map(formatMetadataField);
52
32
 
53
33
  if (stored_metadata_fields.length > 0) {
54
34
  metadata_rows.push(stored_metadata_fields.join(' '));
@@ -60,3 +40,15 @@ export function formatResolvedLinkMetadataRows(target) {
60
40
 
61
41
  return metadata_rows;
62
42
  }
43
+
44
+ /**
45
+ * @param {import('./output-view.types.ts').OutputMetadataField} output_field
46
+ * @returns {string}
47
+ */
48
+ function formatMetadataField(output_field) {
49
+ const value = Array.isArray(output_field.value)
50
+ ? output_field.value.join(', ')
51
+ : output_field.value;
52
+
53
+ return `${output_field.name}: ${value}`;
54
+ }
@@ -1,4 +1,3 @@
1
- /* eslint-disable max-lines */
2
1
  /**
3
2
  * @import { OutputStoredQueryItem } from './output-view.types.ts';
4
3
  */
@@ -98,22 +97,7 @@ function createStoredQueryPhrases(where_clause) {
98
97
  }
99
98
 
100
99
  /**
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
100
+ * @param {import('./parse-where-clause.types.ts').ParsedClause} clause
117
101
  * @param {boolean} should_prefix_and
118
102
  * @returns {StoredQuerySegment[]}
119
103
  */
@@ -137,20 +121,7 @@ function createClausePhrase(clause, should_prefix_and) {
137
121
  }
138
122
 
139
123
  /**
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
124
+ * @param {import('./parse-where-clause.types.ts').ParsedTerm} term
154
125
  * @returns {StoredQuerySegment[]}
155
126
  */
156
127
  function createTermSegments(term) {
@@ -185,7 +156,7 @@ function createTermSegments(term) {
185
156
  }
186
157
 
187
158
  /**
188
- * @param {{ field_name: 'id' | 'kind' | 'path' | 'status' | 'title', kind: 'field_set', operator: 'in' | 'not in', values: string[] }} term
159
+ * @param {import('./parse-where-clause.types.ts').ParsedFieldSetTerm} term
189
160
  * @returns {StoredQuerySegment[]}
190
161
  */
191
162
  function createFieldSetSegments(term) {
@@ -201,14 +172,7 @@ function createFieldSetSegments(term) {
201
172
  }
202
173
 
203
174
  /**
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
175
+ * @param {import('./parse-where-clause.types.ts').ParsedAggregateTerm} term
212
176
  * @returns {StoredQuerySegment[]}
213
177
  */
214
178
  function createAggregateSegments(term) {
@@ -245,30 +209,12 @@ function createTraversalSegments(traversal) {
245
209
  }
246
210
 
247
211
  /**
248
- * @param {{ is_negated: boolean, term: unknown }[]} clauses
212
+ * @param {import('./parse-where-clause.types.ts').ParsedClause[]} clauses
249
213
  * @returns {StoredQuerySegment[]}
250
214
  */
251
215
  function createNestedClauseSegments(clauses) {
252
216
  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
- );
217
+ const clause_phrase = createClausePhrase(clause, clause_index > 0);
272
218
 
273
219
  if (clause_index === 0) {
274
220
  return clause_phrase;