Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [12.0.0] — 2026-02-25
## [12.1.0] — 2026-02-25

### Added

- **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).
- **Centralized `matchGlob` utility** (`src/domain/utils/matchGlob.js`) — unified glob matching logic with regex caching and support for array-based multi-pattern matching.

### [12.0.0] — 2026-02-25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix version header formatting for 12.0.0.

The 12.0.0 section uses ### (h3) instead of ## (h2), which breaks consistency with other version headers in this changelog and violates Keep a Changelog format.

📝 Suggested fix
-### [12.0.0] — 2026-02-25
+## [12.0.0] — 2026-02-25
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### [12.0.0] — 2026-02-25
## [12.0.0] — 2026-02-25
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` at line 17, The version header for 12.0.0 in CHANGELOG.md uses
"### [12.0.0] — 2026-02-25" which is an h3 and inconsistent with the rest of the
changelog; change that header to "## [12.0.0] — 2026-02-25" so it matches the h2
format used by other release sections and conforms to Keep a Changelog.


### Changed

Expand Down
8 changes: 4 additions & 4 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export interface HopOptions {
* Fluent query builder.
*/
export class QueryBuilder {
match(pattern: string): QueryBuilder;
match(pattern: string | string[]): QueryBuilder;
where(fn: ((node: QueryNodeSnapshot) => boolean) | Record<string, unknown>): QueryBuilder;
outgoing(label?: string, options?: HopOptions): QueryBuilder;
incoming(label?: string, options?: HopOptions): QueryBuilder;
Expand Down Expand Up @@ -1194,8 +1194,8 @@ export const CONTENT_PROPERTY_KEY: '_content';
* Configuration for an observer view.
*/
export interface ObserverConfig {
/** Glob pattern for visible nodes (e.g. 'user:*') */
match: string;
/** Glob pattern(s) for visible nodes (e.g. 'user:*') */
match: string | string[];
/** Property keys to include (whitelist). If omitted, all non-redacted properties are visible. */
expose?: string[];
/** Property keys to exclude (blacklist). Takes precedence over expose. */
Expand Down Expand Up @@ -1915,7 +1915,7 @@ export default class WarpGraph {

/** Filtered watcher that only fires for changes matching a glob pattern. */
watch(
pattern: string,
pattern: string | string[],
options: {
onChange: (diff: StateDiffResult) => void;
onError?: (error: Error) => void;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@git-stunts/git-warp",
"version": "12.0.0",
"version": "12.1.0",
"description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
"type": "module",
"license": "Apache-2.0",
Expand Down
36 changes: 4 additions & 32 deletions src/domain/services/ObserverView.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,7 @@ import QueryBuilder from './QueryBuilder.js';
import LogicalTraversal from './LogicalTraversal.js';
import { orsetContains, orsetElements } from '../crdt/ORSet.js';
import { decodeEdgeKey } from './KeyCodec.js';

/** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
const globRegexCache = new Map();

/**
* Tests whether a string matches a glob-style pattern.
*
* Supports `*` as a wildcard matching zero or more characters.
* A lone `*` matches everything.
*
* @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
* @param {string} str - The string to test
* @returns {boolean} True if the string matches the pattern
*/
function matchGlob(pattern, str) {
if (pattern === '*') {
return true;
}
if (!pattern.includes('*')) {
return pattern === str;
}
let regex = globRegexCache.get(pattern);
if (!regex) {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
globRegexCache.set(pattern, regex);
}
return regex.test(str);
}
import { matchGlob } from '../utils/matchGlob.js';

/**
* Filters a properties Map based on expose and redact lists.
Expand Down Expand Up @@ -94,7 +66,7 @@ function sortNeighbors(list) {
* Builds filtered adjacency maps by scanning all edges in the OR-Set.
*
* @param {import('./JoinReducer.js').WarpStateV5} state
* @param {string} pattern
* @param {string|string[]} pattern
* @returns {{ outgoing: Map<string, NeighborEntry[]>, incoming: Map<string, NeighborEntry[]> }}
*/
function buildAdjacencyFromEdges(state, pattern) {
Expand Down Expand Up @@ -187,7 +159,7 @@ export default class ObserverView {
* @param {Object} options
* @param {string} options.name - Observer name
* @param {Object} options.config - Observer configuration
* @param {string} options.config.match - Glob pattern for visible nodes
* @param {string|string[]} options.config.match - Glob pattern(s) for visible nodes
* @param {string[]} [options.config.expose] - Property keys to include
* @param {string[]} [options.config.redact] - Property keys to exclude (takes precedence over expose)
* @param {import('../WarpGraph.js').default} options.graph - The source WarpGraph instance
Expand All @@ -196,7 +168,7 @@ export default class ObserverView {
/** @type {string} */
this._name = name;

/** @type {string} */
/** @type {string|string[]} */
this._matchPattern = config.match;

/** @type {string[]|undefined} */
Expand Down
58 changes: 14 additions & 44 deletions src/domain/services/QueryBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import QueryError from '../errors/QueryError.js';
import { matchGlob } from '../utils/matchGlob.js';

const DEFAULT_PATTERN = '*';

Expand Down Expand Up @@ -48,15 +49,18 @@ const DEFAULT_PATTERN = '*';
*/

/**
* Asserts that a match pattern is a string.
* Asserts that a match pattern is a string or array of strings.
*
* @param {unknown} pattern - The pattern to validate
* @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
* @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
* @private
*/
function assertMatchPattern(pattern) {
if (typeof pattern !== 'string') {
throw new QueryError('match() expects a string pattern', {
const isString = typeof pattern === 'string';
const isStringArray = Array.isArray(pattern) && pattern.every((p) => typeof p === 'string');

if (!isString && !isStringArray) {
throw new QueryError('match() expects a string pattern or array of string patterns', {
code: 'E_QUERY_MATCH_TYPE',
context: { receivedType: typeof pattern },
});
Expand Down Expand Up @@ -165,41 +169,6 @@ function sortIds(ids) {
return [...ids].sort();
}

/**
* Escapes special regex characters in a string so it can be used as a literal match.
*
* @param {string} value - The string to escape
* @returns {string} The escaped string safe for use in a RegExp
* @private
*/
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
* Tests whether a node ID matches a glob-style pattern.
*
* Supports:
* - `*` as the default pattern, matching all node IDs
* - Wildcard `*` anywhere in the pattern, matching zero or more characters
* - Literal match when pattern contains no wildcards
*
* @param {string} nodeId - The node ID to test
* @param {string} pattern - The glob pattern (e.g., "user:*", "*:admin", "*")
* @returns {boolean} True if the node ID matches the pattern
* @private
*/
function matchesPattern(nodeId, pattern) {
if (pattern === DEFAULT_PATTERN) {
return true;
}
if (pattern.includes('*')) {
const regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
return regex.test(nodeId);
}
return nodeId === pattern;
}

/**
* Recursively freezes an object and all nested objects/arrays.
*
Expand Down Expand Up @@ -494,7 +463,7 @@ export default class QueryBuilder {
*/
constructor(graph) {
this._graph = graph;
/** @type {string|null} */
/** @type {string|string[]|null} */
this._pattern = null;
/** @type {Array<{type: string, fn?: (node: QueryNodeSnapshot) => boolean, label?: string, depth?: [number, number]}>} */
this._operations = [];
Expand All @@ -505,16 +474,17 @@ export default class QueryBuilder {
}

/**
* Sets the match pattern for filtering nodes by ID.
* Sets the match pattern(s) for filtering nodes by ID.
*
* Supports glob-style patterns:
* - `*` matches all nodes
* - `user:*` matches all nodes starting with "user:"
* - `*:admin` matches all nodes ending with ":admin"
* - Array of patterns: `['campaign:*', 'milestone:*']` (OR semantics)
*
* @param {string} pattern - Glob pattern to match node IDs against
* @param {string|string[]} pattern - Glob pattern or array of patterns to match node IDs against
* @returns {QueryBuilder} This builder for chaining
* @throws {QueryError} If pattern is not a string (code: E_QUERY_MATCH_TYPE)
* @throws {QueryError} If pattern is not a string or array of strings (code: E_QUERY_MATCH_TYPE)
*/
match(pattern) {
assertMatchPattern(pattern);
Expand Down Expand Up @@ -682,7 +652,7 @@ export default class QueryBuilder {
const pattern = this._pattern ?? DEFAULT_PATTERN;

let workingSet;
workingSet = allNodes.filter((nodeId) => matchesPattern(nodeId, pattern));
workingSet = allNodes.filter((nodeId) => matchGlob(pattern, nodeId));

for (const op of this._operations) {
if (op.type === 'where') {
Expand Down
31 changes: 7 additions & 24 deletions src/domain/services/TranslationCost.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,10 @@

import { orsetElements, orsetContains } from '../crdt/ORSet.js';
import { decodeEdgeKey, decodePropKey, isEdgePropKey } from './KeyCodec.js';
import { matchGlob } from '../utils/matchGlob.js';

/** @typedef {import('./JoinReducer.js').WarpStateV5} WarpStateV5 */

/**
* Tests whether a string matches a glob-style pattern.
*
* @param {string} pattern - Glob pattern (e.g. 'user:*', '*:admin', '*')
* @param {string} str - The string to test
* @returns {boolean} True if the string matches the pattern
*/
function matchGlob(pattern, str) {
if (pattern === '*') {
return true;
}
if (!pattern.includes('*')) {
return pattern === str;
}
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^${escaped.replace(/\*/g, '.*')}$`);
return regex.test(str);
}

