isomorphic-jj 1.4.3 → 1.7.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.
@@ -1,510 +1,937 @@
1
1
  /**
2
2
  * RevsetEngine - Query language for finding changes
3
- *
3
+ *
4
4
  * Implements a simplified revset language for querying the change graph.
5
+ *
6
+ * Parsing pipeline (v1.7): expressions are tokenized and parsed into a small
7
+ * AST via a real recursive-descent / precedence-climbing parser, then
8
+ * evaluated by walking the AST. This replaces an earlier implementation that
9
+ * matched the whole trimmed expression against one big chain of regexes per
10
+ * construct — which could not correctly handle a revset-taking function
11
+ * whose argument was itself a nested function call (e.g. `roots(ancestors(x))`
12
+ * would silently mis-parse), and had no real operator precedence for mixed
13
+ * `&`/`|`/`~` expressions. The AST only covers *structural* parsing (`@`,
14
+ * change-id atoms, the `-`/`+` suffix operators, `..` ranges, function-call
15
+ * boundaries, parenthesized grouping, and `&`/`|`/`~` precedence); the actual
16
+ * per-function semantics (author matching, ancestry walks, date filters,
17
+ * etc.) are unchanged from before and live in the filter/getX helper methods
18
+ * below the parser.
5
19
  */
6
20
 
7
21
  import { JJError } from '../utils/errors.js';
8
22
 
