@git-stunts/git-warp 12.0.0 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,15 +8,10 @@
8
8
  <img src="docs/images/hero.gif" alt="git-warp CLI demo" width="600">
9
9
  </p>
10
10
 
11
- ## What's New in v12.0.0
12
-
13
- - **MaterializedViewService** — unified service orchestrating build, persist, and load of bitmap indexes and property readers as a single coherent materialized view. Checkpoints now embed the index (schema:4) for instant hydration on open.
14
- - **GraphTraversal engine (11 algorithms)** — BFS, DFS, shortest path, Dijkstra, A\*, bidirectional A\*, topological sort, longest path, connected component, reachability, and common ancestors. All accessible via `graph.traverse.*`.
15
- - **NeighborProviderPort abstraction** — decouples traversal algorithms from storage. Two implementations: `AdjacencyNeighborProvider` (in-memory) and `BitmapNeighborProvider` (O(1) bitmap lookups).
16
- - **Logical bitmap index** — CBOR-sharded Roaring bitmap index with labeled edges, stable numeric IDs, and property indexes. `IncrementalIndexUpdater` enables O(diff) updates.
17
- - **`nodeWeightFn`** — node-weighted graph algorithms (Dijkstra, A\*, longest path) as an alternative to edge-weight functions.
18
- - **CLI: `verify-index` and `reindex`** — new commands for index integrity checks and forced rebuilds.
19
- - **Cross-runtime hardening** — eliminated bare `Buffer` usage across the index subsystem; bitmap indexes now work on Node, Bun, and Deno.
11
+ ## What's New in v12.1.0
12
+
13
+ - **Multi-pattern glob support** — `graph.observer()`, `query().match()`, and `translationCost()` now accept an array of glob patterns (e.g. `['campaign:*', 'milestone:*']`). Nodes matching *any* pattern in the array are included (OR semantics).
14
+ - **Release preflight** — `npm run release:preflight` runs a 10-check local gate (version agreement, CHANGELOG, README, lint, types, tests, pack dry-runs) before tagging.
20
15
 
21
16
  See the [full changelog](CHANGELOG.md) for details.
22
17
 