/**
* Computes the set of property keys visible under an observer config.
*
Expand Down Expand Up @@ -188,20 +170,21 @@ function computePropLoss(state, { nodesA, nodesBSet, configA, configB }) {
* A's view to B's view. It is asymmetric: cost(A->B) != cost(B->A) in general.
*
* @param {Object} configA - Observer configuration for A
* @param {string} configA.match - Glob pattern for visible nodes
* @param {string|string[]} configA.match - Glob pattern(s) for visible nodes
* @param {string[]} [configA.expose] - Property keys to include
* @param {string[]} [configA.redact] - Property keys to exclude
* @param {Object} configB - Observer configuration for B
* @param {string} configB.match - Glob pattern for visible nodes
* @param {string|string[]} configB.match - Glob pattern(s) for visible nodes
* @param {string[]} [configB.expose] - Property keys to include
* @param {string[]} [configB.redact] - Property keys to exclude
* @param {WarpStateV5} state - WarpStateV5 materialized state
* @returns {{ cost: number, breakdown: { nodeLoss: number, edgeLoss: number, propLoss: number } }}
*/
export function computeTranslationCost(configA, configB, state) {
if (!configA || typeof configA.match !== 'string' ||
!configB || typeof configB.match !== 'string') {
throw new Error('configA.match and configB.match must be strings');
const isValidMatch = (/** @type {string|string[]} */ m) => typeof m === 'string' || (Array.isArray(m) && m.every(i => typeof i === 'string'));
if (!configA || !isValidMatch(configA.match) ||
!configB || !isValidMatch(configB.match)) {
throw new Error('configA.match and configB.match must be strings or arrays of strings');
}
const allNodes = [...orsetElements(state.nodeAlive)];
const nodesA = allNodes.filter((id) => matchGlob(configA.match, id));
Expand Down
51 changes: 51 additions & 0 deletions src/domain/utils/matchGlob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/** @type {Map<string, RegExp>} Module-level cache for compiled glob regexes. */
const globRegexCache = new Map();

/**
* Escapes special regex characters in a string so it can be used as a literal match.
*
* @param {string} value - The string to escape
* @returns {string} The escaped string safe for use in a RegExp
* @private
*/
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
* Tests whether a string matches a glob-style pattern or an array of patterns.
*
* Supports:
* - `*` as the default pattern, matching all strings
* - Wildcard `*` anywhere in the pattern, matching zero or more characters
* - Literal match when pattern contains no wildcards
* - Array of patterns: returns true if ANY pattern matches (OR semantics)
*
* @param {string|string[]} pattern - The glob pattern(s) to match against
* @param {string} str - The string to test
* @returns {boolean} True if the string matches any of the patterns
*/
export function matchGlob(pattern, str) {
if (Array.isArray(pattern)) {
return pattern.some((p) => matchGlob(p, str));
}

if (pattern === '*') {
return true;
}

if (typeof pattern !== 'string') {
return false;
}

if (!pattern.includes('*')) {
return pattern === str;
}

let regex = globRegexCache.get(pattern);
if (!regex) {
regex = new RegExp(`^${escapeRegex(pattern).replace(/\\\*/g, '.*')}$`);
globRegexCache.set(pattern, regex);
}
return regex.test(str);
}
7 changes: 4 additions & 3 deletions src/domain/warp/query.methods.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,15 @@ export function query() {
* @this {import('../WarpGraph.js').default}
* @param {string} name - Observer name
* @param {Object} config - Observer configuration
* @param {string} config.match - Glob pattern for visible nodes
* @param {string|string[]} config.match - Glob pattern(s) for visible nodes
* @param {string[]} [config.expose] - Property keys to include
* @param {string[]} [config.redact] - Property keys to exclude
* @returns {Promise<import('../services/ObserverView.js').default>} A read-only observer view
*/
export async function observer(name, config) {
if (!config || typeof config.match !== 'string') {
throw new Error('observer config.match must be a string');
const isValidMatch = (/** @type {string|string[]} */ m) => typeof m === 'string' || (Array.isArray(m) && m.every(i => typeof i === 'string'));
if (!config || !isValidMatch(config.match)) {
throw new Error('observer config.match must be a string or array of strings');
}
await this._ensureFreshState();
return new ObserverView({ name, config, graph: this });
Expand Down
Loading
Loading