9
- export class RevsetEngine {
10
- /**
11
- * @param {ChangeGraph} graph - Change graph instance
12
- * @param {WorkingCopy} workingCopy - Working copy instance
13
- * @param {UserConfig} userConfig - User configuration instance (optional)
14
- * @param {BookmarkStore} bookmarkStore - Bookmark store instance (optional, v0.4)
15
- */
16
- constructor(graph, workingCopy, userConfig = null, bookmarkStore = null) {
17
- this.graph = graph;
18
- this.workingCopy = workingCopy;
19
- this.userConfig = userConfig;
20
- this.bookmarkStore = bookmarkStore;
23
+ /**
24
+ * @typedef {object} ChangeGraph
25
+ * @property {() => Promise<any>} load
26
+ * @property {() => any[]} getAll
27
+ * @property {(id: string) => Promise<any>} getChange
28
+ * @property {(id: string) => string[]} getParents
29
+ * @property {(id: string) => string[]} getChildren
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} WorkingCopy
34
+ * @property {() => string} getCurrentChangeId
35
+ */
36
+
37
+ // ============================================================================
38
+ // Tokenizer
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Find the index of the `)` matching the `(` at `openIndex`, respecting
43
+ * nested parentheses and single/double-quoted strings — so a quoted argument
44
+ * containing a literal `(`, `)`, or `,` (e.g. `description("a (b), c")`)
45
+ * doesn't confuse the boundary search.
46
+ *
47
+ * @param {string} source - Full source string
48
+ * @param {number} openIndex - Index of the '(' to match
49
+ * @returns {number} Index of the matching ')'
50
+ */
51
+ function findMatchingParen(source, openIndex) {
52
+ let depth = 0;
53
+ /** @type {string|null} */
54
+ let quote = null;
55
+ for (let i = openIndex; i < source.length; i++) {
56
+ const ch = source[i];
57
+ if (quote) {
58
+ if (ch === quote) quote = null;
59
+ continue;
60
+ }
61
+ if (ch === '"' || ch === "'") {
62
+ quote = ch;
63
+ continue;
64
+ }
65
+ if (ch === '(') depth++;
66
+ else if (ch === ')') {
67
+ depth--;
68
+ if (depth === 0) return i;
69
+ }
21
70
  }
71
+ throw new JJError('INVALID_REVSET', `Unmatched '(' in revset expression: ${source}`, {
72
+ expression: source,
73
+ });
74
+ }
75
+
76
+ // Characters that terminate a bare (unquoted, unparenthesized) symbol token
77
+ // at the top-level expression grammar. Everything else (letters, digits,
78
+ // dots, slashes, glob '*'/'?', hyphens WITHIN a symbol like a date, etc.) is
79
+ // swallowed into the symbol — hyphens are only split off as MINUS tokens
80
+ // when the tokenizer's main loop reaches them directly (i.e. not already
81
+ // consumed as part of a preceding symbol scan), matching how change-id/`@`
82
+ // suffix operators are recognized without breaking apart tokens that merely
83
+ // *contain* a hyphen in the middle (dates are never routed through this
84
+ // tokenizer at all — see the note on function argument kinds below).
85
+ const SYMBOL_STOP_CHARS = new Set([
86
+ ' ',
87
+ '\t',
88
+ '\n',
89
+ '\r',
90
+ '(',
91
+ ')',
92
+ ',',
93
+ '&',
94
+ '|',
95
+ '~',
96
+ '@',
97
+ '-',
98
+ '+',
99
+ ]);
100
+
101
+ /**
102
+ * Tokenize a top-level revset expression (or a nested revset-typed function
103
+ * argument). String-typed function arguments (author patterns, dates,
104
+ * glob patterns, etc.) are never routed through this tokenizer — they are
105
+ * extracted as raw substrings by `findMatchingParen` + `splitTopLevelArgs`,
106
+ * exactly as before, and are only interpreted once evaluateFunctionCall()
107
+ * dispatches on the now-unambiguous function name.
108
+ *
109
+ * @param {string} source
110
+ * @returns {Array<{type: string, value?: string}>}
111
+ */
112
+ function tokenize(source) {
113
+ const tokens = [];
114
+ let i = 0;
115
+ const n = source.length;
116
+
117
+ while (i < n) {
118
+ const ch = source[i];
22
119
 
120
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
121
+ i++;
122
+ continue;
123
+ }
124
+ if (ch === '(') {
125
+ tokens.push({ type: 'LPAREN' });
126
+ i++;
127
+ continue;
128
+ }
129
+ if (ch === ')') {
130
+ tokens.push({ type: 'RPAREN' });
131
+ i++;
132
+ continue;
133
+ }
134
+ if (ch === '&') {
135
+ tokens.push({ type: 'AMP' });
136
+ i++;
137
+ continue;
138
+ }
139
+ if (ch === '|') {
140
+ tokens.push({ type: 'PIPE' });
141
+ i++;
142
+ continue;
143
+ }
144
+ if (ch === '~') {
145
+ tokens.push({ type: 'TILDE' });
146
+ i++;
147
+ continue;
148
+ }
149
+ if (ch === '@') {
150
+ tokens.push({ type: 'AT' });
151
+ i++;
152
+ continue;
153
+ }
154
+ if (ch === '+') {
155
+ tokens.push({ type: 'PLUS' });
156
+ i++;
157
+ continue;
158
+ }
159
+ if (ch === '-') {
160
+ tokens.push({ type: 'MINUS' });
161
+ i++;
162
+ continue;
163
+ }
164
+
165
+ // Bare symbol (identifier, hex change id, glob pattern, `base..tip`
166
+ // range, etc.) — consume until a structural character.
167
+ let j = i;
168
+ while (j < n && !SYMBOL_STOP_CHARS.has(source[j])) {
169
+ j++;
170
+ }
171
+ if (j === i) {
172
+ throw new JJError('INVALID_REVSET', `Unexpected character '${ch}' in revset expression`, {
173
+ expression: source,
174
+ });
175
+ }
176
+ const name = source.slice(i, j);
177
+
178
+ if (j < n && source[j] === '(') {
179
+ // Function call: consume the whole balanced (...) group as one token,
180
+ // so nested calls (`roots(ancestors(x))`) are handled correctly no
181
+ // matter how deep, instead of a non-greedy regex truncating at the
182
+ // first ')' it sees.
183
+ const closeIndex = findMatchingParen(source, j);
184
+ tokens.push({ type: 'CALL', name, argsRaw: source.slice(j + 1, closeIndex) });
185
+ i = closeIndex + 1;
186
+ } else {
187
+ tokens.push({ type: 'SYMBOL', value: name });
188
+ i = j;
189
+ }
190
+ }
191
+
192
+ tokens.push({ type: 'EOF' });
193
+ return tokens;
194
+ }
195
+
196
+ // ============================================================================
197
+ // Parser (precedence climbing) — produces a small AST
198
+ // ============================================================================
199
+ //
200
+ // Grammar (highest to lowest precedence):
201
+ // primary := '@' suffix? | SYMBOL suffix? | CALL | '(' union ')'
202
+ // suffix := ('-' | '+')+ -- must be homogeneous
203
+ // intersect := primary (('&' | '~') primary)* -- left-assoc, same tier
204
+ // union := intersect ('|' intersect)* -- left-assoc, lowest
205
+ //
206
+ // A bare SYMBOL containing '..' (e.g. "abcd1234..ef567890") is recognized as
207
+ // a range atom at the primary level, matching the previous implementation's
208
+ // top-level `trimmed.includes('..')` special case.
209
+
210
+ class RevsetParser {
23
211
  /**
24
- * Evaluate a revset expression
25
- *
26
- * @param {string} expression - Revset expression
27
- * @returns {Promise<Array<string>>} Array of matching change IDs
212
+ * @param {Array<{type: string, value?: string, name?: string, argsRaw?: string}>} tokens
213
+ * @param {string} source - Original source (for error messages)
28
214
  */
29
- async evaluate(expression) {
30
- const trimmed = expression.trim();
215
+ constructor(tokens, source) {
216
+ this.tokens = tokens;
217
+ this.pos = 0;
218
+ this.source = source;
219
+ }
220
+
221
+ peek() {
222
+ return this.tokens[this.pos];
223
+ }
31
224
 
32
- // @ - working copy
33
- if (trimmed === '@') {
34
- return [this.workingCopy.getCurrentChangeId()];
225
+ next() {
226
+ return this.tokens[this.pos++];
227
+ }
228
+
229
+ /** @param {string} message */
230
+ fail(message) {
231
+ throw new JJError('INVALID_REVSET', message, { expression: this.source });
232
+ }
233
+
234
+ /** @returns {any} */
235
+ parse() {
236
+ const node = this.parseUnion();
237
+ if (this.peek().type !== 'EOF') {
238
+ this.fail(`Invalid revset expression: ${this.source}`);
35
239
  }
240
+ return node;
241
+ }
36
242
 
37
- // @- operator (parent of working copy) - handles chaining like @-- for grandparents
38
- const atParentsMatch = trimmed.match(/^@(-+)$/);
39
- if (atParentsMatch) {
40
- const depth = atParentsMatch[1].length; // Count number of '-' characters
41
- const workingCopyId = this.workingCopy.getCurrentChangeId();
243
+ /** @returns {any} */
244
+ parseUnion() {
245
+ /** @type {any} */
246
+ let node = this.parseIntersect();
247
+ while (this.peek().type === 'PIPE') {
248
+ this.next();
249
+ const right = this.parseIntersect();
250
+ node = { type: 'union', left: node, right };
251
+ }
252
+ return node;
253
+ }
42
254
 
43
- let currentSet = [workingCopyId];
44
- for (let i = 0; i < depth; i++) {
45
- currentSet = await this.getParentsOfSet(currentSet);
46
- if (currentSet.length === 0) break; // Stop if we reach root
47
- }
48
- return currentSet;
255
+ /** @returns {any} */
256
+ parseIntersect() {
257
+ /** @type {any} */
258
+ let node = this.parsePrimary();
259
+ while (this.peek().type === 'AMP' || this.peek().type === 'TILDE') {
260
+ const op = this.next().type === 'AMP' ? 'intersect' : 'difference';
261
+ const right = this.parsePrimary();
262
+ node = { type: op, left: node, right };
49
263
  }
264
+ return node;
265
+ }
50
266
 
51
- // @+ operator (children of working copy)
52
- const atChildrenMatch = trimmed.match(/^@(\++)$/);
53
- if (atChildrenMatch) {
54
- const depth = atChildrenMatch[1].length; // Count number of '+' characters
55
- const workingCopyId = this.workingCopy.getCurrentChangeId();
267
+ /** Consume a homogeneous run of '-' or '+' tokens, returning its depth (0 if none). */
268
+ parseSuffixDepth() {
269
+ const first = this.peek().type;
270
+ if (first !== 'PLUS' && first !== 'MINUS') return { dir: null, depth: 0 };
271
+ let depth = 0;
272
+ while (this.peek().type === first) {
273
+ this.next();
274
+ depth++;
275
+ }
276
+ // A mixed run (e.g. '-+') is not a valid suffix; only a homogeneous run
277
+ // is consumed above, so if the OPPOSITE token immediately follows, that's
278
+ // invalid syntax (matches the old regex's "all same character" requirement).
279
+ if (this.peek().type === 'PLUS' || this.peek().type === 'MINUS') {
280
+ this.fail(`Invalid revset expression: ${this.source}`);
281
+ }
282
+ return { dir: first === 'PLUS' ? '+' : '-', depth };
283
+ }
284
+
285
+ /** @returns {any} */
286
+ parsePrimary() {
287
+ const tok = this.peek();
56
288
 
57
- let currentSet = [workingCopyId];
58
- for (let i = 0; i < depth; i++) {
59
- currentSet = await this.getChildrenOfSet(currentSet);
60
- if (currentSet.length === 0) break; // Stop if we reach leaves
289
+ if (tok.type === 'LPAREN') {
290
+ this.next();
291
+ /** @type {any} */
292
+ const inner = this.parseUnion();
293
+ if (this.peek().type !== 'RPAREN') {
294
+ this.fail(`Unmatched '(' in revset expression: ${this.source}`);
61
295
  }
62
- return currentSet;
296
+ this.next();
297
+ return inner;
63
298
  }
64
299
 
65
- // all() - all changes
66
- if (trimmed === 'all()') {
67
- await this.graph.load();
68
- return this.graph.getAll().map((c) => c.changeId);
300
+ if (tok.type === 'AT') {
301
+ this.next();
302
+ const { dir, depth } = this.parseSuffixDepth();
303
+ return { type: 'workingCopy', dir, depth };
69
304
  }
70
305
 
71
- // v1.0: none() - empty set
72
- if (trimmed === 'none()') {
73
- return [];
306
+ if (tok.type === 'CALL') {
307
+ this.next();
308
+ return { type: 'call', name: tok.name, argsRaw: tok.argsRaw };
74
309
  }
75
310
 
76
- // v1.0: root() - the first commit (oldest commit with no parents)
77
- if (trimmed === 'root()') {
78
- await this.graph.load();
79
- const allChanges = this.graph.getAll();
80
- const rootCommits = allChanges.filter(c => !c.parents || c.parents.length === 0);
81
- if (rootCommits.length === 0) return [];
82
- // Return the oldest root by timestamp
83
- const oldest = rootCommits.sort((a, b) =>
84
- new Date(a.timestamp) - new Date(b.timestamp)
85
- )[0];
86
- return [oldest.changeId];
87
- }
311
+ if (tok.type === 'SYMBOL') {
312
+ this.next();
313
+ const value = /** @type {string} */ (tok.value);
88
314
 
89
- // v1.0: visible_heads() - all commits with no children
90
- if (trimmed === 'visible_heads()') {
91
- await this.graph.load();
92
- const allChanges = this.graph.getAll();
93
- const changeIdSet = new Set(allChanges.map(c => c.changeId));
94
- const hasChildren = new Set();
95
-
96
- // Mark all commits that have children
97
- for (const change of allChanges) {
98
- if (change.parents) {
99
- for (const parent of change.parents) {
100
- if (changeIdSet.has(parent)) {
101
- hasChildren.add(parent);
102
- }
103
- }
315
+ // A bare "base..tip" range, e.g. `abcd1234..ef567890`.
316
+ if (value.includes('..')) {
317
+ const parts = value.split('..');
318
+ if (parts.length === 2) {
319
+ return { type: 'range', base: parts[0].trim(), tip: parts[1].trim() };
104
320
  }
105
321
  }
106
322
 
107
- // Return commits without children
108
- return allChanges
109
- .filter(c => !hasChildren.has(c.changeId))
110
- .map(c => c.changeId);
323
+ const { dir, depth } = this.parseSuffixDepth();
324
+ return { type: 'symbol', value, dir, depth };
111
325
  }
112
326
 
113
- // v1.0: git_refs() - all commits with bookmarks
114
- if (trimmed === 'git_refs()') {
115
- if (!this.bookmarkStore) return [];
116
- await this.bookmarkStore.load();
117
- const allBookmarks = await this.bookmarkStore.list();
118
- return allBookmarks.map(b => b.changeId);
327
+ this.fail(`Invalid revset expression: ${this.source}`);
328
+ // unreachable; keeps TS/control-flow happy
329
+ return /** @type {any} */ (null);
330
+ }
331
+ }
332
+
333
+ export class RevsetEngine {
334
+ /**
335
+ * @param {ChangeGraph} graph - Change graph instance
336
+ * @param {WorkingCopy} workingCopy - Working copy instance
337
+ * @param {any} [userConfig] - User configuration instance (optional)
338
+ * @param {any} [bookmarkStore] - Bookmark store instance (optional, v0.4)
339
+ * @param {any} [tagStore] - Tag store instance (optional, v1.5)
340
+ */
341
+ constructor(graph, workingCopy, userConfig = null, bookmarkStore = null, tagStore = null) {
342
+ this.graph = graph;
343
+ this.workingCopy = workingCopy;
344
+ this.userConfig = userConfig;
345
+ this.bookmarkStore = bookmarkStore;
346
+ this.tagStore = tagStore;
347
+ }
348
+
349
+ /**
350
+ * Evaluate a revset expression
351
+ *
352
+ * @param {string} expression - Revset expression
353
+ * @returns {Promise<Array<string>>} Array of matching change IDs
354
+ */
355
+ async evaluate(expression) {
356
+ const trimmed = expression.trim();
357
+ if (trimmed === '') {
358
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: ${expression}`, {
359
+ expression,
360
+ suggestion: this._suggestion(),
361
+ });
119
362
  }
120
363
 
121
- // v1.0: git_head() - current working copy (Git HEAD equivalent)
122
- if (trimmed === 'git_head()') {
123
- try {
124
- const currentId = this.workingCopy.getCurrentChangeId();
125
- return currentId ? [currentId] : [];
126
- } catch (error) {
127
- // Working copy not loaded, return empty set
128
- return [];
364
+ let ast;
365
+ try {
366
+ ast = new RevsetParser(tokenize(trimmed), trimmed).parse();
367
+ } catch (error) {
368
+ if (error instanceof JJError) {
369
+ // Attach the full suggestion text to the top-level parse failure,
370
+ // matching the previous single-throw-site behavior.
371
+ error.context = error.context || {};
372
+ if (error.code === 'INVALID_REVSET' && !error.context.suggestion) {
373
+ error.context.suggestion = this._suggestion();
374
+ error.suggestion = error.context.suggestion;
375
+ }
129
376
  }
377
+ throw error;
130
378
  }
131
379
 
132
- // v0.36.0: visible() - non-abandoned changes
133
- if (trimmed === 'visible()') {
134
- await this.graph.load();
135
- const all = this.graph.getAll();
136
- return all
137
- .filter(c => !c.abandoned)
138
- .map(c => c.changeId);
139
- }
380
+ return await this.evaluateAst(ast);
381
+ }
140
382
 
141
- // v0.36.0: hidden() - abandoned changes
142
- if (trimmed === 'hidden()') {
143
- await this.graph.load();
144
- const all = this.graph.getAll();
145
- return all
146
- .filter(c => c.abandoned === true)
147
- .map(c => c.changeId);
148
- }
383
+ /**
384
+ * @private
385
+ * @returns {string}
386
+ */
387
+ _suggestion() {
388
+ return 'Use @, @-, @+, bookmark(name), all(), none(), root(), visible_heads(), git_refs(), git_head(), ancestors(revset[, depth]), author(name), author_name(x), author_email(x), committer(x), committer_name(x), committer_email(x), subject(pattern), description(text), change_id(prefix), commit_id(prefix), empty(), mine(), merge(), merges(), forks(), signed(), divergent(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), first_parent(revset), first_ancestors(revset), fork_point(revset), merge_point(revset), exactly(revset, n), present(revset), coalesce(a, b, ...), latest(revset, [count]), tags([pattern]), remote_tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID';
389
+ }
149
390
 
150
- // ancestors(changeId) - all ancestors including the change itself
151
- const ancestorsMatch = trimmed.match(/^ancestors\(([0-9a-f]{32})\)$/);
152
- if (ancestorsMatch) {
153
- const changeId = ancestorsMatch[1];
154
- return await this.getAncestors(changeId);
391
+ /**
392
+ * Evaluate a parsed AST node into an array of change IDs.
393
+ *
394
+ * @param {any} node
395
+ * @returns {Promise<Array<string>>}
396
+ */
397
+ async evaluateAst(node) {
398
+ switch (node.type) {
399
+ case 'union': {
400
+ const [left, right] = await Promise.all([
401
+ this.evaluateAst(node.left),
402
+ this.evaluateAst(node.right),
403
+ ]);
404
+ return Array.from(new Set([...left, ...right]));
405
+ }
406
+ case 'intersect': {
407
+ const [left, right] = await Promise.all([
408
+ this.evaluateAst(node.left),
409
+ this.evaluateAst(node.right),
410
+ ]);
411
+ const rightSet = new Set(right);
412
+ return left.filter((id) => rightSet.has(id));
413
+ }
414
+ case 'difference': {
415
+ const [left, right] = await Promise.all([
416
+ this.evaluateAst(node.left),
417
+ this.evaluateAst(node.right),
418
+ ]);
419
+ const rightSet = new Set(right);
420
+ return left.filter((id) => !rightSet.has(id));
421
+ }
422
+ case 'workingCopy': {
423
+ const workingCopyId = this.workingCopy.getCurrentChangeId();
424
+ return await this.applySuffix([workingCopyId], node.dir, node.depth);
425
+ }
426
+ case 'symbol': {
427
+ if (!/^[0-9a-f]{32}$/.test(node.value)) {
428
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: ${node.value}`, {
429
+ expression: node.value,
430
+ suggestion: this._suggestion(),
431
+ });
432
+ }
433
+ if (node.depth > 0) {
434
+ // Suffixed change-id form never validated existence of the base id
435
+ // up-front (getParentsOfSet/getChildrenOfSet already no-op on an
436
+ // unknown id), matching the previous implementation.
437
+ return await this.applySuffix([node.value], node.dir, node.depth);
438
+ }
439
+ await this.graph.load();
440
+ const change = await this.graph.getChange(node.value);
441
+ return change ? [node.value] : [];
442
+ }
443
+ case 'range':
444
+ return await this.evalRange(node.base, node.tip);
445
+ case 'call':
446
+ return await this.evaluateFunctionCall(node.name, node.argsRaw);
447
+ default:
448
+ throw new JJError('INVALID_REVSET', `Invalid revset expression`, {
449
+ suggestion: this._suggestion(),
450
+ });
155
451
  }
452
+ }
156
453
 
157
- // v0.2: author(name) - changes by author
158
- const authorMatch = trimmed.match(/^author\((.+?)\)$/);
159
- if (authorMatch) {
160
- const authorName = authorMatch[1].replace(/['"]/g, '');
161
- return await this.filterByAuthor(authorName);
454
+ /**
455
+ * Apply a run of '-' (parents) or '+' (children) suffix operators to a seed
456
+ * set, stepping generation-by-generation exactly like the previous
457
+ * `@(-+)$` / `x(-+)$` handling.
458
+ *
459
+ * @param {string[]} seedSet
460
+ * @param {'-'|'+'|null} dir
461
+ * @param {number} depth
462
+ * @returns {Promise<string[]>}
463
+ */
464
+ async applySuffix(seedSet, dir, depth) {
465
+ let currentSet = seedSet;
466
+ for (let i = 0; i < depth; i++) {
467
+ currentSet =
468
+ dir === '-'
469
+ ? await this.getParentsOfSet(currentSet)
470
+ : await this.getChildrenOfSet(currentSet);
471
+ if (currentSet.length === 0) break;
162
472
  }
473
+ return currentSet;
474
+ }
163
475
 
164
- // v0.2: description(text) - changes with description containing text
165
- const descMatch = trimmed.match(/^description\((.+?)\)$/);
166
- if (descMatch) {
167
- const text = descMatch[1].replace(/['"]/g, '');
168
- return await this.filterByDescription(text);
476
+ /**
477
+ * Evaluate a bare `base..tip` range (ancestors(tip) ~ ancestors(base)).
478
+ *
479
+ * @param {string} base
480
+ * @param {string} tip
481
+ * @returns {Promise<string[]>}
482
+ */
483
+ async evalRange(base, tip) {
484
+ const baseChange = await this.graph.getChange(base);
485
+ const tipChange = await this.graph.getChange(tip);
486
+
487
+ if (!baseChange || !tipChange) {
488
+ throw new JJError('INVALID_REVSET', `Invalid range: ${base}..${tip}`, {
489
+ suggestion: 'Both base and tip must be valid change IDs',
490
+ });
169
491
  }
170
492
 
171
- // v0.2: empty() - changes with no content
172
- if (trimmed === 'empty()') {
173
- return await this.filterEmpty();
174
- }
493
+ const tipAncestors = new Set(await this.getAncestors(tip));
494
+ const baseAncestors = new Set(await this.getAncestors(base));
495
+ return [...tipAncestors].filter((id) => !baseAncestors.has(id));
496
+ }
175
497
 
176
- // v0.3.1: mine() - changes by current user
177
- if (trimmed === 'mine()') {
178
- return await this.filterMine();
179
- }
498
+ /**
499
+ * Dispatch a parsed function call (name + raw, unsplit argument string) to
500
+ * its semantic handler. This mirrors the previous per-function branches in
501
+ * `evaluate()` almost verbatim — only how `name`/`argsRaw` were obtained
502
+ * has changed (via the tokenizer/parser above, instead of one regex per
503
+ * function tried against the whole expression).
504
+ *
505
+ * @param {string} name
506
+ * @param {string} argsRaw - Raw text between the call's parentheses
507
+ * @returns {Promise<string[]>}
508
+ */
509
+ async evaluateFunctionCall(name, argsRaw) {
510
+ const raw = argsRaw.trim();
511
+ const unquote = (/** @type {string} */ s) => s.trim().replace(/^['"]|['"]$/g, '');
180
512
 
181
- // v0.3.1: merge() - merge commits (multiple parents)
182
- if (trimmed === 'merge()') {
183
- return await this.filterMerge();
184
- }
513
+ switch (name) {
514
+ case 'all':
515
+ await this.graph.load();
516
+ return this.graph.getAll().map((c) => c.changeId);
185
517
 
186
- // v0.3.1: file(pattern) - changes touching files matching pattern
187
- const fileMatch = trimmed.match(/^file\((.+?)\)$/);
188
- if (fileMatch) {
189
- const pattern = fileMatch[1].replace(/['"]/g, '');
190
- return await this.filterByFile(pattern);
191
- }
518
+ case 'none':
519
+ return [];
192
520
 
193
- // v0.4: roots(revset) - commits not descendants of others in set
194
- const rootsMatch = trimmed.match(/^roots\((.+?)\)$/);
195
- if (rootsMatch) {
196
- const innerRevset = rootsMatch[1];
197
- const innerResults = await this.evaluate(innerRevset);
198
- return await this.filterRoots(innerResults);
199
- }
521
+ case 'root': {
522
+ await this.graph.load();
523
+ const allChanges = this.graph.getAll();
524
+ const rootCommits = allChanges.filter((c) => !c.parents || c.parents.length === 0);
525
+ if (rootCommits.length === 0) return [];
526
+ const oldest = rootCommits.sort(
527
+ (a, b) =>
528
+ /** @type {any} */ (new Date(a.timestamp)) - /** @type {any} */ (new Date(b.timestamp))
529
+ )[0];
530
+ return [oldest.changeId];
531
+ }
200
532
 
201
- // v0.4: heads(revset) - commits not ancestors of others in set
202
- const headsMatch = trimmed.match(/^heads\((.+?)\)$/);
203
- if (headsMatch) {
204
- const innerRevset = headsMatch[1];
205
- const innerResults = await this.evaluate(innerRevset);
206
- return await this.filterHeads(innerResults);
207
- }
533
+ case 'visible_heads': {
534
+ await this.graph.load();
535
+ const allChanges = this.graph.getAll();
536
+ const changeIdSet = new Set(allChanges.map((c) => c.changeId));
537
+ const hasChildren = new Set();
538
+ for (const change of allChanges) {
539
+ if (change.parents) {
540
+ for (const parent of change.parents) {
541
+ if (changeIdSet.has(parent)) hasChildren.add(parent);
542
+ }
543
+ }
544
+ }
545
+ return allChanges.filter((c) => !hasChildren.has(c.changeId)).map((c) => c.changeId);
546
+ }
208
547
 
209
- // v1.0: parents(revset) - direct parents of commits in set
210
- const parentsMatch = trimmed.match(/^parents\((.+?)\)$/);
211
- if (parentsMatch) {
212
- const innerRevset = parentsMatch[1];
213
- const innerResults = await this.evaluate(innerRevset);
214
- return await this.getParentsOfSet(innerResults);
215
- }
548
+ case 'git_refs': {
549
+ if (!this.bookmarkStore) return [];
550
+ await this.bookmarkStore.load();
551
+ const allBookmarks = /** @type {any[]} */ (await this.bookmarkStore.list());
552
+ return allBookmarks.map((b) => b.changeId);
553
+ }
216
554
 
217
- // v1.0: children(revset) - direct children of commits in set
218
- const childrenMatch = trimmed.match(/^children\((.+?)\)$/);
219
- if (childrenMatch) {
220
- const innerRevset = childrenMatch[1];
221
- const innerResults = await this.evaluate(innerRevset);
222
- return await this.getChildrenOfSet(innerResults);
223
- }
555
+ case 'git_head': {
556
+ try {
557
+ const currentId = this.workingCopy.getCurrentChangeId();
558
+ return currentId ? [currentId] : [];
559
+ } catch (error) {
560
+ return [];
561
+ }
562
+ }
224
563
 
225
- // v0.4: latest(revset, [count]) - latest N commits by committer timestamp
226
- const latestMatch = trimmed.match(/^latest\((.+?)(?:,\s*(\d+))?\)$/);
227
- if (latestMatch) {
228
- const innerRevset = latestMatch[1];
229
- const count = latestMatch[2] ? parseInt(latestMatch[2], 10) : 1;
230
- const innerResults = await this.evaluate(innerRevset);
231
- return await this.filterLatest(innerResults, count);
232
- }
564
+ case 'visible': {
565
+ await this.graph.load();
566
+ return this.graph
567
+ .getAll()
568
+ .filter((c) => !c.abandoned)
569
+ .map((c) => c.changeId);
570
+ }
233
571
 
234
- // v0.4: tags([pattern]) - tag targets
235
- const tagsMatch = trimmed.match(/^tags\((?:(.+?))?\)$/);
236
- if (tagsMatch || trimmed === 'tags()') {
237
- const pattern = tagsMatch ? tagsMatch[1]?.replace(/['"]/g, '') : undefined;
238
- return await this.filterTags(pattern);
239
- }
572
+ case 'hidden': {
573
+ await this.graph.load();
574
+ return this.graph
575
+ .getAll()
576
+ .filter((c) => c.abandoned === true)
577
+ .map((c) => c.changeId);
578
+ }
240
579
 
241
- // v1.0: bookmark(name) - single bookmark by exact name
242
- const bookmarkMatch = trimmed.match(/^bookmark\((.+?)\)$/);
243
- if (bookmarkMatch) {
244
- const name = bookmarkMatch[1].replace(/['"]/g, '');
245
- if (!this.bookmarkStore) return [];
246
- await this.bookmarkStore.load();
247
- const target = await this.bookmarkStore.get(name);
248
- return target ? [target] : [];
249
- }
580
+ case 'ancestors': {
581
+ const parts = this.splitTopLevelArgs(raw);
582
+ const seeds = await this.resolveArg(parts[0]);
583
+ const depth = parts[1] !== undefined ? parseInt(parts[1].trim(), 10) : undefined;
584
+ const result = new Set();
585
+ for (const seed of seeds) {
586
+ for (const id of await this.getAncestors(seed, depth)) {
587
+ result.add(id);
588
+ }
589
+ }
590
+ return Array.from(result);
591
+ }
250
592
 
251
- // v0.4: bookmarks([pattern]) - bookmark targets
252
- const bookmarksMatch = trimmed.match(/^bookmarks\((?:(.+?))?\)$/);
253
- if (bookmarksMatch || trimmed === 'bookmarks()') {
254
- const pattern = bookmarksMatch ? bookmarksMatch[1]?.replace(/['"]/g, '') : undefined;
255
- return await this.filterBookmarks(pattern);
256
- }
593
+ case 'change_id':
594
+ return await this.filterByIdPrefix(unquote(raw), 'changeId');
595
+ case 'commit_id':
596
+ return await this.filterByIdPrefix(unquote(raw), 'commitId');
597
+ case 'subject':
598
+ return await this.filterBySubject(unquote(raw));
599
+
600
+ case 'author_name':
601
+ return await this.filterBySignatureField('author', 'name', unquote(raw));
602
+ case 'author_email':
603
+ return await this.filterBySignatureField('author', 'email', unquote(raw));
604
+ case 'committer_name':
605
+ return await this.filterBySignatureField('committer', 'name', unquote(raw));
606
+ case 'committer_email':
607
+ return await this.filterBySignatureField('committer', 'email', unquote(raw));
608
+ case 'committer': {
609
+ const pattern = unquote(raw);
610
+ const byName = await this.filterBySignatureField('committer', 'name', pattern);
611
+ const byEmail = await this.filterBySignatureField('committer', 'email', pattern);
612
+ return Array.from(new Set([...byName, ...byEmail]));
613
+ }
257
614
 
258
- // v0.5: last(N) - last N commits
259
- // v0.5: last(Nd) - commits in last N days
260
- // v0.5: last(Nh) - commits in last N hours
261
- const lastMatch = trimmed.match(/^last\((\d+)([dh])?\)$/);
262
- if (lastMatch) {
263
- const value = parseInt(lastMatch[1], 10);
264
- const unit = lastMatch[2]; // 'd', 'h', or undefined
265
-
266
- if (unit) {
267
- // Time-based: last(Nd) or last(Nh)
268
- return await this.filterByTimeRange(value, unit);
269
- } else {
270
- // Count-based: last(N)
271
- return await this.filterLast(value);
615
+ case 'signed': {
616
+ await this.graph.load();
617
+ return this.graph
618
+ .getAll()
619
+ .filter((c) => c.signed === true || (c.signature && c.signature.status))
620
+ .map((c) => c.changeId);
272
621
  }
273
- }
274
622
 
275
- // v0.5: since(date) - commits since date
276
- const sinceMatch = trimmed.match(/^since\(([0-9-]+)\)$/);
277
- if (sinceMatch) {
278
- const date = sinceMatch[1];
279
- return await this.filterSince(date);
280
- }
623
+ case 'divergent': {
624
+ await this.graph.load();
625
+ const all = this.graph.getAll();
626
+ const counts = new Map();
627
+ for (const c of all) {
628
+ counts.set(c.changeId, (counts.get(c.changeId) || 0) + 1);
629
+ }
630
+ return all
631
+ .filter((c) => c.divergent === true || counts.get(c.changeId) > 1)
632
+ .map((c) => c.changeId);
633
+ }
281
634
 
282
- // v0.5: between(start, end) - commits between dates
283
- const betweenMatch = trimmed.match(/^between\(([0-9-]+),\s*([0-9-]+)\)$/);
284
- if (betweenMatch) {
285
- const startDate = betweenMatch[1];
286
- const endDate = betweenMatch[2];
287
- return await this.filterBetween(startDate, endDate);
288
- }
635
+ case 'merges':
636
+ case 'merge':
637
+ return await this.filterMerge();
289
638
 
290
- // v0.5: descendants(changeId[, depth]) - all descendants
291
- const descendantsMatch = trimmed.match(/^descendants\(([0-9a-f]{32})(?:,\s*(\d+))?\)$/);
292
- if (descendantsMatch) {
293
- const changeId = descendantsMatch[1];
294
- const depth = descendantsMatch[2] ? parseInt(descendantsMatch[2], 10) : undefined;
295
- return await this.getDescendants(changeId, depth);
296
- }
639
+ case 'forks': {
640
+ await this.graph.load();
641
+ const all = this.graph.getAll();
642
+ return all
643
+ .filter((c) => this.graph.getChildren(c.changeId).length > 1)
644
+ .map((c) => c.changeId);
645
+ }
297
646
 
298
- // v0.5: common_ancestor(rev1, rev2) - common ancestor
299
- const commonAncestorMatch = trimmed.match(/^common_ancestor\(([0-9a-f]{32}),\s*([0-9a-f]{32})\)$/);
300
- if (commonAncestorMatch) {
301
- const rev1 = commonAncestorMatch[1];
302
- const rev2 = commonAncestorMatch[2];
303
- return await this.findCommonAncestor(rev1, rev2);
304
- }
647
+ case 'remote_tags':
648
+ return await this.filterRemoteTags(raw === '' ? undefined : unquote(raw));
305
649
 
306
- // v0.5: range(base..tip) - commits in range
307
- const rangeMatch = trimmed.match(/^range\(([0-9a-f]{32})\.\.([0-9a-f]{32})\)$/);
308
- if (rangeMatch) {
309
- const base = rangeMatch[1];
310
- const tip = rangeMatch[2];
311
- return await this.getRange(base, tip);
312
- }
650
+ case 'first_parent': {
651
+ const seeds = await this.resolveArg(raw);
652
+ const parents = new Set();
653
+ for (const seed of seeds) {
654
+ const change = await this.graph.getChange(seed);
655
+ if (change && change.parents && change.parents.length > 0) {
656
+ parents.add(change.parents[0]);
657
+ }
658
+ }
659
+ return Array.from(parents);
660
+ }
313
661
 
314
- // v0.5: diverge_point(rev1, rev2) - divergence point
315
- const divergeMatch = trimmed.match(/^diverge_point\(([0-9a-f]{32}),\s*([0-9a-f]{32})\)$/);
316
- if (divergeMatch) {
317
- const rev1 = divergeMatch[1];
318
- const rev2 = divergeMatch[2];
319
- return await this.findDivergePoint(rev1, rev2);
320
- }
662
+ case 'first_ancestors': {
663
+ const seeds = await this.resolveArg(raw);
664
+ const result = new Set();
665
+ for (const seed of seeds) {
666
+ let current = seed;
667
+ while (current) {
668
+ if (result.has(current)) break;
669
+ result.add(current);
670
+ const change = await this.graph.getChange(current);
671
+ current =
672
+ change && change.parents && change.parents.length > 0 ? change.parents[0] : null;
673
+ }
674
+ }
675
+ return Array.from(result);
676
+ }
321
677
 
322
- // v0.5: connected(rev1, rev2) - check if path exists
323
- const connectedMatch = trimmed.match(/^connected\(([0-9a-f]{32}),\s*([0-9a-f]{32})\)$/);
324
- if (connectedMatch) {
325
- const rev1 = connectedMatch[1];
326
- const rev2 = connectedMatch[2];
327
- return await this.checkConnected(rev1, rev2);
328
- }
678
+ case 'fork_point':
679
+ return await this.findForkPoint(await this.resolveArg(raw));
680
+ case 'merge_point':
681
+ return await this.findMergePoint(await this.resolveArg(raw));
329
682
 
330
- // v1.0: x- operator (parents) - handles chaining like x-- for grandparents
331
- const parentsOpMatch = trimmed.match(/^([0-9a-f]{32})(-+)$/);
332
- if (parentsOpMatch) {
333
- const baseChangeId = parentsOpMatch[1];
334
- const depth = parentsOpMatch[2].length; // Count number of '-' characters
683
+ case 'exactly': {
684
+ const parts = this.splitTopLevelArgs(raw);
685
+ if (parts.length !== 2) {
686
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: exactly(${raw})`, {
687
+ suggestion: this._suggestion(),
688
+ });
689
+ }
690
+ const result = await this.evaluate(parts[0].trim());
691
+ const expected = parseInt(parts[1].trim(), 10);
692
+ if (result.length !== expected) {
693
+ throw new JJError(
694
+ 'REVSET_EXACTLY_MISMATCH',
695
+ `exactly() expected ${expected} revision(s) but found ${result.length}`,
696
+ { expected, actual: result.length }
697
+ );
698
+ }
699
+ return result;
700
+ }
335
701
 
336
- let currentSet = [baseChangeId];
337
- for (let i = 0; i < depth; i++) {
338
- currentSet = await this.getParentsOfSet(currentSet);
339
- if (currentSet.length === 0) break; // Stop if we reach root
702
+ case 'present': {
703
+ try {
704
+ return await this.evaluate(raw);
705
+ } catch {
706
+ return [];
707
+ }
340
708
  }
341
- return currentSet;
342
- }
343
709
 
344
- // v1.0: x+ operator (children) - handles chaining like x++ for grandchildren
345
- const childrenOpMatch = trimmed.match(/^([0-9a-f]{32})(\++)$/);
346
- if (childrenOpMatch) {
347
- const baseChangeId = childrenOpMatch[1];
348
- const depth = childrenOpMatch[2].length; // Count number of '+' characters
710
+ case 'coalesce': {
711
+ for (const part of this.splitTopLevelArgs(raw)) {
712
+ /** @type {string[]} */
713
+ let result = [];
714
+ try {
715
+ result = await this.evaluate(part.trim());
716
+ } catch {
717
+ result = [];
718
+ }
719
+ if (result.length > 0) return result;
720
+ }
721
+ return [];
722
+ }
349
723
 
350
- let currentSet = [baseChangeId];
351
- for (let i = 0; i < depth; i++) {
352
- currentSet = await this.getChildrenOfSet(currentSet);
353
- if (currentSet.length === 0) break; // Stop if we reach leaves
724
+ case 'author':
725
+ return await this.filterByAuthor(unquote(raw));
726
+ case 'description':
727
+ return await this.filterByDescription(unquote(raw));
728
+ case 'empty':
729
+ return await this.filterEmpty();
730
+ case 'mine':
731
+ return await this.filterMine();
732
+ case 'file':
733
+ return await this.filterByFile(unquote(raw));
734
+
735
+ case 'roots':
736
+ return await this.filterRoots(await this.evaluate(raw));
737
+ case 'heads':
738
+ return await this.filterHeads(await this.evaluate(raw));
739
+ case 'parents':
740
+ return await this.getParentsOfSet(await this.evaluate(raw));
741
+ case 'children':
742
+ return await this.getChildrenOfSet(await this.evaluate(raw));
743
+
744
+ case 'latest': {
745
+ const parts = this.splitTopLevelArgs(raw);
746
+ const innerResults = await this.evaluate(parts[0]);
747
+ const count = parts[1] !== undefined ? parseInt(parts[1].trim(), 10) : 1;
748
+ return await this.filterLatest(innerResults, count);
749
+ }
750
+
751
+ case 'tags':
752
+ return await this.filterTags(raw === '' ? undefined : unquote(raw));
753
+ case 'bookmark': {
754
+ const name = unquote(raw);
755
+ if (!this.bookmarkStore) return [];
756
+ await this.bookmarkStore.load();
757
+ const target = await this.bookmarkStore.get(name);
758
+ return target ? [target] : [];
759
+ }
760
+ case 'bookmarks':
761
+ return await this.filterBookmarks(raw === '' ? undefined : unquote(raw));
762
+
763
+ case 'last': {
764
+ const lastMatch = raw.match(/^(\d+)([dh])?$/);
765
+ if (!lastMatch) {
766
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: last(${raw})`, {
767
+ suggestion: this._suggestion(),
768
+ });
769
+ }
770
+ const value = parseInt(lastMatch[1], 10);
771
+ const unit = lastMatch[2];
772
+ return unit ? await this.filterByTimeRange(value, unit) : await this.filterLast(value);
773
+ }
774
+
775
+ case 'since': {
776
+ const dateMatch = raw.match(/^[0-9-]+$/);
777
+ if (!dateMatch) {
778
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: since(${raw})`, {
779
+ suggestion: this._suggestion(),
780
+ });
781
+ }
782
+ return await this.filterSince(raw);
354
783
  }
355
- return currentSet;
356
- }
357
784
 
358
- // Range operator (..) - all changes from base to tip
359
- // Format: base..tip means ancestors(tip) ~ ancestors(base)
360
- if (trimmed.includes('..')) {
361
- const parts = trimmed.split('..');
362
- if (parts.length === 2) {
363
- const base = parts[0].trim();
364
- const tip = parts[1].trim();
365
-
366
- // Get ancestors of tip
367
- const baseChange = await this.graph.getChange(base);
368
- const tipChange = await this.graph.getChange(tip);
369
-
370
- if (!baseChange || !tipChange) {
371
- throw new JJError('INVALID_REVSET', `Invalid range: ${trimmed}`, {
372
- suggestion: 'Both base and tip must be valid change IDs',
785
+ case 'between': {
786
+ const parts = this.splitTopLevelArgs(raw).map((p) => p.trim());
787
+ if (parts.length !== 2 || !/^[0-9-]+$/.test(parts[0]) || !/^[0-9-]+$/.test(parts[1])) {
788
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: between(${raw})`, {
789
+ suggestion: this._suggestion(),
373
790
  });
374
791
  }
792
+ return await this.filterBetween(parts[0], parts[1]);
793
+ }
375
794
 
376
- // ancestors(tip) ~ ancestors(base) gives us all changes from base to tip
377
- const tipAncestors = new Set(await this.getAncestors(tip));
378
- const baseAncestors = new Set(await this.getAncestors(base));
795
+ case 'descendants': {
796
+ const parts = this.splitTopLevelArgs(raw).map((p) => p.trim());
797
+ if (!/^[0-9a-f]{32}$/.test(parts[0])) {
798
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: descendants(${raw})`, {
799
+ suggestion: this._suggestion(),
800
+ });
801
+ }
802
+ const depth = parts[1] !== undefined ? parseInt(parts[1], 10) : undefined;
803
+ return await this.getDescendants(parts[0], depth);
804
+ }
379
805
 
380
- // Remove base's ancestors from tip's ancestors
381
- const rangeChanges = [...tipAncestors].filter(id => !baseAncestors.has(id));
806
+ case 'common_ancestor': {
807
+ const [rev1, rev2] = this._twoHexArgs(name, raw);
808
+ return await this.findCommonAncestor(rev1, rev2);
809
+ }
382
810
 
383
- return rangeChanges;
811
+ case 'range': {
812
+ const rangeParts = raw.split('..');
813
+ if (
814
+ rangeParts.length !== 2 ||
815
+ !/^[0-9a-f]{32}$/.test(rangeParts[0]) ||
816
+ !/^[0-9a-f]{32}$/.test(rangeParts[1])
817
+ ) {
818
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: range(${raw})`, {
819
+ suggestion: this._suggestion(),
820
+ });
821
+ }
822
+ return await this.getRange(rangeParts[0], rangeParts[1]);
384
823
  }
385
- }
386
824
 
387
- // conflicted() - changes with conflicts
388
- if (trimmed === 'conflicted()') {
389
- await this.graph.load();
390
- const all = this.graph.getAll();
391
- return all
392
- .filter(c => c.conflicts && Object.keys(c.conflicts).length > 0)
393
- .map(c => c.changeId);
394
- }
825
+ case 'diverge_point': {
826
+ const [rev1, rev2] = this._twoHexArgs(name, raw);
827
+ return await this.findDivergePoint(rev1, rev2);
828
+ }
395
829
 
396
- // reachable(heads) - all changes reachable from heads
397
- // Use a more careful regex that handles nested expressions
398
- if (trimmed.startsWith('reachable(')) {
399
- const innerStart = 'reachable('.length;
400
- let parenCount = 1;
401
- let endIndex = innerStart;
402
-
403
- // Find the matching closing parenthesis
404
- while (endIndex < trimmed.length && parenCount > 0) {
405
- if (trimmed[endIndex] === '(') parenCount++;
406
- if (trimmed[endIndex] === ')') parenCount--;
407
- endIndex++;
830
+ case 'connected': {
831
+ const [rev1, rev2] = this._twoHexArgs(name, raw);
832
+ return /** @type {any} */ (await this.checkConnected(rev1, rev2));
408
833
  }
409
834
 
410
- // Check if this is a complete reachable() expression (not part of a larger expression)
411
- if (parenCount === 0 && endIndex === trimmed.length) {
412
- const headsExpr = trimmed.substring(innerStart, endIndex - 1).trim();
413
- const heads = await this.evaluate(headsExpr);
835
+ case 'conflicted': {
836
+ await this.graph.load();
837
+ return this.graph
838
+ .getAll()
839
+ .filter((c) => c.conflicts && Object.keys(c.conflicts).length > 0)
840
+ .map((c) => c.changeId);
841
+ }
414
842
 
415
- // Get all ancestors of all heads
843
+ case 'reachable': {
844
+ const heads = await this.evaluate(raw);
416
845
  const reachable = new Set();
417
846
  for (const headId of heads) {
418
- const ancestors = await this.getAncestors(headId);
419
- for (const ancestorId of ancestors) {
847
+ for (const ancestorId of await this.getAncestors(headId)) {
420
848
  reachable.add(ancestorId);
421
849
  }
422
850
  }
423
-
424
851
  return Array.from(reachable);
425
852
  }
426
- }
427
-
428
- // tracked() - changes with tracked files (all changes with files)
429
- if (trimmed === 'tracked()') {
430
- await this.graph.load();
431
- const all = this.graph.getAll();
432
- return all
433
- .filter(c => c.fileSnapshot && Object.keys(c.fileSnapshot).length > 0)
434
- .map(c => c.changeId);
435
- }
436
-
437
- // untracked() - changes with no tracked files (empty changes)
438
- if (trimmed === 'untracked()') {
439
- await this.graph.load();
440
- const all = this.graph.getAll();
441
- return all
442
- .filter(c => !c.fileSnapshot || Object.keys(c.fileSnapshot).length === 0)
443
- .map(c => c.changeId);
444
- }
445
-
446
- // remote_branches([pattern]) - remote branch targets
447
- const remoteBranchesMatch = trimmed.match(/^remote_branches\((?:"([^"]+)")?\)$/);
448
- if (remoteBranchesMatch || trimmed === 'remote_branches()') {
449
- if (!this.bookmarkStore) return [];
450
853
 
451
- await this.bookmarkStore.load();
452
- const allBookmarks = await this.bookmarkStore.list();
453
-
454
- // Filter for remote branches (bookmarks with '/' in the name)
455
- let remoteBookmarks = allBookmarks.filter(bookmark => bookmark.name.includes('/'));
854
+ case 'tracked': {
855
+ await this.graph.load();
856
+ return this.graph
857
+ .getAll()
858
+ .filter((c) => c.fileSnapshot && Object.keys(c.fileSnapshot).length > 0)
859
+ .map((c) => c.changeId);
860
+ }
456
861
 
457
- // If pattern provided, filter by pattern
458
- if (remoteBranchesMatch && remoteBranchesMatch[1]) {
459
- const pattern = remoteBranchesMatch[1];
460
- const regex = new RegExp(pattern.replace(/\*/g, '.*'));
461
- remoteBookmarks = remoteBookmarks.filter(bookmark => regex.test(bookmark.name));
862
+ case 'untracked': {
863
+ await this.graph.load();
864
+ return this.graph
865
+ .getAll()
866
+ .filter((c) => !c.fileSnapshot || Object.keys(c.fileSnapshot).length === 0)
867
+ .map((c) => c.changeId);
462
868
  }
463
869
 
464
- // Get target change IDs
465
- const targets = new Set();
466
- for (const bookmark of remoteBookmarks) {
467
- if (bookmark.changeId) {
468
- targets.add(bookmark.changeId);
870
+ case 'remote_branches': {
871
+ if (!this.bookmarkStore) return [];
872
+ await this.bookmarkStore.load();
873
+ const allBookmarks = /** @type {any[]} */ (await this.bookmarkStore.list());
874
+ let remoteBookmarks = allBookmarks.filter((bookmark) => bookmark.name.includes('/'));
875
+ if (raw !== '') {
876
+ const pattern = unquote(raw);
877
+ remoteBookmarks = remoteBookmarks.filter((bookmark) =>
878
+ this.globMatch(bookmark.name, pattern)
879
+ );
880
+ }
881
+ const targets = new Set();
882
+ for (const bookmark of remoteBookmarks) {
883
+ if (bookmark.changeId) targets.add(bookmark.changeId);
469
884
  }
885
+ return Array.from(targets);
470
886
  }
471
887
 
472
- return Array.from(targets);
473
- }
474
-
475
- // v0.5: Set operations - intersection (&), union (|), difference (~)
476
- if (trimmed.includes(' & ') || trimmed.includes(' | ') || trimmed.includes(' ~ ')) {
477
- return await this.evaluateSetOperation(trimmed);
888
+ default:
889
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: ${name}(${raw})`, {
890
+ expression: `${name}(${raw})`,
891
+ suggestion: this._suggestion(),
892
+ });
478
893
  }
894
+ }
479
895
 
480
- // Direct changeId
481
- if (/^[0-9a-f]{32}$/.test(trimmed)) {
482
- await this.graph.load();
483
- const change = await this.graph.getChange(trimmed);
484
- return change ? [trimmed] : [];
896
+ /**
897
+ * Parse and validate exactly two comma-separated 32-hex-char arguments for
898
+ * functions like common_ancestor/diverge_point/connected.
899
+ *
900
+ * @private
901
+ * @param {string} name - Function name (for the error message)
902
+ * @param {string} raw - Raw argument text
903
+ * @returns {[string, string]}
904
+ */
905
+ _twoHexArgs(name, raw) {
906
+ const parts = this.splitTopLevelArgs(raw).map((p) => p.trim());
907
+ if (
908
+ parts.length !== 2 ||
909
+ !/^[0-9a-f]{32}$/.test(parts[0]) ||
910
+ !/^[0-9a-f]{32}$/.test(parts[1])
911
+ ) {
912
+ throw new JJError('INVALID_REVSET', `Invalid revset expression: ${name}(${raw})`, {
913
+ suggestion: this._suggestion(),
914
+ });
485
915
  }
486
-
487
- throw new JJError('INVALID_REVSET', `Invalid revset expression: ${expression}`, {
488
- expression,
489
- suggestion: 'Use @, @-, @+, bookmark(name), all(), none(), root(), visible_heads(), git_refs(), git_head(), ancestors(changeId), author(name), description(text), empty(), mine(), merge(), file(pattern), roots(revset), heads(revset), parents(revset), children(revset), latest(revset, [count]), tags([pattern]), bookmarks([pattern]), last(N[dh]), since(date), between(start, end), descendants(rev[, depth]), common_ancestor(rev1, rev2), range(base..tip), diverge_point(rev1, rev2), connected(rev1, rev2), operators (x-, x+), set operations (& | ~), or a direct change ID',
490
- });
916
+ return [parts[0], parts[1]];
491
917
  }
492
918
 
493
919
  /**
494
920
  * Get all ancestors of a change (including the change itself)
495
- *
921
+ *
496
922
  * @param {string} changeId - Change ID
923
+ * @param {number} [depth] - Optional depth limit
497
924
  * @returns {Promise<Array<string>>} Array of ancestor change IDs
498
925
  */
499
- async getAncestors(changeId) {
926
+ async getAncestors(changeId, depth = undefined) {
500
927
  await this.graph.load();
501
928
 
502
929
  const ancestors = [];
503
930
  const visited = new Set();
504
- const queue = [changeId];
931
+ const queue = [{ id: changeId, level: 0 }];
505
932
 
506
933
  while (queue.length > 0) {
507
- const current = queue.shift();
934
+ const { id: current, level } = /** @type {{ id: string, level: number }} */ (queue.shift());
508
935
 
509
936
  if (visited.has(current)) {
510
937
  continue;
@@ -513,43 +940,202 @@ export class RevsetEngine {
513
940
  visited.add(current);
514
941
  ancestors.push(current);
515
942
 
943
+ // Depth is measured from the seed change. A depth of N includes the seed
944
+ // plus N generations of parents (matching jj's `ancestors(x, depth)`).
945
+ if (depth !== undefined && level >= depth) {
946
+ continue;
947
+ }
948
+
516
949
  const parents = this.graph.getParents(current);
517
950
  for (const parent of parents) {
518
- queue.push(parent);
951
+ queue.push({ id: parent, level: level + 1 });
519
952
  }
520
953
  }
521
954
 
522
955
  return ancestors;
523
956
  }
524
957
 
958
+ /**
959
+ * Resolve a revset argument to a set of change IDs (v1.5).
960
+ *
961
+ * Accepts a bare 32-hex change ID, a hex prefix, or any nested revset
962
+ * expression. Used by graph functions that take another revset as input.
963
+ *
964
+ * @param {string} arg - Argument expression
965
+ * @returns {Promise<Array<string>>} Change IDs
966
+ */
967
+ async resolveArg(arg) {
968
+ const trimmed = arg.trim();
969
+ if (/^[0-9a-f]{32}$/.test(trimmed)) {
970
+ return [trimmed];
971
+ }
972
+ return await this.evaluate(trimmed);
973
+ }
974
+
975
+ /**
976
+ * Split a comma-separated argument list, respecting nested parentheses and
977
+ * quoted strings — so a quoted argument containing a literal comma (e.g.
978
+ * `description("a, b")`) isn't mis-split (v1.7: now quote-aware).
979
+ *
980
+ * @param {string} argString - Raw argument string (without the outer parens)
981
+ * @returns {Array<string>} Individual argument expressions
982
+ */
983
+ splitTopLevelArgs(argString) {
984
+ const args = [];
985
+ let depth = 0;
986
+ /** @type {string|null} */
987
+ let quote = null;
988
+ let current = '';
989
+ for (const ch of argString) {
990
+ if (quote) {
991
+ current += ch;
992
+ if (ch === quote) quote = null;
993
+ continue;
994
+ }
995
+ if (ch === '"' || ch === "'") {
996
+ quote = ch;
997
+ current += ch;
998
+ continue;
999
+ }
1000
+ if (ch === '(') depth++;
1001
+ if (ch === ')') depth--;
1002
+ if (ch === ',' && depth === 0) {
1003
+ args.push(current);
1004
+ current = '';
1005
+ } else {
1006
+ current += ch;
1007
+ }
1008
+ }
1009
+ if (current.trim() !== '') args.push(current);
1010
+ return args;
1011
+ }
1012
+
1013
+ /**
1014
+ * Filter changes whose changeId/commitId starts with a hex prefix (v1.5).
1015
+ *
1016
+ * @param {string} prefix - Hex prefix
1017
+ * @param {string} field - 'changeId' or 'commitId'
1018
+ * @returns {Promise<Array<string>>} Matching change IDs
1019
+ */
1020
+ async filterByIdPrefix(prefix, field) {
1021
+ await this.graph.load();
1022
+ const lower = prefix.toLowerCase();
1023
+ return this.graph
1024
+ .getAll()
1025
+ .filter((c) => (c[field] || '').toLowerCase().startsWith(lower))
1026
+ .map((c) => c.changeId);
1027
+ }
1028
+
1029
+ /**
1030
+ * Filter changes whose subject (first line of the description) matches (v1.5).
1031
+ *
1032
+ * @param {string} text - Text to match within the subject line
1033
+ * @returns {Promise<Array<string>>} Matching change IDs
1034
+ */
1035
+ async filterBySubject(text) {
1036
+ await this.graph.load();
1037
+ return this.graph
1038
+ .getAll()
1039
+ .filter((c) => {
1040
+ const subject = (c.description || '').split('\n')[0];
1041
+ return subject.includes(text);
1042
+ })
1043
+ .map((c) => c.changeId);
1044
+ }
1045
+
1046
+ /**
1047
+ * Filter changes by a signature field (v1.5).
1048
+ *
1049
+ * @param {string} role - 'author' or 'committer'
1050
+ * @param {string} field - 'name' or 'email'
1051
+ * @param {string} pattern - Substring to match
1052
+ * @returns {Promise<Array<string>>} Matching change IDs
1053
+ */
1054
+ async filterBySignatureField(role, field, pattern) {
1055
+ await this.graph.load();
1056
+ return this.graph
1057
+ .getAll()
1058
+ .filter((c) => c[role] && c[role][field] && c[role][field].includes(pattern))
1059
+ .map((c) => c.changeId);
1060
+ }
1061
+
1062
+ /**
1063
+ * Find the fork point (youngest common ancestor) of a set of changes (v1.5).
1064
+ *
1065
+ * @param {Array<string>} changeIds - Seed change IDs
1066
+ * @returns {Promise<Array<string>>} Fork point (single element array or empty)
1067
+ */
1068
+ async findForkPoint(changeIds) {
1069
+ await this.graph.load();
1070
+ if (changeIds.length === 0) return [];
1071
+
1072
+ // Intersect the ancestor sets of every seed.
1073
+ /** @type {Set<string> | null} */
1074
+ let common = null;
1075
+ for (const id of changeIds) {
1076
+ const ancestors = /** @type {Set<string>} */ (new Set(await this.getAncestors(id)));
1077
+ common =
1078
+ common === null
1079
+ ? ancestors
1080
+ : new Set([.../** @type {Set<string>} */ (common)].filter((a) => ancestors.has(a)));
1081
+ }
1082
+ if (!common || common.size === 0) return [];
1083
+
1084
+ // Youngest common ancestor: the one that is not an ancestor of any other
1085
+ // member of the common set (i.e. a head within the common set).
1086
+ return await this.filterHeads(Array.from(common));
1087
+ }
1088
+
1089
+ /**
1090
+ * Find the merge point (youngest common descendant) of a set of changes (v1.5).
1091
+ *
1092
+ * @param {Array<string>} changeIds - Seed change IDs
1093
+ * @returns {Promise<Array<string>>} Merge point (single element array or empty)
1094
+ */
1095
+ async findMergePoint(changeIds) {
1096
+ await this.graph.load();
1097
+ if (changeIds.length === 0) return [];
1098
+
1099
+ /** @type {Set<string>|null} */
1100
+ let common = null;
1101
+ for (const id of changeIds) {
1102
+ const descendants = /** @type {Set<string>} */ (new Set(await this.getDescendants(id)));
1103
+ descendants.add(id);
1104
+ common =
1105
+ common === null
1106
+ ? descendants
1107
+ : new Set([.../** @type {Set<string>} */ (common)].filter((d) => descendants.has(d)));
1108
+ }
1109
+ if (!common || common.size === 0) return [];
1110
+
1111
+ // Youngest common descendant: a root within the common set.
1112
+ return await this.filterRoots(Array.from(common));
1113
+ }
1114
+
525
1115
  /**
526
1116
  * Filter changes by author name (v0.2)
527
- *
1117
+ *
528
1118
  * @param {string} authorName - Author name to match
529
1119
  * @returns {Promise<Array<string>>} Array of matching change IDs
530
1120
  */
531
1121
  async filterByAuthor(authorName) {
532
1122
  await this.graph.load();
533
1123
  const all = this.graph.getAll();
534
-
535
- return all
536
- .filter((c) => c.author && c.author.name.includes(authorName))
537
- .map((c) => c.changeId);
1124
+
1125
+ return all.filter((c) => c.author && c.author.name.includes(authorName)).map((c) => c.changeId);
538
1126
  }
539
1127
 
540
1128
  /**
541
1129
  * Filter changes by description text (v0.2)
542
- *
1130
+ *
543
1131
  * @param {string} text - Text to search for in description
544
1132
  * @returns {Promise<Array<string>>} Array of matching change IDs
545
1133
  */
546
1134
  async filterByDescription(text) {
547
1135
  await this.graph.load();
548
1136
  const all = this.graph.getAll();
549
-
550
- return all
551
- .filter((c) => c.description && c.description.includes(text))
552
- .map((c) => c.changeId);
1137
+
1138
+ return all.filter((c) => c.description && c.description.includes(text)).map((c) => c.changeId);
553
1139
  }
554
1140
 
555
1141
  /**
@@ -585,9 +1171,10 @@ export class RevsetEngine {
585
1171
  const currentUser = this.userConfig.getUser();
586
1172
 
587
1173
  return all
588
- .filter((c) => c.author &&
589
- (c.author.email === currentUser.email ||
590
- c.author.name === currentUser.name))
1174
+ .filter(
1175
+ (c) =>
1176
+ c.author && (c.author.email === currentUser.email || c.author.name === currentUser.name)
1177
+ )
591
1178
  .map((c) => c.changeId);
592
1179
  }
593
1180
 
@@ -600,9 +1187,7 @@ export class RevsetEngine {
600
1187
  await this.graph.load();
601
1188
  const all = this.graph.getAll();
602
1189
 
603
- return all
604
- .filter((c) => c.parents && c.parents.length > 1)
605
- .map((c) => c.changeId);
1190
+ return all.filter((c) => c.parents && c.parents.length > 1).map((c) => c.changeId);
606
1191
  }
607
1192
 
608
1193
  /**
@@ -620,10 +1205,8 @@ export class RevsetEngine {
620
1205
  for (const change of all) {
621
1206
  if (change.fileSnapshot) {
622
1207
  const files = Object.keys(change.fileSnapshot);
623
- const matches = files.some(file =>
624
- file === pattern ||
625
- file.includes(pattern) ||
626
- this.globMatch(file, pattern)
1208
+ const matches = files.some(
1209
+ (file) => file === pattern || file.includes(pattern) || this.globMatch(file, pattern)
627
1210
  );
628
1211
  if (matches) {
629
1212
  results.push(change.changeId);
@@ -642,10 +1225,7 @@ export class RevsetEngine {
642
1225
  * @returns {boolean} True if matches
643
1226
  */
644
1227
  globMatch(str, pattern) {
645
- const regexPattern = pattern
646
- .replace(/\./g, '\\.')
647
- .replace(/\*/g, '.*')
648
- .replace(/\?/g, '.');
1228
+ const regexPattern = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\?/g, '.');
649
1229
  const regex = new RegExp(`^${regexPattern}$`);
650
1230
  return regex.test(str);
651
1231
  }
@@ -665,7 +1245,7 @@ export class RevsetEngine {
665
1245
  for (const changeId of changeIds) {
666
1246
  // A change is a root if none of its parents are in the set
667
1247
  const parents = this.graph.getParents(changeId);
668
- const hasParentInSet = parents.some(p => changeSet.has(p));
1248
+ const hasParentInSet = parents.some((p) => changeSet.has(p));
669
1249
 
670
1250
  if (!hasParentInSet) {
671
1251
  roots.push(changeId);
@@ -690,7 +1270,7 @@ export class RevsetEngine {
690
1270
  for (const changeId of changeIds) {
691
1271
  // A change is a head if it has no children in the set
692
1272
  const children = this.graph.getChildren(changeId);
693
- const hasChildInSet = children.some(c => changeSet.has(c));
1273
+ const hasChildInSet = children.some((c) => changeSet.has(c));
694
1274
 
695
1275
  if (!hasChildInSet) {
696
1276
  heads.push(changeId);
@@ -722,10 +1302,10 @@ export class RevsetEngine {
722
1302
  changes.sort((a, b) => {
723
1303
  const timeA = a.committer?.timestamp || a.timestamp || 0;
724
1304
  const timeB = b.committer?.timestamp || b.timestamp || 0;
725
- return new Date(timeB) - new Date(timeA);
1305
+ return /** @type {any} */ (new Date(timeB)) - /** @type {any} */ (new Date(timeA));
726
1306
  });
727
1307
 
728
- return changes.slice(0, count).map(c => c.changeId);
1308
+ return changes.slice(0, count).map((c) => c.changeId);
729
1309
  }
730
1310
 
731
1311
  /**
@@ -735,11 +1315,47 @@ export class RevsetEngine {
735
1315
  * @returns {Promise<Array<string>>} Change IDs pointed to by matching tags
736
1316
  */
737
1317
  async filterTags(pattern) {
738
- await this.graph.load();
1318
+ if (!this.tagStore) {
1319
+ return [];
1320
+ }
1321
+
1322
+ await this.tagStore.load();
1323
+ // TagStore.list([pattern]) returns [{ name, changeId }] and applies the
1324
+ // glob pattern itself when provided.
1325
+ const matchingTags = await this.tagStore.list(pattern);
739
1326
 
740
- // For now, return empty array since we don't have tag support yet
741
- // TODO: Implement when tag storage is added
742
- return [];
1327
+ const targets = new Set();
1328
+ for (const tag of matchingTags) {
1329
+ if (tag.changeId) {
1330
+ targets.add(tag.changeId);
1331
+ }
1332
+ }
1333
+ return Array.from(targets);
1334
+ }
1335
+
1336
+ /**
1337
+ * Filter remote tags (v1.5 - jj v0.38 remote_tags())
1338
+ *
1339
+ * Remote tags are stored with a slash-qualified name (e.g. "origin/v1.0").
1340
+ *
1341
+ * @param {string} [pattern] - Optional tag name pattern
1342
+ * @returns {Promise<Array<string>>} Change IDs pointed to by matching remote tags
1343
+ */
1344
+ async filterRemoteTags(pattern) {
1345
+ if (!this.tagStore) {
1346
+ return [];
1347
+ }
1348
+
1349
+ await this.tagStore.load();
1350
+ const allTags = await this.tagStore.list();
1351
+
1352
+ const targets = new Set();
1353
+ for (const tag of allTags) {
1354
+ if (!tag.name.includes('/')) continue; // only remote-qualified tags
1355
+ if (pattern && !this.globMatch(tag.name, pattern)) continue;
1356
+ if (tag.changeId) targets.add(tag.changeId);
1357
+ }
1358
+ return Array.from(targets);
743
1359
  }
744
1360
 
745
1361
  /**
@@ -769,7 +1385,7 @@ export class RevsetEngine {
769
1385
  continue;
770
1386
  }
771
1387
 
772
- result.push(bookmark.target);
1388
+ result.push(bookmark.changeId);
773
1389
  }
774
1390
 
775
1391
  // Return unique changeIds
@@ -793,7 +1409,7 @@ export class RevsetEngine {
793
1409
  return timeB - timeA;
794
1410
  });
795
1411
 
796
- return sorted.slice(0, count).map(c => c.changeId);
1412
+ return sorted.slice(0, count).map((c) => c.changeId);
797
1413
  }
798
1414
 
799
1415
  /**
@@ -821,11 +1437,11 @@ export class RevsetEngine {
821
1437
  const cutoffTime = now - milliseconds;
822
1438
 
823
1439
  return all
824
- .filter(c => {
1440
+ .filter((c) => {
825
1441
  const timestamp = c.committer?.timestamp || 0;
826
1442
  return timestamp >= cutoffTime;
827
1443
  })
828
- .map(c => c.changeId);
1444
+ .map((c) => c.changeId);
829
1445
  }
830
1446
 
831
1447
  /**
@@ -841,11 +1457,11 @@ export class RevsetEngine {
841
1457
  const sinceTime = new Date(dateStr).getTime();
842
1458
 
843
1459
  return all
844
- .filter(c => {
1460
+ .filter((c) => {
845
1461
  const timestamp = c.committer?.timestamp || 0;
846
1462
  return timestamp >= sinceTime;
847
1463
  })
848
- .map(c => c.changeId);
1464
+ .map((c) => c.changeId);
849
1465
  }
850
1466
 
851
1467
  /**
@@ -863,11 +1479,11 @@ export class RevsetEngine {
863
1479
  const endTime = new Date(endDateStr).getTime();
864
1480
 
865
1481
  return all
866
- .filter(c => {
1482
+ .filter((c) => {
867
1483
  const timestamp = c.committer?.timestamp || 0;
868
1484
  return timestamp >= startTime && timestamp <= endTime;
869
1485
  })
870
- .map(c => c.changeId);
1486
+ .map((c) => c.changeId);
871
1487
  }
872
1488
 
873
1489
  /**
@@ -885,7 +1501,7 @@ export class RevsetEngine {
885
1501
  const queue = [{ id: changeId, level: 0 }];
886
1502
 
887
1503
  while (queue.length > 0) {
888
- const { id: current, level } = queue.shift();
1504
+ const { id: current, level } = /** @type {{ id: string, level: number }} */ (queue.shift());
889
1505
 
890
1506
  if (visited.has(current)) {
891
1507
  continue;
@@ -1035,7 +1651,7 @@ export class RevsetEngine {
1035
1651
  // Get all changes and check if their parents include any from our set
1036
1652
  const allChanges = this.graph.getAll();
1037
1653
  for (const change of allChanges) {
1038
- if (change.parents && change.parents.some((p) => changeIdSet.has(p))) {
1654
+ if (change.parents && change.parents.some((/** @type {string} */ p) => changeIdSet.has(p))) {
1039
1655
  children.add(change.changeId);
1040
1656
  }
1041
1657
  }
@@ -1044,56 +1660,24 @@ export class RevsetEngine {
1044
1660
  }
1045
1661
 
1046
1662
  /**
1047
- * Evaluate set operations (v0.5)
1663
+ * Evaluate a bare set-operation expression directly (kept for backward
1664
+ * compatibility — evaluate() now handles `&`/`|`/`~` as part of normal
1665
+ * parsing/precedence, so this is a thin wrapper that still enforces the
1666
+ * "must contain an operator" contract for direct callers.
1048
1667
  *
1049
- * @param {string} expression - Expression with set operations
1050
- * @returns {Promise<Array<string>>} Result of set operation
1668
+ * @param {string} expression - Expression expected to contain a set operator
1669
+ * @returns {Promise<Array<string>>} Result of the set operation
1051
1670
  */
1052
1671
  async evaluateSetOperation(expression) {
1053
- // Parse set operations: & (intersection), | (union), ~ (difference)
1054
- // Simple implementation - split by operators and evaluate left to right
1055
-
1056
- // Handle intersection (&)
1057
- if (expression.includes(' & ')) {
1058
- const parts = expression.split(' & ');
1059
- let result = new Set(await this.evaluate(parts[0]));
1060
-
1061
- for (let i = 1; i < parts.length; i++) {
1062
- const partResult = new Set(await this.evaluate(parts[i]));
1063
- result = new Set([...result].filter(x => partResult.has(x)));
1064
- }
1065
-
1066
- return Array.from(result);
1067
- }
1068
-
1069
- // Handle union (|)
1070
- if (expression.includes(' | ')) {
1071
- const parts = expression.split(' | ');
1072
- const result = new Set();
1073
-
1074
- for (const part of parts) {
1075
- const partResult = await this.evaluate(part);
1076
- partResult.forEach(id => result.add(id));
1077
- }
1078
-
1079
- return Array.from(result);
1080
- }
1081
-
1082
- // Handle difference (~)
1083
- if (expression.includes(' ~ ')) {
1084
- const parts = expression.split(' ~ ');
1085
- let result = new Set(await this.evaluate(parts[0]));
1086
-
1087
- for (let i = 1; i < parts.length; i++) {
1088
- const partResult = new Set(await this.evaluate(parts[i]));
1089
- result = new Set([...result].filter(x => !partResult.has(x)));
1090
- }
1091
-
1092
- return Array.from(result);
1672
+ const tokens = tokenize(expression.trim());
1673
+ const hasOperator = tokens.some(
1674
+ (t) => t.type === 'AMP' || t.type === 'PIPE' || t.type === 'TILDE'
1675
+ );
1676
+ if (!hasOperator) {
1677
+ throw new JJError('INVALID_SET_OPERATION', `Invalid set operation: ${expression}`, {
1678
+ expression,
1679
+ });
1093
1680
  }
1094
-
1095
- throw new JJError('INVALID_SET_OPERATION', `Invalid set operation: ${expression}`, {
1096
- expression,
1097
- });
1681
+ return await this.evaluate(expression);
1098
1682
  }
1099
1683
  }