package/index.d.ts CHANGED
@@ -226,7 +226,7 @@ export interface HopOptions {
226
226
  * Fluent query builder.
227
227
  */
228
228
  export class QueryBuilder {
229
- match(pattern: string): QueryBuilder;
229
+ match(pattern: string | string[]): QueryBuilder;
230
230
  where(fn: ((node: QueryNodeSnapshot) => boolean) | Record<string, unknown>): QueryBuilder;
231
231
  outgoing(label?: string, options?: HopOptions): QueryBuilder;
232
232
  incoming(label?: string, options?: HopOptions): QueryBuilder;
@@ -1194,8 +1194,8 @@ export const CONTENT_PROPERTY_KEY: '_content';
1194
1194
  * Configuration for an observer view.
1195
1195
  */
1196
1196
  export interface ObserverConfig {
1197
- /** Glob pattern for visible nodes (e.g. 'user:*') */
1198
- match: string;
1197
+ /** Glob pattern or array of patterns for visible nodes (e.g. 'user:*' or ['user:*', 'team:*']) */
1198
+ match: string | string[];
1199
1199
  /** Property keys to include (whitelist). If omitted, all non-redacted properties are visible. */
1200
1200
  expose?: string[];
1201
1201
  /** Property keys to exclude (blacklist). Takes precedence over expose. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@git-stunts/git-warp",
3
- "version": "12.0.0",
3
+ "version": "12.1.0",
4
4
  "description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -79,6 +79,7 @@
79
79
  "setup:hooks": "node scripts/setup-hooks.js",
80
80
  "prepare": "patch-package && node scripts/setup-hooks.js",
81
81
  "prepack": "npm run lint && npm run test:local && npm run typecheck:consumer",
82
+ "release:preflight": "bash scripts/release-preflight.sh",
82
83
  "install:git-warp": "bash scripts/install-git-warp.sh",
83
84
  "uninstall:git-warp": "bash scripts/uninstall-git-warp.sh",
84
85
  "test:node20": "docker compose -f docker-compose.test.yml --profile node20 run --build --rm test-node20",
@@ -13,35 +13,7 @@ import QueryBuilder from './QueryBuilder.js';
13
13
  import LogicalTraversal from './LogicalTraversal.js';
14
14
  import { orsetContains, orsetElements } from '../crdt/ORSet.js';
15
15
  import { decodeEdgeKey } from './KeyCodec.js';
16
-
17
- /** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
18
- const globRegexCache = new Map();
19
-
20
- /**
21
- * Tests whether a string matches a glob-style pattern.
22
- *
23
- * Supports `*` as a wildcard matching zero or more characters.
24
- * A lone `*` matches everything.
25
- *
26
- * @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
27
- * @param {string} str - The string to test
28
- * @returns {boolean} True if the string matches the pattern
29
- */
30
- function matchGlob(pattern, str) {
31
- if (pattern === '*') {
32
- return true;
33
- }
34
- if (!pattern.includes('*')) {
35
- return pattern === str;
36
- }
37
- let regex = globRegexCache.get(pattern);
38
- if (!regex) {
39
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
40
- regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
41
- globRegexCache.set(pattern, regex);
42
- }
43
- return regex.test(str);
44
- }
16
+ import { matchGlob } from '../utils/matchGlob.js';
45
17
 
46
18
  /**
47
19
  * Filters a properties Map based on expose and redact lists.
@@ -94,7 +66,7 @@ function sortNeighbors(list) {
94
66
  * Builds filtered adjacency maps by scanning all edges in the OR-Set.
95
67
  *
96
68
  * @param {import('./JoinReducer.js').WarpStateV5} state
97
- * @param {string} pattern
69
+ * @param {string|string[]} pattern
98
70
  * @returns {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }}
99
71
  */
100
72
  function buildAdjacencyFromEdges(state, pattern) {
@@ -187,7 +159,7 @@ export default class ObserverView {
187
159
  * @param {Object} options
188
160
  * @param {string} options.name - Observer name
189
161
  * @param {Object} options.config - Observer configuration
190
- * @param {string} options.config.match - Glob pattern for visible nodes
162
+ * @param {string|string[]} options.config.match - Glob pattern(s) for visible nodes
191
163
  * @param {string[]} [options.config.expose] - Property keys to include
192
164
  * @param {string[]} [options.config.redact] - Property keys to exclude (takes precedence over expose)
193
165
  * @param {import('../WarpGraph.js').default} options.graph - The source WarpGraph instance
@@ -196,7 +168,7 @@ export default class ObserverView {
196
168
  /** @type {string} */
197
169
  this._name = name;
198
170
 
199
- /** @type {string} */
171
+ /** @type {string|string[]} */
200
172
  this._matchPattern = config.match;
201
173
 
202
174
  /** @type {string[]|undefined} */
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import QueryError from '../errors/QueryError.js';
8
+ import { matchGlob } from '../utils/matchGlob.js';
8
9
 
9
10
  const DEFAULT_PATTERN = '*';
10
11
 
@@ -48,15 +49,18 @@ const DEFAULT_PATTERN = '*';
48
49
  */
49
50
 
50
51
  /**
51
- * Asserts that a match pattern is a string.
52
+ * Asserts that a match pattern is a string or array of strings.
52
53
  *
53
54
  * @param {unknown} pattern - The pattern to validate
54
- * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
55
+ * @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
55
56
  * @private
56
57
  */
57
58
  function assertMatchPattern(pattern) {
58
- if (typeof pattern !== 'string') {
59
- throw new QueryError('match() expects a string pattern', {
59
+ const isString = typeof pattern === 'string';
60
+ const isStringArray = Array.isArray(pattern) && pattern.every((p) => typeof p === 'string');
61
+
62
+ if (!isString && !isStringArray) {
63
+ throw new QueryError('match() expects a string pattern or array of string patterns', {
60
64
  code: 'E_QUERY_MATCH_TYPE',
61
65
  context: { receivedType: typeof pattern },
62
66
  });
@@ -165,41 +169,6 @@ function sortIds(ids) {
165
169
  return [...ids].sort();
166
170
  }
167
171
 
168
- /**
169
- * Escapes special regex characters in a string so it can be used as a literal match.
170
- *
171
- * @param {string} value - The string to escape
172
- * @returns {string} The escaped string safe for use in a RegExp
173
- * @private
174
- */
175
- function escapeRegex(value) {
176
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
177
- }
178
-
179
- /**
180
- * Tests whether a node ID matches a glob-style pattern.
181
- *
182
- * Supports:
183
- * - `*` as the default pattern, matching all node IDs
184
- * - Wildcard `*` anywhere in the pattern, matching zero or more characters
185
- * - Literal match when pattern contains no wildcards
186
- *
187
- * @param {string} nodeId - The node ID to test
188
- * @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
189
- * @returns {boolean} True if the node ID matches the pattern
190
- * @private
191
- */
192
- function matchesPattern(nodeId, pattern) {
193
- if (pattern === DEFAULT_PATTERN) {
194
- return true;
195
- }
196
- if (pattern.includes('*')) {
197
- const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
198
- return regex.test(nodeId);
199
- }
200
- return nodeId === pattern;
201
- }
202
-
203
172
  /**
204
173
  * Recursively freezes an object and all nested objects/arrays.
205
174
  *
@@ -494,7 +463,7 @@ export default class QueryBuilder {
494
463
  */
495
464
  constructor(graph) {
496
465
  this._graph = graph;
497
- /** @type {string|null} */
466
+ /** @type {string|string[]|null} */
498
467
  this._pattern = null;
499
468
  /** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */
500
469
  this._operations = [];
@@ -505,19 +474,21 @@ export default class QueryBuilder {
505
474
  }
506
475
 
507
476
  /**
508
- * Sets the match pattern for filtering nodes by ID.
477
+ * Sets the match pattern(s) for filtering nodes by ID.
509
478
  *
510
479
  * Supports glob-style patterns:
511
480
  * - `*` matches all nodes
512
481
  * - `user:*` matches all nodes starting with "user:"
513
482
  * - `*:admin` matches all nodes ending with ":admin"
483
+ * - Array of patterns: `['campaign:*', 'milestone:*']` (OR semantics)
514
484
  *
515
- * @param {string} pattern - Glob pattern to match node IDs against
485
+ * @param {string|string[]} pattern - Glob pattern or array of patterns to match node IDs against
516
486
  * @returns {QueryBuilder} This builder for chaining
517
- * @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
487
+ * @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
518
488
  */
519
489
  match(pattern) {
520
490
  assertMatchPattern(pattern);
491
+ /** @type {string|string[]|null} */
521
492
  this._pattern = pattern;
522
493
  return this;
523
494
  }
@@ -682,7 +653,7 @@ export default class QueryBuilder {
682
653
  const pattern = this._pattern ?? DEFAULT_PATTERN;
683
654
 
684
655
  let workingSet;
685
- workingSet = allNodes.filter((nodeId) => matchesPattern(nodeId, pattern));
656
+ workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));
686
657
 
687
658
  for (const op of this._operations) {
688
659
  if (op.type === 'where') {
@@ -15,28 +15,10 @@
15
15
 
16
16
  import { orsetElements, orsetContains } from '../crdt/ORSet.js';
17
17
  import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
18
+ import { matchGlob } from '../utils/matchGlob.js';
18
19
 
19
20
  /** @typedef {import('./JoinReducer.js').WarpStateV5} WarpStateV5 */
20
21
 
21
- /**
22
- * Tests whether a string matches a glob-style pattern.
23
- *
24
- * @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
25
- * @param {string} str - The string to test
26
- * @returns {boolean} True if the string matches the pattern
27
- */
28
- function matchGlob(pattern, str) {
29
- if (pattern === '*') {
30
- return true;
31
- }
32
- if (!pattern.includes('*')) {
33
- return pattern === str;
34
- }
35
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
36
- const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
37
- return regex.test(str);
38
- }
39
-
40
22
  /**
41
23
  * Computes the set of property keys visible under an observer config.
42
24
  *
@@ -188,20 +170,22 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
188
170
  * A's view to B's view. It is asymmetric: cost(A->B) != cost(B->A) in general.
189
171
  *
190
172
  * @param {Object} configA - Observer configuration for A
191
- * @param {string} configA.match - Glob pattern for visible nodes
173
+ * @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
192
174
  * @param {string[]} [configA.expose] - Property keys to include
193
175
  * @param {string[]} [configA.redact] - Property keys to exclude
194
176
  * @param {Object} configB - Observer configuration for B
195
- * @param {string} configB.match - Glob pattern for visible nodes
177
+ * @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
196
178
  * @param {string[]} [configB.expose] - Property keys to include
197
179
  * @param {string[]} [configB.redact] - Property keys to exclude
198
180
  * @param {WarpStateV5} state - WarpStateV5 materialized state
199
181
  * @returns {{ cost: number, breakdown: { nodeLoss: number, edgeLoss: number, propLoss: number } }}
200
182
  */
201
183
  export function computeTranslationCost(configA, configB, state) {
202
- if (!configA || typeof configA.match !== 'string' ||
203
- !configB || typeof configB.match !== 'string') {
204
- throw new Error('configA.match and configB.match must be strings');
184
+ /** @param {unknown} m */
185
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
186
+ if (!configA || !isValidMatch(configA.match) ||
187
+ !configB || !isValidMatch(configB.match)) {
188
+ throw new Error('configA.match and configB.match must be strings or arrays of strings');
205
189
  }
206
190
  const allNodes = [...orsetElements(state.nodeAlive)];
207
191
  const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
@@ -0,0 +1,51 @@
1
+ /** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
2
+ const globRegexCache = new Map();
3
+
4
+ /**
5
+ * Escapes special regex characters in a string so it can be used as a literal match.
6
+ *
7
+ * @param {string} value - The string to escape
8
+ * @returns {string} The escaped string safe for use in a RegExp
9
+ * @private
10
+ */
11
+ function escapeRegex(value) {
12
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ }
14
+
15
+ /**
16
+ * Tests whether a string matches a glob-style pattern or an array of patterns.
17
+ *
18
+ * Supports:
19
+ * - `*` as the default pattern, matching all strings
20
+ * - Wildcard `*` anywhere in the pattern, matching zero or more characters
21
+ * - Literal match when pattern contains no wildcards
22
+ * - Array of patterns: returns true if ANY pattern matches (OR semantics)
23
+ *
24
+ * @param {string|string[]} pattern - The glob pattern(s) to match against
25
+ * @param {string} str - The string to test
26
+ * @returns {boolean} True if the string matches any of the patterns
27
+ */
28
+ export function matchGlob(pattern, str) {
29
+ if (Array.isArray(pattern)) {
30
+ return pattern.some((p) => matchGlob(p, str));
31
+ }
32
+
33
+ if (pattern === '*') {
34
+ return true;
35
+ }
36
+
37
+ if (typeof pattern !== 'string') {
38
+ return false;
39
+ }
40
+
41
+ if (!pattern.includes('*')) {
42
+ return pattern === str;
43
+ }
44
+
45
+ let regex = globRegexCache.get(pattern);
46
+ if (!regex) {
47
+ regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
48
+ globRegexCache.set(pattern, regex);
49
+ }
50
+ return regex.test(str);
51
+ }
@@ -312,14 +312,16 @@ export function query() {
312
312
  * @this {import('../WarpGraph.js').default}
313
313
  * @param {string} name - Observer name
314
314
  * @param {Object} config - Observer configuration
315
- * @param {string} config.match - Glob pattern for visible nodes
315
+ * @param {string|string[]} config.match - Glob pattern(s) for visible nodes
316
316
  * @param {string[]} [config.expose] - Property keys to include
317
317
  * @param {string[]} [config.redact] - Property keys to exclude
318
318
  * @returns {Promise<import('../services/ObserverView.js').default>} A read-only observer view
319
319
  */
320
320
  export async function observer(name, config) {
321
- if (!config || typeof config.match !== 'string') {
322
- throw new Error('observer config.match must be a string');
321
+ /** @param {unknown} m */
322
+ const isValidMatch = (m) => typeof m === 'string' || (Array.isArray(m) && m.every(/** @param {unknown} i */ i => typeof i === 'string'));
323
+ if (!config || !isValidMatch(config.match)) {
324
+ throw new Error('observer config.match must be a string or array of strings');
323
325
  }
324
326
  await this._ensureFreshState();
325
327
  return new ObserverView({ name, config, graph: this });