From d8c441114614c0c7e95585ce95c983c61e6587c3 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Tue, 24 Mar 2026 22:53:01 +1100 Subject: [PATCH 1/5] feat: add filtering support to show command Adds a --filter flag to `show` that accepts a named view (from config) or an inline filter expression. Expressions use a SELECT...WHERE pseudo-DSL where the WHERE clause is a JSONata predicate evaluated per node. Nodes are augmented with pre-computed ancestors[] and descendants[] arrays (flat, deduplicated, nearest-first) so multi-hop traversal queries work without complex JSONata lambda capture. Each entry carries edge metadata (_field, _source, _selfRef) for filtering by relationship type. Named views are defined in config under spaces[].views. SELECT clause parsing is implemented but evaluation is deferred to a future release. --- README.md | 74 +++++++- docs/concepts.md | 69 ++++++++ skills/ost-tools/references/commands.md | 34 +++- src/commands/show.ts | 10 +- src/config.ts | 11 ++ src/filter/augment-nodes.ts | 149 ++++++++++++++++ src/filter/filter-nodes.ts | 81 +++++++++ src/filter/parse-expression.ts | 69 ++++++++ src/index.ts | 3 +- tests/filter/augment-nodes.test.ts | 223 ++++++++++++++++++++++++ tests/filter/filter-nodes.test.ts | 106 +++++++++++ tests/filter/parse-expression.test.ts | 93 ++++++++++ 12 files changed, 917 insertions(+), 5 deletions(-) create mode 100644 src/filter/augment-nodes.ts create mode 100644 src/filter/filter-nodes.ts create mode 100644 src/filter/parse-expression.ts create mode 100644 tests/filter/augment-nodes.test.ts create mode 100644 tests/filter/filter-nodes.test.ts create mode 100644 tests/filter/parse-expression.test.ts diff --git a/README.md b/README.md index 44427f0..d80c96d 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,29 @@ All plugin names must start with `ost-tools-` (the prefix is optional in config - `templatePrefix` — filename prefix for templates (default blank) - `fieldMap` — maps file/frontmatter field names to canonical schema field names (e.g. `{ "record_type": "type" }`) +**Filter views:** Named filter expressions can be defined per space under `views`. Each view has an `expression` field using the filter expression syntax: + +```json +{ + "spaces": [ + { + "name": "my-space", + "path": "/path/to/space", + "views": { + "active-solutions": { + "expression": "WHERE resolvedType='solution' and status='active'" + }, + "solutions-under-active-opportunity": { + "expression": "WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active'])" + } + } + } + ] +} +``` + +Use a view name with `ost-tools show --filter `. + ### Spaces A space is a named directory or single file registered in the config. Spaces let you reference content by name instead of path: @@ -194,13 +217,62 @@ Validates markdown files against the JSON schema: ### Show space tree ```bash -ost-tools show +ost-tools show [--filter ] ``` Prints the space as an indented hierarchy tree. Hierarchy roots are listed first, followed by orphans (nodes in the hierarchy but with no resolved parent) and non-hierarchy nodes. When a node appears under multiple parents (DAG hierarchy), it is printed in full under its first parent. Subsequent appearances with children show a `(*)` marker indicating the subtree is omitted. +**Filtering:** The `--filter` flag accepts either a named view from the space config, or an inline filter expression. Only nodes matching the expression are shown. + +```bash +# Inline expression +ost-tools show --filter "WHERE resolvedType='solution' and status='active'" + +# Named view from config +ost-tools show --filter active-solutions +``` + +See [Filter expressions](#filter-expressions) below for expression syntax. + +### Filter expressions + +Filter expressions are used with `--filter` and in config `views`. They use a `SELECT ... WHERE ...` pseudo-DSL: + +| Form | Meaning | +|------|---------| +| `WHERE {jsonata}` | Return nodes where the JSONata predicate is truthy | +| `SELECT {spec} WHERE {jsonata}` | As above; SELECT expansion is reserved for a future release | +| `{jsonata}` | Bare JSONata, treated as a WHERE predicate (convenience shorthand) | + +The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Within the expression, each node's fields are accessible directly (e.g. `resolvedType`, `status`, any schema fields like `title`). Additionally, two pre-computed traversal arrays are available: + +- **`ancestors[]`** — flat array of ancestor nodes, nearest first, deduplicated. Each entry includes all schema fields of the ancestor node, plus: + - `_field` — the edge field name that connects to the ancestor + - `_source` — `'hierarchy'` or `'relationship'` + - `_selfRef` — whether the edge is a same-type (self-referential) link +- **`descendants[]`** — same structure, for descendant nodes + +**Examples:** + +```jsonata +// All solutions +WHERE resolvedType='solution' + +// Active solutions only +WHERE resolvedType='solution' and status='active' + +// Solutions whose nearest opportunity ancestor is active +WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active']) + +// Nodes that have any ancestor goal +WHERE $exists(ancestors[resolvedType='goal']) + +// Bare JSONata shorthand (no WHERE keyword) +resolvedType='solution' and status='active' +``` + ### Generate Mermaid diagram ```bash diff --git a/docs/concepts.md b/docs/concepts.md index 9d9b6a2..21393cb 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -253,6 +253,75 @@ An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `type --- +## Filter expressions + +A **filter expression** is a string that selects a subset of nodes from a space. Filter expressions are used with the `--filter` flag on the `show` command and in named filter views in config. + +### Syntax + +``` +WHERE {jsonata} — return nodes where the JSONata predicate is truthy +SELECT {spec} WHERE {jsonata} — predicate + include spec (SELECT expansion is a future feature) +{jsonata} — bare JSONata, treated as a WHERE predicate (convenience shorthand) +``` + +Keywords (`WHERE`, `SELECT`) are case-insensitive. + +### Predicate context + +The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Node fields (from `schemaData`) are accessible directly — e.g. `resolvedType`, `status`, `title`. Additionally, two pre-computed traversal arrays are available: + +| Field | Description | +|-------|-------------| +| `ancestors[]` | Flat array of ancestor nodes, nearest first, deduplicated by title | +| `descendants[]` | Flat array of descendant nodes, nearest first, deduplicated by title | + +Each entry in `ancestors[]` or `descendants[]` includes all schema fields of the target node, plus edge metadata: + +| Metadata field | Type | Description | +|----------------|------|-------------| +| `_field` | `string` | The edge field name that connects to this ancestor/descendant | +| `_source` | `'hierarchy' \| 'relationship'` | Whether the edge came from the hierarchy or a relationship | +| `_selfRef` | `boolean` | Whether the edge is a same-type (self-referential) link | + +### Examples + +```jsonata +WHERE resolvedType='solution' and status='active' + +WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active']) + +WHERE $count(descendants[resolvedType='solution']) > 3 +``` + +--- + +## Filter views + +A **filter view** is a named filter expression defined in the space config. Views allow commonly used filters to be referenced by name rather than repeating the expression inline. + +Views are defined in the space config under the `views` key: + +```json +{ + "spaces": [ + { + "name": "my-space", + "path": "/path/to/space", + "views": { + "active-solutions": { + "expression": "WHERE resolvedType='solution' and status='active'" + } + } + } + ] +} +``` + +Use a view by name with `ost-tools show --filter `. If no matching view name is found in the config, the value is treated as an inline filter expression. + +--- + ## Status **Status** is a lifecycle field on nodes indicating a node's current stage. The valid values and their semantics are defined by the schema in use. Examples from the default schema (in rough progression): diff --git a/skills/ost-tools/references/commands.md b/skills/ost-tools/references/commands.md index 4816f22..5cc5ae3 100644 --- a/skills/ost-tools/references/commands.md +++ b/skills/ost-tools/references/commands.md @@ -32,7 +32,7 @@ bunx ost-tools validate --watch ## show ```bash -bunx ost-tools show +bunx ost-tools show [--filter ] ``` Prints a hierarchical tree of all nodes, indented by parent→child relationships. Useful for @@ -41,6 +41,35 @@ browsing structure, verifying parent links are correct, and spotting orphaned no Uses hierarchy edge config from `$metadata.hierarchy.levels` (`field`, `fieldOn`, `multiple`). If those are misconfigured for your content, output will appear flatter than expected. +**`--filter`** accepts either a named view from the space config (`views` key) or an inline filter +expression. Only matching nodes are shown in the tree. + +```bash +# Inline expression +bunx ost-tools show --filter "WHERE resolvedType='solution' and status='active'" + +# Ancestor attribute filter (solutions under an active opportunity) +bunx ost-tools show --filter "WHERE resolvedType='solution' and \$exists(ancestors[resolvedType='opportunity' and status='active'])" + +# Named view from config +bunx ost-tools show --filter my-view-name +``` + +**Filter expression syntax:** `WHERE {jsonata}` | `SELECT {spec} WHERE {jsonata}` | bare JSONata. +Within the predicate, node fields (e.g. `resolvedType`, `status`) are directly accessible. Two +traversal arrays are also available per node: +- `ancestors[]` — ancestor nodes nearest-first, each with `_field`, `_source`, `_selfRef` edge metadata +- `descendants[]` — descendant nodes, same structure + +**Named views** are defined in the space config: +```json5 +{ + views: { + "active-solutions": { expression: "WHERE resolvedType='solution' and status='active'" } + } +} +``` + ## dump ```bash @@ -138,6 +167,9 @@ bunx ost-tools template-sync --create-missing }, miroBoardId: 'xxx', miroFrameId: 'xxx', // auto-populated by --new-frame + views: { + 'active-solutions': { expression: "WHERE resolvedType='solution' and status='active'" }, + }, } ] } diff --git a/src/commands/show.ts b/src/commands/show.ts index ec92ce7..ab3d639 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -1,11 +1,17 @@ +import { filterNodes } from '../filter/filter-nodes'; import { readSpace } from '../read/read-space'; import type { SpaceContext, SpaceNode } from '../types'; import { classifyNodes } from '../util/graph-helpers'; -export async function show(context: SpaceContext) { +export async function show(context: SpaceContext, options?: { filter?: string }) { const levels = context.schema.metadata.hierarchy?.levels ?? []; - const { nodes } = await readSpace(context); + let { nodes } = await readSpace(context); + + if (options?.filter) { + const expression = context.space.views?.[options.filter]?.expression ?? options.filter; + nodes = await filterNodes(expression, nodes); + } const { hierarchyRoots, orphans, nonHierarchy, children } = classifyNodes(nodes, levels); diff --git a/src/config.ts b/src/config.ts index a6ad18e..5b68d93 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,15 @@ const CONFIG_SCHEMA = { miroBoardId: { type: 'string' }, miroFrameId: { type: 'string' }, plugins: { type: 'object', additionalProperties: { type: 'object' } }, + views: { + type: 'object', + additionalProperties: { + type: 'object', + properties: { expression: { type: 'string', minLength: 1 } }, + required: ['expression'], + additionalProperties: false, + }, + }, }, required: ['name', 'path'], additionalProperties: false, @@ -40,6 +49,8 @@ export type SpaceConfig = { miroFrameId?: string; /** Plugin name → plugin config map. Overrides top-level plugins when set. */ plugins?: Record>; + /** Named filter views for this space. Keys are view names; values contain the filter expression. */ + views?: Record; }; export type Config = { diff --git a/src/filter/augment-nodes.ts b/src/filter/augment-nodes.ts new file mode 100644 index 0000000..12664a6 --- /dev/null +++ b/src/filter/augment-nodes.ts @@ -0,0 +1,149 @@ +import type { ResolvedParentRef, SpaceNode } from '../types'; + +/** Edge metadata merged into each ancestor/descendant entry. */ +export type EdgeMetadata = { + _field: string; + _source: 'hierarchy' | 'relationship'; + _selfRef: boolean; +}; + +/** A flat node representation augmented with ancestor and descendant traversal arrays. */ +export type AugmentedFlatNode = Record & { + resolvedType: string; + resolvedParentTitles: string[]; + ancestors: Array & EdgeMetadata>; + descendants: Array & EdgeMetadata>; +}; + +/** + * Build an index from parent title → direct children, using all edges in resolvedParents. + * Used by augmentNode for descendant traversal. + */ +export function buildChildrenIndex(nodes: SpaceNode[]): Map { + const index = new Map(); + for (const node of nodes) { + const title = node.schemaData.title as string; + if (!index.has(title)) index.set(title, []); + + for (const parentRef of node.resolvedParents) { + if (!index.has(parentRef.title)) index.set(parentRef.title, []); + index.get(parentRef.title)!.push(node); + } + } + return index; +} + +/** Flatten a SpaceNode's data fields for use in an augmented representation. */ +function flattenData(node: SpaceNode): Record { + return { + ...node.schemaData, + resolvedType: node.resolvedType, + }; +} + +/** + * Build the augmented flat representation of a node, including pre-computed + * ancestors[] and descendants[] arrays with edge metadata. + * + * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by title. + * - descendants: BFS via childrenIndex, nearest first, deduplicated by title. + * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _selfRef). + */ +export function augmentNode( + node: SpaceNode, + nodeIndex: Map, + childrenIndex: Map, +): AugmentedFlatNode { + const ancestors = buildAncestors(node, nodeIndex); + const descendants = buildDescendants(node, childrenIndex); + + return { + ...flattenData(node), + resolvedType: node.resolvedType, + resolvedParentTitles: node.resolvedParents.map((r) => r.title), + ancestors, + descendants, + }; +} + +function buildAncestors( + node: SpaceNode, + nodeIndex: Map, +): Array & EdgeMetadata> { + const visited = new Set(); + const result: Array & EdgeMetadata> = []; + + // BFS queue holds: { parentRef that led to this node, the node itself } + const queue: Array<{ ref: ResolvedParentRef; node: SpaceNode }> = []; + + for (const ref of node.resolvedParents) { + const parentNode = nodeIndex.get(ref.title); + if (parentNode) queue.push({ ref, node: parentNode }); + } + + while (queue.length > 0) { + const item = queue.shift()!; + const title = item.node.schemaData.title as string; + if (visited.has(title)) continue; + visited.add(title); + + result.push({ + ...flattenData(item.node), + _field: item.ref.field, + _source: item.ref.source, + _selfRef: item.ref.selfRef, + }); + + // Continue BFS upward + for (const ref of item.node.resolvedParents) { + const parentNode = nodeIndex.get(ref.title); + if (parentNode && !visited.has(ref.title)) { + queue.push({ ref, node: parentNode }); + } + } + } + + return result; +} + +function buildDescendants( + node: SpaceNode, + childrenIndex: Map, +): Array & EdgeMetadata> { + const nodeTitle = node.schemaData.title as string; + const visited = new Set(); + const result: Array & EdgeMetadata> = []; + + // BFS queue holds: child node + the resolvedParents entry on that child that points to its parent + const queue: Array<{ childNode: SpaceNode; ref: ResolvedParentRef }> = []; + + const directChildren = childrenIndex.get(nodeTitle) ?? []; + for (const child of directChildren) { + const ref = child.resolvedParents.find((r) => r.title === nodeTitle); + if (ref) queue.push({ childNode: child, ref }); + } + + while (queue.length > 0) { + const item = queue.shift()!; + const title = item.childNode.schemaData.title as string; + if (visited.has(title)) continue; + visited.add(title); + + result.push({ + ...flattenData(item.childNode), + _field: item.ref.field, + _source: item.ref.source, + _selfRef: item.ref.selfRef, + }); + + const grandchildren = childrenIndex.get(title) ?? []; + for (const grandchild of grandchildren) { + if (!visited.has(grandchild.schemaData.title as string)) { + const ref = grandchild.resolvedParents.find((r) => r.title === title); + if (ref) queue.push({ childNode: grandchild, ref }); + } + } + } + + return result; +} diff --git a/src/filter/filter-nodes.ts b/src/filter/filter-nodes.ts new file mode 100644 index 0000000..31879e0 --- /dev/null +++ b/src/filter/filter-nodes.ts @@ -0,0 +1,81 @@ +import jsonata from 'jsonata'; +import type { SpaceNode } from '../types'; +import { type AugmentedFlatNode, augmentNode, buildChildrenIndex } from './augment-nodes'; +import { parseFilterExpression } from './parse-expression'; + +const expressionCache = new Map>(); + +/** + * Filter a set of nodes using a filter expression. + * + * The expression follows the SELECT...WHERE DSL: + * WHERE {jsonata} — return nodes where the JSONata predicate is truthy + * SELECT {spec} WHERE {jsonata} — as above; SELECT expansion is deferred to Phase 2 + * {jsonata} — bare JSONata treated as WHERE predicate + * + * Each node's predicate is evaluated against an augmented context that includes + * pre-computed ancestors[] and descendants[] arrays with edge metadata. + * + * @param expression - Filter DSL expression or view expression string + * @param nodes - All nodes in the space + * @returns Matched SpaceNode[] (original node objects, not augmented representations) + */ +export async function filterNodes(expression: string, nodes: SpaceNode[]): Promise { + const { where, include } = parseFilterExpression(expression); + + if (include !== undefined) { + console.warn( + 'Warning: SELECT clause in filter expression is not yet evaluated. ' + + 'Only the WHERE clause will be applied. SELECT expansion will be supported in a future release.', + ); + } + + if (where === undefined) { + // SELECT-only: no filter predicate, return all nodes (Phase 2 will expand from them) + return nodes; + } + + // Build lookup structures + const nodeIndex = new Map(); + for (const node of nodes) { + const title = node.schemaData.title as string; + if (title) nodeIndex.set(title, node); + } + const childrenIndex = buildChildrenIndex(nodes); + + // Pre-augment all nodes once (ancestors/descendants needed for cross-node predicate access) + const augmented = new Map(); + for (const node of nodes) { + const title = node.schemaData.title as string; + augmented.set(title, augmentNode(node, nodeIndex, childrenIndex)); + } + const allAugmented = Array.from(augmented.values()); + + // Compile and cache the JSONata expression + let expr = expressionCache.get(where); + if (!expr) { + expr = jsonata(where); + expressionCache.set(where, expr); + } + + // Evaluate the predicate for each node + const matched: SpaceNode[] = []; + for (const node of nodes) { + const title = node.schemaData.title as string; + const current = augmented.get(title); + if (!current) continue; + + // Spread current node fields at root level so bare field names work in expressions + // (e.g. `resolvedType='solution'` rather than `current.resolvedType='solution'`). + // Also expose `ancestors` and `descendants` directly, and `nodes` for cross-node access. + const input = { ...current, nodes: allAugmented }; + try { + const result = await expr.evaluate(input); + if (result) matched.push(node); + } catch (error) { + console.warn(`Warning: Error evaluating filter expression for node "${title}":`, error); + } + } + + return matched; +} diff --git a/src/filter/parse-expression.ts b/src/filter/parse-expression.ts new file mode 100644 index 0000000..2d6fd1b --- /dev/null +++ b/src/filter/parse-expression.ts @@ -0,0 +1,69 @@ +/** + * Parser for the filter expression DSL. + * + * Grammar (keywords are case-insensitive): + * WHERE {jsonata} — filter predicate only + * SELECT {spec} WHERE {jsonata} — include spec + filter predicate + * SELECT {spec} — include spec only (Phase 2: expansion from all nodes) + * {jsonata} — bare JSONata treated as WHERE predicate (convenience) + */ + +export type ParsedFilterExpression = { + /** JSONata predicate evaluated per node. Absent means match all. */ + where?: string; + /** Include spec for result expansion (Phase 1: stored but not evaluated). */ + include?: string; +}; + +/** + * Parse a filter expression string into its WHERE and SELECT parts. + * Throws a descriptive error on malformed input. + */ +export function parseFilterExpression(expr: string): ParsedFilterExpression { + const trimmed = expr.trim(); + if (!trimmed) { + throw new Error('Filter expression must not be empty'); + } + + // Case-insensitive keyword detection using regex + // SELECT ... WHERE ... (both present) + const selectWhereMatch = trimmed.match(/^SELECT\s+([\s\S]+?)\s+WHERE\s+([\s\S]+)$/i); + if (selectWhereMatch) { + const include = selectWhereMatch[1]!.trim(); + const where = selectWhereMatch[2]!.trim(); + if (!include) throw new Error('SELECT clause must not be empty'); + if (!where) throw new Error('WHERE clause must not be empty'); + return { include, where }; + } + + // SELECT ... (no WHERE) + const selectOnlyMatch = trimmed.match(/^SELECT\s+([\s\S]+)$/i); + if (selectOnlyMatch) { + const include = selectOnlyMatch[1]!.trim(); + if (!include) throw new Error('SELECT clause must not be empty'); + // Detect SELECT {spec} WHERE (trailing WHERE with no content) + if (/\sWHERE\s*$/i.test(include)) { + throw new Error('WHERE clause must not be empty'); + } + return { include }; + } + + // WHERE ... (no SELECT) + const whereOnlyMatch = trimmed.match(/^WHERE\s+([\s\S]+)$/i); + if (whereOnlyMatch) { + const where = whereOnlyMatch[1]!.trim(); + if (!where) throw new Error('WHERE clause must not be empty'); + return { where }; + } + + // Detect a keyword used without any content (e.g. just "WHERE" or "SELECT") + if (/^WHERE\s*$/i.test(trimmed)) { + throw new Error('WHERE clause must not be empty'); + } + if (/^SELECT\s*$/i.test(trimmed)) { + throw new Error('SELECT clause must not be empty'); + } + + // Bare JSONata — treat as WHERE predicate + return { where: trimmed }; +} diff --git a/src/index.ts b/src/index.ts index bea0432..959f786 100755 --- a/src/index.ts +++ b/src/index.ts @@ -77,7 +77,8 @@ program .command('show') .description('Print space tree as an indented list') .argument('', 'Space name') - .action((spaceName) => show(buildSpaceContext(spaceName))); + .option('--filter ', 'Filter view name (from config) or inline filter expression') + .action((spaceName, options) => show(buildSpaceContext(spaceName), options)); program .command('dump') diff --git a/tests/filter/augment-nodes.test.ts b/tests/filter/augment-nodes.test.ts new file mode 100644 index 0000000..4d30782 --- /dev/null +++ b/tests/filter/augment-nodes.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'bun:test'; +import { augmentNode, buildChildrenIndex } from '../../src/filter/augment-nodes'; +import type { SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; + +function makeNode(title: string, type: string, parentRefs: ReturnType[] = []): SpaceNode { + return { + label: `${title}.md`, + schemaData: { title, type }, + linkTargets: [title], + type, + resolvedType: type, + resolvedParents: parentRefs, + }; +} + +describe('buildChildrenIndex', () => { + it('builds empty index for nodes with no parents', () => { + const root = makeNode('Root', 'goal'); + const index = buildChildrenIndex([root]); + expect(index.get('Root')).toEqual([]); + }); + + it('maps parent titles to their children', () => { + const root = makeNode('Root', 'goal'); + const child = makeNode('Child', 'opportunity', [makeParentRef('Root')]); + const index = buildChildrenIndex([root, child]); + expect(index.get('Root')).toEqual([child]); + expect(index.get('Child')).toEqual([]); + }); + + it('supports multiple children per parent', () => { + const root = makeNode('Root', 'goal'); + const child1 = makeNode('Child 1', 'opportunity', [makeParentRef('Root')]); + const child2 = makeNode('Child 2', 'opportunity', [makeParentRef('Root')]); + const index = buildChildrenIndex([root, child1, child2]); + expect(index.get('Root')).toEqual([child1, child2]); + }); +}); + +describe('augmentNode', () => { + describe('ancestors', () => { + it('returns empty ancestors for a root node', () => { + const root = makeNode('Root', 'goal'); + const nodeIndex = new Map([['Root', root]]); + const childrenIndex = buildChildrenIndex([root]); + const result = augmentNode(root, nodeIndex, childrenIndex); + expect(result.ancestors).toEqual([]); + }); + + it('includes direct parent as ancestor with edge metadata', () => { + const root = makeNode('Root', 'goal'); + const child = makeNode('Child', 'opportunity', [makeParentRef('Root')]); + const nodeIndex = new Map([ + ['Root', root], + ['Child', child], + ]); + const childrenIndex = buildChildrenIndex([root, child]); + const result = augmentNode(child, nodeIndex, childrenIndex); + + expect(result.ancestors).toHaveLength(1); + expect(result.ancestors[0]).toMatchObject({ + title: 'Root', + resolvedType: 'goal', + _field: 'parent', + _source: 'hierarchy', + _selfRef: false, + }); + }); + + it('includes transitive ancestors, nearest first', () => { + const grandparent = makeNode('Grandparent', 'goal'); + const parent = makeNode('Parent', 'opportunity', [makeParentRef('Grandparent')]); + const child = makeNode('Child', 'solution', [makeParentRef('Parent')]); + const nodes = [grandparent, parent, child]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(child, nodeIndex, childrenIndex); + + expect(result.ancestors).toHaveLength(2); + expect(result.ancestors[0]).toMatchObject({ title: 'Parent' }); + expect(result.ancestors[1]).toMatchObject({ title: 'Grandparent' }); + }); + + it('deduplicates ancestors with multiple paths', () => { + // Diamond DAG: child has two parents, both pointing to same grandparent + const grandparent = makeNode('Grandparent', 'goal'); + const parent1 = makeNode('Parent 1', 'opportunity', [makeParentRef('Grandparent')]); + const parent2 = makeNode('Parent 2', 'opportunity', [makeParentRef('Grandparent')]); + const child = makeNode('Child', 'solution', [makeParentRef('Parent 1'), makeParentRef('Parent 2')]); + const nodes = [grandparent, parent1, parent2, child]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(child, nodeIndex, childrenIndex); + + const ancestorTitles = result.ancestors.map((a) => a.title); + // Grandparent appears only once despite two paths + expect(ancestorTitles.filter((t) => t === 'Grandparent')).toHaveLength(1); + }); + + it('handles cycle detection for selfRef nodes', () => { + // solution → solution (self-referential hierarchy) + const solutionA = makeNode('Solution A', 'solution', []); + const solutionB = makeNode('Solution B', 'solution', [makeParentRef('Solution A', { selfRef: true })]); + // Make Solution A also point to Solution B to create a cycle + solutionA.resolvedParents = [makeParentRef('Solution B', { selfRef: true })]; + const nodes = [solutionA, solutionB]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + // Should not throw or infinite loop + const result = augmentNode(solutionB, nodeIndex, childrenIndex); + expect(result.ancestors.length).toBeGreaterThan(0); + // Each title appears at most once + const titles = result.ancestors.map((a) => a.title); + expect(new Set(titles).size).toBe(titles.length); + }); + + it('preserves edge metadata from relationship edges', () => { + const parent = makeNode('Opportunity', 'opportunity'); + const child = makeNode('Assumption', 'assumption', [ + makeParentRef('Opportunity', { source: 'relationship', field: 'assumptions', selfRef: false }), + ]); + const nodes = [parent, child]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(child, nodeIndex, childrenIndex); + + expect(result.ancestors[0]).toMatchObject({ + _field: 'assumptions', + _source: 'relationship', + _selfRef: false, + }); + }); + }); + + describe('descendants', () => { + it('returns empty descendants for a leaf node', () => { + const leaf = makeNode('Leaf', 'solution'); + const nodeIndex = new Map([['Leaf', leaf]]); + const childrenIndex = buildChildrenIndex([leaf]); + const result = augmentNode(leaf, nodeIndex, childrenIndex); + expect(result.descendants).toEqual([]); + }); + + it('includes direct children as descendants', () => { + const root = makeNode('Root', 'goal'); + const child = makeNode('Child', 'opportunity', [makeParentRef('Root')]); + const nodes = [root, child]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(root, nodeIndex, childrenIndex); + + expect(result.descendants).toHaveLength(1); + expect(result.descendants[0]).toMatchObject({ + title: 'Child', + resolvedType: 'opportunity', + _field: 'parent', + _source: 'hierarchy', + }); + }); + + it('includes transitive descendants, nearest first', () => { + const root = makeNode('Root', 'goal'); + const mid = makeNode('Mid', 'opportunity', [makeParentRef('Root')]); + const leaf = makeNode('Leaf', 'solution', [makeParentRef('Mid')]); + const nodes = [root, mid, leaf]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(root, nodeIndex, childrenIndex); + + expect(result.descendants).toHaveLength(2); + expect(result.descendants[0]).toMatchObject({ title: 'Mid' }); + expect(result.descendants[1]).toMatchObject({ title: 'Leaf' }); + }); + + it('deduplicates descendants with multiple paths (diamond DAG)', () => { + const root = makeNode('Root', 'goal'); + const child1 = makeNode('Child 1', 'opportunity', [makeParentRef('Root')]); + const child2 = makeNode('Child 2', 'opportunity', [makeParentRef('Root')]); + const grandchild = makeNode('Grandchild', 'solution', [makeParentRef('Child 1'), makeParentRef('Child 2')]); + const nodes = [root, child1, child2, grandchild]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(root, nodeIndex, childrenIndex); + + const descTitles = result.descendants.map((d) => d.title); + expect(descTitles.filter((t) => t === 'Grandchild')).toHaveLength(1); + }); + }); + + describe('flat node fields', () => { + it('includes schemaData fields at the top level', () => { + const node = makeNode('My Node', 'solution'); + (node.schemaData as Record).status = 'active'; + const nodeIndex = new Map([['My Node', node]]); + const childrenIndex = buildChildrenIndex([node]); + + const result = augmentNode(node, nodeIndex, childrenIndex); + + expect(result['status']).toBe('active'); + expect(result['resolvedType']).toBe('solution'); + }); + + it('includes resolvedParentTitles', () => { + const parent = makeNode('Parent', 'goal'); + const child = makeNode('Child', 'opportunity', [makeParentRef('Parent')]); + const nodes = [parent, child]; + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + + const result = augmentNode(child, nodeIndex, childrenIndex); + + expect(result.resolvedParentTitles).toEqual(['Parent']); + }); + }); +}); diff --git a/tests/filter/filter-nodes.test.ts b/tests/filter/filter-nodes.test.ts new file mode 100644 index 0000000..2f7dd9e --- /dev/null +++ b/tests/filter/filter-nodes.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, spyOn } from 'bun:test'; +import { filterNodes } from '../../src/filter/filter-nodes'; +import type { SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; + +function makeNode(title: string, type: string, extra: Record = {}): SpaceNode { + return { + label: `${title}.md`, + schemaData: { title, type, ...extra }, + linkTargets: [title], + type, + resolvedType: type, + resolvedParents: [], + }; +} + +const goal = makeNode('My Goal', 'goal', { status: 'active' }); +const activeOpportunity = makeNode('Active Opportunity', 'opportunity', { status: 'active' }); +const pausedOpportunity = makeNode('Paused Opportunity', 'opportunity', { status: 'paused' }); +const solution1 = { ...makeNode('Solution 1', 'solution', { status: 'active' }) }; +const solution2 = { ...makeNode('Solution 2', 'solution', { status: 'paused' }) }; + +// Wire up parents +solution1.resolvedParents = [makeParentRef('Active Opportunity')]; +solution2.resolvedParents = [makeParentRef('Paused Opportunity')]; +activeOpportunity.resolvedParents = [makeParentRef('My Goal')]; +pausedOpportunity.resolvedParents = [makeParentRef('My Goal')]; + +const allNodes = [goal, activeOpportunity, pausedOpportunity, solution1, solution2]; + +describe('filterNodes', () => { + describe('WHERE clause matching', () => { + it('filters by resolvedType', async () => { + const result = await filterNodes("WHERE resolvedType='solution'", allNodes); + expect(result).toHaveLength(2); + expect(result.map((n) => n.schemaData.title)).toContain('Solution 1'); + expect(result.map((n) => n.schemaData.title)).toContain('Solution 2'); + }); + + it('filters by a schemaData field (status)', async () => { + const result = await filterNodes("WHERE status='active'", allNodes); + expect(result.map((n) => n.schemaData.title)).toEqual(['My Goal', 'Active Opportunity', 'Solution 1']); + }); + + it('filters by combined conditions', async () => { + const result = await filterNodes("WHERE resolvedType='solution' and status='active'", allNodes); + expect(result).toHaveLength(1); + expect(result[0]!.schemaData.title).toBe('Solution 1'); + }); + + it('returns empty array when nothing matches', async () => { + const result = await filterNodes("WHERE resolvedType='nonexistent'", allNodes); + expect(result).toEqual([]); + }); + + it('filters by ancestor attribute using ancestors[] array', async () => { + // Solutions whose parent opportunity has status='active' + const result = await filterNodes( + "WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active'])", + allNodes, + ); + expect(result).toHaveLength(1); + expect(result[0]!.schemaData.title).toBe('Solution 1'); + }); + + it('supports bare JSONata without WHERE keyword', async () => { + const result = await filterNodes("resolvedType='solution'", allNodes); + expect(result).toHaveLength(2); + }); + }); + + describe('no filter predicate', () => { + it('returns all nodes when no WHERE clause', async () => { + const result = await filterNodes('SELECT ancestors(opportunity)', allNodes); + // SELECT-only: returns all nodes (no WHERE filter) + expect(result).toHaveLength(allNodes.length); + }); + }); + + describe('SELECT clause warning', () => { + it('warns when SELECT clause is present', async () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + await filterNodes("SELECT ancestors(opportunity) WHERE resolvedType='solution'", allNodes); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('SELECT clause')); + warnSpy.mockRestore(); + }); + + it('does not warn when only WHERE is present', async () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + await filterNodes("WHERE resolvedType='solution'", allNodes); + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe('return type', () => { + it('returns the original SpaceNode objects, not augmented representations', async () => { + const result = await filterNodes("WHERE resolvedType='solution'", allNodes); + for (const node of result) { + // Original SpaceNode has resolvedParents; augmented representation would have ancestors[] + expect(node.resolvedParents).toBeDefined(); + expect((node as Record)['ancestors']).toBeUndefined(); + } + }); + }); +}); diff --git a/tests/filter/parse-expression.test.ts b/tests/filter/parse-expression.test.ts new file mode 100644 index 0000000..fdecd78 --- /dev/null +++ b/tests/filter/parse-expression.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'bun:test'; +import { parseFilterExpression } from '../../src/filter/parse-expression'; + +describe('parseFilterExpression', () => { + describe('WHERE-only syntax', () => { + it('parses WHERE clause', () => { + expect(parseFilterExpression("WHERE resolvedType='solution'")).toEqual({ + where: "resolvedType='solution'", + }); + }); + + it('is case-insensitive for WHERE keyword', () => { + expect(parseFilterExpression("where resolvedType='solution'")).toEqual({ + where: "resolvedType='solution'", + }); + expect(parseFilterExpression("Where resolvedType='solution'")).toEqual({ + where: "resolvedType='solution'", + }); + }); + + it('trims whitespace from WHERE predicate', () => { + expect(parseFilterExpression('WHERE status = "active" ')).toEqual({ + where: 'status = "active"', + }); + }); + }); + + describe('bare JSONata (no keyword prefix)', () => { + it('treats bare JSONata as a WHERE predicate', () => { + expect(parseFilterExpression("resolvedType='solution'")).toEqual({ + where: "resolvedType='solution'", + }); + }); + + it('treats complex bare JSONata as WHERE', () => { + expect(parseFilterExpression("status='active' and $count(ancestors)>0")).toEqual({ + where: "status='active' and $count(ancestors)>0", + }); + }); + }); + + describe('SELECT + WHERE syntax', () => { + it('parses SELECT and WHERE parts', () => { + expect(parseFilterExpression("SELECT ancestors(opportunity) WHERE resolvedType='solution'")).toEqual({ + include: 'ancestors(opportunity)', + where: "resolvedType='solution'", + }); + }); + + it('is case-insensitive for SELECT and WHERE keywords', () => { + expect(parseFilterExpression("select ancestors WHERE resolvedType='solution'")).toEqual({ + include: 'ancestors', + where: "resolvedType='solution'", + }); + }); + + it('handles multiple items in SELECT clause', () => { + expect(parseFilterExpression("SELECT ancestors, siblings WHERE status='active'")).toEqual({ + include: 'ancestors, siblings', + where: "status='active'", + }); + }); + }); + + describe('SELECT-only syntax', () => { + it('parses SELECT clause without WHERE', () => { + expect(parseFilterExpression('SELECT ancestors(opportunity)')).toEqual({ + include: 'ancestors(opportunity)', + }); + }); + }); + + describe('error cases', () => { + it('throws on empty expression', () => { + expect(() => parseFilterExpression('')).toThrow('must not be empty'); + expect(() => parseFilterExpression(' ')).toThrow('must not be empty'); + }); + + it('throws on WHERE keyword with empty clause', () => { + expect(() => parseFilterExpression('WHERE')).toThrow('WHERE clause must not be empty'); + expect(() => parseFilterExpression('WHERE ')).toThrow('WHERE clause must not be empty'); + }); + + it('throws on SELECT keyword with empty clause', () => { + expect(() => parseFilterExpression('SELECT')).toThrow('SELECT clause must not be empty'); + expect(() => parseFilterExpression('SELECT ')).toThrow('SELECT clause must not be empty'); + }); + + it('throws on SELECT+WHERE with empty WHERE', () => { + expect(() => parseFilterExpression('SELECT ancestors WHERE')).toThrow('WHERE clause must not be empty'); + }); + }); +}); From f7d5a2143f34111fb2aa7f2bbd41fd38074922ca Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Tue, 24 Mar 2026 23:38:36 +1100 Subject: [PATCH 2/5] feat: implement SELECT include expansion (Phase 2) Implements the SELECT clause in filter expressions, allowing matched nodes to be expanded with related nodes via graph traversal. Supports: ancestors[(type)], descendants[(type)], siblings, relationships[(childType | parentType:childType | parentType:field:childType)] Multiple directives can be combined: SELECT ancestors(goal), siblings WHERE ... The SELECT-only form (no WHERE) starts from all nodes and expands from them. --- README.md | 28 ++- docs/concepts.md | 27 ++- skills/ost-tools/references/commands.md | 8 +- src/filter/expand-include.ts | 246 ++++++++++++++++++++ src/filter/filter-nodes.ts | 84 +++---- tests/filter/expand-include.test.ts | 294 ++++++++++++++++++++++++ tests/filter/filter-nodes.test.ts | 27 ++- 7 files changed, 659 insertions(+), 55 deletions(-) create mode 100644 src/filter/expand-include.ts create mode 100644 tests/filter/expand-include.test.ts diff --git a/README.md b/README.md index d80c96d..aaf4cff 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,8 @@ Filter expressions are used with `--filter` and in config `views`. They use a `S | Form | Meaning | |------|---------| | `WHERE {jsonata}` | Return nodes where the JSONata predicate is truthy | -| `SELECT {spec} WHERE {jsonata}` | As above; SELECT expansion is reserved for a future release | +| `SELECT {spec} WHERE {jsonata}` | Filter by WHERE, then expand result via SELECT | +| `SELECT {spec}` | Expand from all nodes via SELECT (no WHERE filter) | | `{jsonata}` | Bare JSONata, treated as a WHERE predicate (convenience shorthand) | The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Within the expression, each node's fields are accessible directly (e.g. `resolvedType`, `status`, any schema fields like `title`). Additionally, two pre-computed traversal arrays are available: @@ -254,6 +255,22 @@ The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression - `_selfRef` — whether the edge is a same-type (self-referential) link - **`descendants[]`** — same structure, for descendant nodes +**SELECT spec** expands the result set by walking the graph from matched nodes. The spec is a comma-separated list of directives: + +| Directive | Meaning | +|-----------|---------| +| `ancestors` | All ancestor nodes | +| `ancestors(type)` | Ancestors of the given resolved type | +| `descendants` | All descendant nodes | +| `descendants(type)` | Descendants of the given resolved type | +| `siblings` | Nodes sharing at least one parent with matched nodes | +| `relationships` | All nodes connected via a relationship (non-hierarchy) edge | +| `relationships(childType)` | Relationship-connected nodes of the given child type | +| `relationships(parentType:childType)` | As above, also filtering by parent type | +| `relationships(parentType:field:childType)` | Fully qualified: also filtering by edge field name | + +Multiple directives may be combined: `SELECT ancestors(goal), siblings WHERE ...` + **Examples:** ```jsonata @@ -271,6 +288,15 @@ WHERE $exists(ancestors[resolvedType='goal']) // Bare JSONata shorthand (no WHERE keyword) resolvedType='solution' and status='active' + +// Solutions + their opportunity ancestors +SELECT ancestors(opportunity) WHERE resolvedType='solution' + +// Solutions + their siblings (other solutions under same opportunity) +SELECT siblings WHERE resolvedType='solution' and status='active' + +// Opportunities + their related assumptions +SELECT relationships(assumption) WHERE resolvedType='opportunity' ``` ### Generate Mermaid diagram diff --git a/docs/concepts.md b/docs/concepts.md index 21393cb..cd9eee0 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -261,7 +261,8 @@ A **filter expression** is a string that selects a subset of nodes from a space. ``` WHERE {jsonata} — return nodes where the JSONata predicate is truthy -SELECT {spec} WHERE {jsonata} — predicate + include spec (SELECT expansion is a future feature) +SELECT {spec} WHERE {jsonata} — filter by WHERE, then expand result via SELECT +SELECT {spec} — expand from all nodes (no WHERE filter) {jsonata} — bare JSONata, treated as a WHERE predicate (convenience shorthand) ``` @@ -284,6 +285,24 @@ Each entry in `ancestors[]` or `descendants[]` includes all schema fields of the | `_source` | `'hierarchy' \| 'relationship'` | Whether the edge came from the hierarchy or a relationship | | `_selfRef` | `boolean` | Whether the edge is a same-type (self-referential) link | +### SELECT spec + +The SELECT clause expands the result set by walking the graph from matched nodes. The spec is a comma-separated list of directives: + +| Directive | Meaning | +|-----------|---------| +| `ancestors` | All ancestor nodes | +| `ancestors(type)` | Ancestors of the given resolved type | +| `descendants` | All descendant nodes | +| `descendants(type)` | Descendants of the given resolved type | +| `siblings` | Nodes sharing at least one parent with matched nodes | +| `relationships` | Nodes connected via a relationship (non-hierarchy) edge | +| `relationships(childType)` | Relationship-connected nodes of the given child type | +| `relationships(parentType:childType)` | As above, also filtering by parent type | +| `relationships(parentType:field:childType)` | Fully qualified: also filtering by edge field name | + +Multiple directives may be combined with commas: `SELECT ancestors(goal), siblings WHERE ...` + ### Examples ```jsonata @@ -292,6 +311,12 @@ WHERE resolvedType='solution' and status='active' WHERE resolvedType='solution' and $exists(ancestors[resolvedType='opportunity' and status='active']) WHERE $count(descendants[resolvedType='solution']) > 3 + +SELECT ancestors(opportunity) WHERE resolvedType='solution' + +SELECT siblings WHERE resolvedType='solution' and status='active' + +SELECT relationships(assumption) WHERE resolvedType='opportunity' ``` --- diff --git a/skills/ost-tools/references/commands.md b/skills/ost-tools/references/commands.md index 5cc5ae3..3c4d344 100644 --- a/skills/ost-tools/references/commands.md +++ b/skills/ost-tools/references/commands.md @@ -55,12 +55,16 @@ bunx ost-tools show --filter "WHERE resolvedType='solution' and \$exists bunx ost-tools show --filter my-view-name ``` -**Filter expression syntax:** `WHERE {jsonata}` | `SELECT {spec} WHERE {jsonata}` | bare JSONata. -Within the predicate, node fields (e.g. `resolvedType`, `status`) are directly accessible. Two +**Filter expression syntax:** `WHERE {jsonata}` | `SELECT {spec} WHERE {jsonata}` | `SELECT {spec}` | bare JSONata. +Within the WHERE predicate, node fields (e.g. `resolvedType`, `status`) are directly accessible. Two traversal arrays are also available per node: - `ancestors[]` — ancestor nodes nearest-first, each with `_field`, `_source`, `_selfRef` edge metadata - `descendants[]` — descendant nodes, same structure +The SELECT spec is a comma-separated list of directives that expand the result set: +`ancestors[(type)]`, `descendants[(type)]`, `siblings`, +`relationships[(childType | parentType:childType | parentType:field:childType)]` + **Named views** are defined in the space config: ```json5 { diff --git a/src/filter/expand-include.ts b/src/filter/expand-include.ts new file mode 100644 index 0000000..b737b0b --- /dev/null +++ b/src/filter/expand-include.ts @@ -0,0 +1,246 @@ +import type { SpaceNode } from '../types'; +import type { AugmentedFlatNode, EdgeMetadata } from './augment-nodes'; + +// --------------------------------------------------------------------------- +// Directive types +// --------------------------------------------------------------------------- + +export type AncestorsDirective = { + kind: 'ancestors'; + /** Filter by resolved type of the ancestor node. Absent means include all. */ + typeFilter?: string; +}; + +export type DescendantsDirective = { + kind: 'descendants'; + /** Filter by resolved type of the descendant node. Absent means include all. */ + typeFilter?: string; +}; + +export type SiblingsDirective = { + kind: 'siblings'; +}; + +/** + * Relationship directive covers all non-hierarchy edges. + * Progressive specification mirrors the `parent_type:field:child_type` naming convention. + * Any combination of filters may be present; absent fields are treated as wildcards. + */ +export type RelationshipsDirective = { + kind: 'relationships'; + /** Filter by the child side's resolved type. */ + childType?: string; + /** Filter by the parent side's resolved type. */ + parentType?: string; + /** Filter by the edge field name. */ + field?: string; +}; + +export type IncludeDirective = + | AncestorsDirective + | DescendantsDirective + | SiblingsDirective + | RelationshipsDirective; + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/** + * Parse a SELECT include spec string into a list of directives. + * + * Grammar: + * spec = directive (',' directive)* + * directive = 'ancestors' ('(' type ')')? + * | 'descendants' ('(' type ')')? + * | 'siblings' + * | 'relationships' ('(' relSpec ')')? + * type = identifier + * relSpec = childType + * | parentType ':' childType + * | parentType ':' field ':' childType + * + * Keywords are case-insensitive. Identifiers match \w+. + * Range syntax (type..type) is reserved for a future release. + */ +export function parseIncludeSpec(spec: string): IncludeDirective[] { + const items = spec.split(',').map((s) => s.trim()).filter(Boolean); + if (items.length === 0) throw new Error('SELECT spec must not be empty'); + return items.map(parseDirective); +} + +function parseDirective(item: string): IncludeDirective { + const match = item.match(/^(\w+)(?:\(([^)]*)\))?$/i); + if (!match) throw new Error(`Invalid include directive: "${item}"`); + + const name = match[1]!.toLowerCase(); + const arg = match[2]?.trim(); + + if (name === 'siblings') { + if (arg !== undefined && arg !== '') throw new Error('siblings() does not accept arguments'); + return { kind: 'siblings' }; + } + + if (name === 'ancestors' || name === 'descendants') { + if (!arg) return { kind: name }; + if (arg.includes('..')) { + throw new Error( + `Range syntax "${arg}" in SELECT is not yet supported. Use a plain type name for now.`, + ); + } + if (!/^\w+$/.test(arg)) throw new Error(`Invalid type name in ${name}(): "${arg}"`); + return { kind: name, typeFilter: arg }; + } + + if (name === 'relationships') { + if (!arg) return { kind: 'relationships' }; + const parts = arg.split(':').map((s) => s.trim()); + if (parts.some((p) => !/^\w+$/.test(p))) { + throw new Error(`Invalid relationship spec: "${arg}"`); + } + if (parts.length === 1) return { kind: 'relationships', childType: parts[0] }; + if (parts.length === 2) return { kind: 'relationships', parentType: parts[0], childType: parts[1] }; + if (parts.length === 3) { + return { kind: 'relationships', parentType: parts[0], field: parts[1], childType: parts[2] }; + } + throw new Error(`Relationship spec "${arg}" has too many parts (max 3: parent:field:child)`); + } + + throw new Error( + `Unknown include directive "${item}". Expected: ancestors, descendants, siblings, relationships`, + ); +} + +// --------------------------------------------------------------------------- +// Expander +// --------------------------------------------------------------------------- + +/** + * Expand the matched node set by adding nodes specified by the include directives. + * + * Processes each matched node and each directive, collecting additional nodes to + * include. The result is `matched ∪ expanded`, preserving original order with new + * nodes appended in the order they are discovered. + * + * @param matchedNodes - Nodes already matched by the WHERE clause (or all nodes for SELECT-only) + * @param directives - Parsed include directives from the SELECT clause + * @param nodeIndex - Title → SpaceNode lookup + * @param childrenIndex - Title → direct children (all edges) + * @param augmented - Title → AugmentedFlatNode with pre-computed ancestors/descendants + */ +export function expandInclude( + matchedNodes: SpaceNode[], + directives: IncludeDirective[], + nodeIndex: Map, + childrenIndex: Map, + augmented: Map, +): SpaceNode[] { + if (directives.length === 0) return matchedNodes; + + const seen = new Set(matchedNodes.map((n) => n.schemaData.title as string)); + const result: SpaceNode[] = [...matchedNodes]; + + function addByTitle(title: string) { + if (seen.has(title)) return; + const node = nodeIndex.get(title); + if (node) { + seen.add(title); + result.push(node); + } + } + + for (const node of matchedNodes) { + const title = node.schemaData.title as string; + const aug = augmented.get(title); + if (!aug) continue; + + for (const directive of directives) { + applyDirective(node, aug, directive, childrenIndex, addByTitle); + } + } + + return result; +} + +function applyDirective( + node: SpaceNode, + aug: AugmentedFlatNode, + directive: IncludeDirective, + childrenIndex: Map, + addByTitle: (title: string) => void, +): void { + switch (directive.kind) { + case 'ancestors': { + for (const a of aug.ancestors) { + if (!directive.typeFilter || a.resolvedType === directive.typeFilter) { + addByTitle(a.title as string); + } + } + break; + } + case 'descendants': { + for (const d of aug.descendants) { + if (!directive.typeFilter || d.resolvedType === directive.typeFilter) { + addByTitle(d.title as string); + } + } + break; + } + case 'siblings': { + // Nodes that share at least one parent with the matched node (any edge type) + for (const parentRef of node.resolvedParents) { + const siblings = childrenIndex.get(parentRef.title) ?? []; + for (const sibling of siblings) { + const siblingTitle = sibling.schemaData.title as string; + if (siblingTitle !== (node.schemaData.title as string)) { + addByTitle(siblingTitle); + } + } + } + break; + } + case 'relationships': { + // Relationship-sourced ancestors (matched node is the child side) + for (const a of aug.ancestors) { + if (a._source === 'relationship' && matchesRelSpec(node, a, directive, true)) { + addByTitle(a.title as string); + } + } + // Relationship-sourced descendants (matched node is the parent side) + for (const d of aug.descendants) { + if (d._source === 'relationship' && matchesRelSpec(node, d, directive, false)) { + addByTitle(d.title as string); + } + } + break; + } + } +} + +/** + * Check whether a relationship edge entry matches the directive's filter spec. + * + * @param matchedNode - The node from the matched set + * @param entry - An ancestor or descendant entry with edge metadata + * @param directive - The relationships directive with optional type/field filters + * @param entryIsParent - true if entry is the parent side (ancestor), false if child side (descendant) + */ +function matchesRelSpec( + matchedNode: SpaceNode, + entry: Record & EdgeMetadata, + directive: RelationshipsDirective, + entryIsParent: boolean, +): boolean { + if (!directive.childType && !directive.parentType && !directive.field) return true; + + const entryType = entry.resolvedType as string; + const matchedType = matchedNode.resolvedType; + // When entry is the parent, matched node is the child, and vice versa + const parentType = entryIsParent ? entryType : matchedType; + const childType = entryIsParent ? matchedType : entryType; + + if (directive.parentType && parentType !== directive.parentType) return false; + if (directive.childType && childType !== directive.childType) return false; + if (directive.field && entry._field !== directive.field) return false; + return true; +} diff --git a/src/filter/filter-nodes.ts b/src/filter/filter-nodes.ts index 31879e0..a61396d 100644 --- a/src/filter/filter-nodes.ts +++ b/src/filter/filter-nodes.ts @@ -1,6 +1,7 @@ import jsonata from 'jsonata'; import type { SpaceNode } from '../types'; import { type AugmentedFlatNode, augmentNode, buildChildrenIndex } from './augment-nodes'; +import { expandInclude, parseIncludeSpec } from './expand-include'; import { parseFilterExpression } from './parse-expression'; const expressionCache = new Map>(); @@ -10,32 +11,24 @@ const expressionCache = new Map>(); * * The expression follows the SELECT...WHERE DSL: * WHERE {jsonata} — return nodes where the JSONata predicate is truthy - * SELECT {spec} WHERE {jsonata} — as above; SELECT expansion is deferred to Phase 2 + * SELECT {spec} WHERE {jsonata} — filter + expand result via include spec + * SELECT {spec} — expand from all nodes via include spec * {jsonata} — bare JSONata treated as WHERE predicate * - * Each node's predicate is evaluated against an augmented context that includes + * Each node's WHERE predicate is evaluated against an augmented context that includes * pre-computed ancestors[] and descendants[] arrays with edge metadata. * + * The SELECT spec may contain: ancestors[(type)], descendants[(type)], siblings, + * relationships[(childType | parentType:childType | parentType:field:childType)] + * * @param expression - Filter DSL expression or view expression string * @param nodes - All nodes in the space - * @returns Matched SpaceNode[] (original node objects, not augmented representations) + * @returns Filtered+expanded SpaceNode[] (original node objects) */ export async function filterNodes(expression: string, nodes: SpaceNode[]): Promise { const { where, include } = parseFilterExpression(expression); - if (include !== undefined) { - console.warn( - 'Warning: SELECT clause in filter expression is not yet evaluated. ' + - 'Only the WHERE clause will be applied. SELECT expansion will be supported in a future release.', - ); - } - - if (where === undefined) { - // SELECT-only: no filter predicate, return all nodes (Phase 2 will expand from them) - return nodes; - } - - // Build lookup structures + // Build lookup structures (always needed for SELECT expansion or WHERE evaluation) const nodeIndex = new Map(); for (const node of nodes) { const title = node.schemaData.title as string; @@ -43,39 +36,52 @@ export async function filterNodes(expression: string, nodes: SpaceNode[]): Promi } const childrenIndex = buildChildrenIndex(nodes); - // Pre-augment all nodes once (ancestors/descendants needed for cross-node predicate access) + // Pre-augment all nodes once (ancestors/descendants needed for WHERE predicates and SELECT expansion) const augmented = new Map(); for (const node of nodes) { const title = node.schemaData.title as string; augmented.set(title, augmentNode(node, nodeIndex, childrenIndex)); } - const allAugmented = Array.from(augmented.values()); - // Compile and cache the JSONata expression - let expr = expressionCache.get(where); - if (!expr) { - expr = jsonata(where); - expressionCache.set(where, expr); - } + // Step 1: apply WHERE clause to get the matched set + let matched: SpaceNode[]; + if (where === undefined) { + // SELECT-only: start from all nodes + matched = nodes; + } else { + const allAugmented = Array.from(augmented.values()); - // Evaluate the predicate for each node - const matched: SpaceNode[] = []; - for (const node of nodes) { - const title = node.schemaData.title as string; - const current = augmented.get(title); - if (!current) continue; + // Compile and cache the JSONata expression + let expr = expressionCache.get(where); + if (!expr) { + expr = jsonata(where); + expressionCache.set(where, expr); + } - // Spread current node fields at root level so bare field names work in expressions - // (e.g. `resolvedType='solution'` rather than `current.resolvedType='solution'`). - // Also expose `ancestors` and `descendants` directly, and `nodes` for cross-node access. - const input = { ...current, nodes: allAugmented }; - try { - const result = await expr.evaluate(input); - if (result) matched.push(node); - } catch (error) { - console.warn(`Warning: Error evaluating filter expression for node "${title}":`, error); + matched = []; + for (const node of nodes) { + const title = node.schemaData.title as string; + const current = augmented.get(title); + if (!current) continue; + + // Spread current node fields at root level so bare field names work in expressions + // (e.g. `resolvedType='solution'` rather than `current.resolvedType='solution'`). + // Also expose `ancestors` and `descendants` directly, and `nodes` for cross-node access. + const input = { ...current, nodes: allAugmented }; + try { + const result = await expr.evaluate(input); + if (result) matched.push(node); + } catch (error) { + console.warn(`Warning: Error evaluating filter expression for node "${title}":`, error); + } } } + // Step 2: apply SELECT clause to expand the result set + if (include !== undefined) { + const directives = parseIncludeSpec(include); + return expandInclude(matched, directives, nodeIndex, childrenIndex, augmented); + } + return matched; } diff --git a/tests/filter/expand-include.test.ts b/tests/filter/expand-include.test.ts new file mode 100644 index 0000000..2ccc31c --- /dev/null +++ b/tests/filter/expand-include.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from 'bun:test'; +import { expandInclude, parseIncludeSpec } from '../../src/filter/expand-include'; +import { augmentNode, buildChildrenIndex } from '../../src/filter/augment-nodes'; +import type { SpaceNode } from '../../src/types'; +import { makeParentRef } from '../test-helpers'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeNode(title: string, type: string, extra: Record = {}): SpaceNode { + return { + label: `${title}.md`, + schemaData: { title, type, ...extra }, + linkTargets: [title], + type, + resolvedType: type, + resolvedParents: [], + }; +} + +function buildContext(nodes: SpaceNode[]) { + const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); + const childrenIndex = buildChildrenIndex(nodes); + const augmented = new Map( + nodes.map((n) => [ + n.schemaData.title as string, + augmentNode(n, nodeIndex, childrenIndex), + ]), + ); + return { nodeIndex, childrenIndex, augmented }; +} + +// --------------------------------------------------------------------------- +// parseIncludeSpec +// --------------------------------------------------------------------------- + +describe('parseIncludeSpec', () => { + it('parses bare ancestors', () => { + expect(parseIncludeSpec('ancestors')).toEqual([{ kind: 'ancestors' }]); + }); + + it('parses ancestors with type filter', () => { + expect(parseIncludeSpec('ancestors(goal)')).toEqual([{ kind: 'ancestors', typeFilter: 'goal' }]); + }); + + it('parses descendants', () => { + expect(parseIncludeSpec('descendants')).toEqual([{ kind: 'descendants' }]); + }); + + it('parses descendants with type filter', () => { + expect(parseIncludeSpec('descendants(solution)')).toEqual([ + { kind: 'descendants', typeFilter: 'solution' }, + ]); + }); + + it('parses siblings', () => { + expect(parseIncludeSpec('siblings')).toEqual([{ kind: 'siblings' }]); + }); + + it('parses bare relationships', () => { + expect(parseIncludeSpec('relationships')).toEqual([{ kind: 'relationships' }]); + }); + + it('parses relationships with child type', () => { + expect(parseIncludeSpec('relationships(assumption)')).toEqual([ + { kind: 'relationships', childType: 'assumption' }, + ]); + }); + + it('parses relationships with parent:child', () => { + expect(parseIncludeSpec('relationships(opportunity:assumption)')).toEqual([ + { kind: 'relationships', parentType: 'opportunity', childType: 'assumption' }, + ]); + }); + + it('parses relationships with parent:field:child', () => { + expect(parseIncludeSpec('relationships(activities:data_produced:data)')).toEqual([ + { kind: 'relationships', parentType: 'activities', field: 'data_produced', childType: 'data' }, + ]); + }); + + it('parses multiple comma-separated directives', () => { + expect(parseIncludeSpec('ancestors(goal), siblings')).toEqual([ + { kind: 'ancestors', typeFilter: 'goal' }, + { kind: 'siblings' }, + ]); + }); + + it('is case-insensitive for directive names', () => { + expect(parseIncludeSpec('Ancestors(goal)')).toEqual([{ kind: 'ancestors', typeFilter: 'goal' }]); + expect(parseIncludeSpec('SIBLINGS')).toEqual([{ kind: 'siblings' }]); + }); + + describe('error cases', () => { + it('throws on empty spec', () => { + expect(() => parseIncludeSpec('')).toThrow('must not be empty'); + }); + + it('throws on unknown directive', () => { + expect(() => parseIncludeSpec('parents')).toThrow('Unknown include directive'); + }); + + it('throws on range syntax (not yet supported)', () => { + expect(() => parseIncludeSpec('ancestors(opportunity..goal)')).toThrow('Range syntax'); + }); + + it('throws on too many relationship parts', () => { + expect(() => parseIncludeSpec('relationships(a:b:c:d)')).toThrow('too many parts'); + }); + + it('throws on siblings with argument', () => { + expect(() => parseIncludeSpec('siblings(goal)')).toThrow('does not accept arguments'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// expandInclude +// --------------------------------------------------------------------------- + +// Fixture graph: +// goal → opportunity A → solution 1 +// → solution 2 +// → opportunity B → solution 3 +// +// plus an assumption linked to opportunity A via relationship edge +const goal = makeNode('Goal', 'goal'); +const oppA = makeNode('Opportunity A', 'opportunity'); +const oppB = makeNode('Opportunity B', 'opportunity'); +const sol1 = makeNode('Solution 1', 'solution'); +const sol2 = makeNode('Solution 2', 'solution'); +const sol3 = makeNode('Solution 3', 'solution'); +const assumption = makeNode('Assumption 1', 'assumption'); + +oppA.resolvedParents = [makeParentRef('Goal')]; +oppB.resolvedParents = [makeParentRef('Goal')]; +sol1.resolvedParents = [makeParentRef('Opportunity A')]; +sol2.resolvedParents = [makeParentRef('Opportunity A')]; +sol3.resolvedParents = [makeParentRef('Opportunity B')]; +// assumption linked to opportunity A via relationship +assumption.resolvedParents = [makeParentRef('Opportunity A', { source: 'relationship', field: 'assumptions' })]; + +const allNodes = [goal, oppA, oppB, sol1, sol2, sol3, assumption]; +const ctx = buildContext(allNodes); + +describe('expandInclude — ancestors', () => { + it('adds all ancestors of matched nodes', () => { + const result = expandInclude([sol1], parseIncludeSpec('ancestors'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Solution 1'); + expect(titles).toContain('Opportunity A'); + expect(titles).toContain('Goal'); + }); + + it('filters ancestors by type', () => { + const result = expandInclude([sol1], parseIncludeSpec('ancestors(goal)'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Solution 1'); + expect(titles).toContain('Goal'); + expect(titles).not.toContain('Opportunity A'); + }); + + it('deduplicates ancestors when multiple matched nodes share them', () => { + const result = expandInclude( + [sol1, sol2], + parseIncludeSpec('ancestors(goal)'), + ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles.filter((t) => t === 'Goal')).toHaveLength(1); + }); +}); + +describe('expandInclude — descendants', () => { + it('adds all descendants of matched nodes', () => { + const result = expandInclude([goal], parseIncludeSpec('descendants'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Goal'); + expect(titles).toContain('Opportunity A'); + expect(titles).toContain('Solution 1'); + expect(titles).toContain('Solution 2'); + expect(titles).toContain('Solution 3'); + }); + + it('filters descendants by type', () => { + const result = expandInclude( + [goal], + parseIncludeSpec('descendants(solution)'), + ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Goal'); + expect(titles).toContain('Solution 1'); + expect(titles).not.toContain('Opportunity A'); + }); +}); + +describe('expandInclude — siblings', () => { + it('adds sibling nodes (other children of same parent)', () => { + const result = expandInclude([sol1], parseIncludeSpec('siblings'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Solution 1'); + expect(titles).toContain('Solution 2'); // sibling under Opportunity A + expect(titles).not.toContain('Solution 3'); // under Opportunity B, not a sibling + }); + + it('does not include the matched node as its own sibling', () => { + const result = expandInclude([sol1], parseIncludeSpec('siblings'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles.filter((t) => t === 'Solution 1')).toHaveLength(1); + }); +}); + +describe('expandInclude — relationships', () => { + it('adds nodes connected via relationship edges (no filter)', () => { + const result = expandInclude([oppA], parseIncludeSpec('relationships'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Opportunity A'); + expect(titles).toContain('Assumption 1'); + }); + + it('does not include hierarchy-connected nodes', () => { + const result = expandInclude([oppA], parseIncludeSpec('relationships'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + // solutions are hierarchy children, not relationship children + expect(titles).not.toContain('Solution 1'); + expect(titles).not.toContain('Solution 2'); + // goal is hierarchy parent, not relationship parent + expect(titles).not.toContain('Goal'); + }); + + it('filters relationships by child type', () => { + const result = expandInclude([oppA], parseIncludeSpec('relationships(assumption)'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Assumption 1'); + }); + + it('filters relationships by parent:child type pair', () => { + const result = expandInclude( + [oppA], + parseIncludeSpec('relationships(opportunity:assumption)'), + ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Assumption 1'); + }); + + it('filters relationships by parent:field:child', () => { + const result = expandInclude( + [oppA], + parseIncludeSpec('relationships(opportunity:assumptions:assumption)'), + ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Assumption 1'); + }); + + it('excludes when field does not match', () => { + const result = expandInclude( + [oppA], + parseIncludeSpec('relationships(opportunity:risks:assumption)'), + ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles).not.toContain('Assumption 1'); + }); +}); + +describe('expandInclude — multiple directives', () => { + it('combines directives, deduplicating results', () => { + const result = expandInclude( + [sol1], + parseIncludeSpec('ancestors(goal), siblings'), + ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Solution 1'); // matched + expect(titles).toContain('Goal'); // via ancestors(goal) + expect(titles).toContain('Solution 2'); // via siblings + expect(titles).not.toContain('Opportunity A'); // filtered out by ancestors(goal) + // No duplicates + expect(new Set(titles).size).toBe(titles.length); + }); +}); + +describe('expandInclude — preserves matched nodes', () => { + it('always includes matched nodes in result', () => { + const result = expandInclude([sol3], parseIncludeSpec('ancestors(goal)'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Solution 3'); + expect(titles).toContain('Goal'); + }); +}); diff --git a/tests/filter/filter-nodes.test.ts b/tests/filter/filter-nodes.test.ts index 2f7dd9e..facb708 100644 --- a/tests/filter/filter-nodes.test.ts +++ b/tests/filter/filter-nodes.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, spyOn } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { filterNodes } from '../../src/filter/filter-nodes'; import type { SpaceNode } from '../../src/types'; import { makeParentRef } from '../test-helpers'; @@ -77,19 +77,22 @@ describe('filterNodes', () => { }); }); - describe('SELECT clause warning', () => { - it('warns when SELECT clause is present', async () => { - const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); - await filterNodes("SELECT ancestors(opportunity) WHERE resolvedType='solution'", allNodes); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('SELECT clause')); - warnSpy.mockRestore(); + describe('SELECT clause expansion', () => { + it('expands result with SELECT ancestors when present', async () => { + // Matched: solutions. Expanded: + their opportunity ancestor. + const result = await filterNodes( + "SELECT ancestors(opportunity) WHERE resolvedType='solution'", + allNodes, + ); + const titles = result.map((n) => n.schemaData.title); + expect(titles).toContain('Solution 1'); + expect(titles).toContain('Active Opportunity'); // ancestor of solution 1 }); - it('does not warn when only WHERE is present', async () => { - const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); - await filterNodes("WHERE resolvedType='solution'", allNodes); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); + it('SELECT-only returns all nodes expanded', async () => { + const result = await filterNodes('SELECT ancestors(opportunity)', allNodes); + // All nodes returned (SELECT-only = no WHERE filter) + expect(result.length).toBeGreaterThanOrEqual(allNodes.length); }); }); From f07165b502b7a703c4fe428072d9f79fc15d1d4f Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 09:13:52 +1100 Subject: [PATCH 3/5] style: auto-format filter implementation files --- src/filter/expand-include.ts | 19 ++--- tests/filter/expand-include.test.ts | 103 ++++++++++++++++++++-------- tests/filter/filter-nodes.test.ts | 5 +- 3 files changed, 82 insertions(+), 45 deletions(-) diff --git a/src/filter/expand-include.ts b/src/filter/expand-include.ts index b737b0b..da05a55 100644 --- a/src/filter/expand-include.ts +++ b/src/filter/expand-include.ts @@ -36,11 +36,7 @@ export type RelationshipsDirective = { field?: string; }; -export type IncludeDirective = - | AncestorsDirective - | DescendantsDirective - | SiblingsDirective - | RelationshipsDirective; +export type IncludeDirective = AncestorsDirective | DescendantsDirective | SiblingsDirective | RelationshipsDirective; // --------------------------------------------------------------------------- // Parser @@ -64,7 +60,10 @@ export type IncludeDirective = * Range syntax (type..type) is reserved for a future release. */ export function parseIncludeSpec(spec: string): IncludeDirective[] { - const items = spec.split(',').map((s) => s.trim()).filter(Boolean); + const items = spec + .split(',') + .map((s) => s.trim()) + .filter(Boolean); if (items.length === 0) throw new Error('SELECT spec must not be empty'); return items.map(parseDirective); } @@ -84,9 +83,7 @@ function parseDirective(item: string): IncludeDirective { if (name === 'ancestors' || name === 'descendants') { if (!arg) return { kind: name }; if (arg.includes('..')) { - throw new Error( - `Range syntax "${arg}" in SELECT is not yet supported. Use a plain type name for now.`, - ); + throw new Error(`Range syntax "${arg}" in SELECT is not yet supported. Use a plain type name for now.`); } if (!/^\w+$/.test(arg)) throw new Error(`Invalid type name in ${name}(): "${arg}"`); return { kind: name, typeFilter: arg }; @@ -106,9 +103,7 @@ function parseDirective(item: string): IncludeDirective { throw new Error(`Relationship spec "${arg}" has too many parts (max 3: parent:field:child)`); } - throw new Error( - `Unknown include directive "${item}". Expected: ancestors, descendants, siblings, relationships`, - ); + throw new Error(`Unknown include directive "${item}". Expected: ancestors, descendants, siblings, relationships`); } // --------------------------------------------------------------------------- diff --git a/tests/filter/expand-include.test.ts b/tests/filter/expand-include.test.ts index 2ccc31c..bcbc4f3 100644 --- a/tests/filter/expand-include.test.ts +++ b/tests/filter/expand-include.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; -import { expandInclude, parseIncludeSpec } from '../../src/filter/expand-include'; import { augmentNode, buildChildrenIndex } from '../../src/filter/augment-nodes'; +import { expandInclude, parseIncludeSpec } from '../../src/filter/expand-include'; import type { SpaceNode } from '../../src/types'; import { makeParentRef } from '../test-helpers'; @@ -22,12 +22,7 @@ function makeNode(title: string, type: string, extra: Record = function buildContext(nodes: SpaceNode[]) { const nodeIndex = new Map(nodes.map((n) => [n.schemaData.title as string, n])); const childrenIndex = buildChildrenIndex(nodes); - const augmented = new Map( - nodes.map((n) => [ - n.schemaData.title as string, - augmentNode(n, nodeIndex, childrenIndex), - ]), - ); + const augmented = new Map(nodes.map((n) => [n.schemaData.title as string, augmentNode(n, nodeIndex, childrenIndex)])); return { nodeIndex, childrenIndex, augmented }; } @@ -49,9 +44,7 @@ describe('parseIncludeSpec', () => { }); it('parses descendants with type filter', () => { - expect(parseIncludeSpec('descendants(solution)')).toEqual([ - { kind: 'descendants', typeFilter: 'solution' }, - ]); + expect(parseIncludeSpec('descendants(solution)')).toEqual([{ kind: 'descendants', typeFilter: 'solution' }]); }); it('parses siblings', () => { @@ -63,9 +56,7 @@ describe('parseIncludeSpec', () => { }); it('parses relationships with child type', () => { - expect(parseIncludeSpec('relationships(assumption)')).toEqual([ - { kind: 'relationships', childType: 'assumption' }, - ]); + expect(parseIncludeSpec('relationships(assumption)')).toEqual([{ kind: 'relationships', childType: 'assumption' }]); }); it('parses relationships with parent:child', () => { @@ -146,7 +137,13 @@ const ctx = buildContext(allNodes); describe('expandInclude — ancestors', () => { it('adds all ancestors of matched nodes', () => { - const result = expandInclude([sol1], parseIncludeSpec('ancestors'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [sol1], + parseIncludeSpec('ancestors'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Solution 1'); expect(titles).toContain('Opportunity A'); @@ -154,7 +151,13 @@ describe('expandInclude — ancestors', () => { }); it('filters ancestors by type', () => { - const result = expandInclude([sol1], parseIncludeSpec('ancestors(goal)'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [sol1], + parseIncludeSpec('ancestors(goal)'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Solution 1'); expect(titles).toContain('Goal'); @@ -165,7 +168,9 @@ describe('expandInclude — ancestors', () => { const result = expandInclude( [sol1, sol2], parseIncludeSpec('ancestors(goal)'), - ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, ); const titles = result.map((n) => n.schemaData.title); expect(titles.filter((t) => t === 'Goal')).toHaveLength(1); @@ -174,7 +179,13 @@ describe('expandInclude — ancestors', () => { describe('expandInclude — descendants', () => { it('adds all descendants of matched nodes', () => { - const result = expandInclude([goal], parseIncludeSpec('descendants'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [goal], + parseIncludeSpec('descendants'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Goal'); expect(titles).toContain('Opportunity A'); @@ -187,7 +198,9 @@ describe('expandInclude — descendants', () => { const result = expandInclude( [goal], parseIncludeSpec('descendants(solution)'), - ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Goal'); @@ -214,14 +227,26 @@ describe('expandInclude — siblings', () => { describe('expandInclude — relationships', () => { it('adds nodes connected via relationship edges (no filter)', () => { - const result = expandInclude([oppA], parseIncludeSpec('relationships'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [oppA], + parseIncludeSpec('relationships'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Opportunity A'); expect(titles).toContain('Assumption 1'); }); it('does not include hierarchy-connected nodes', () => { - const result = expandInclude([oppA], parseIncludeSpec('relationships'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [oppA], + parseIncludeSpec('relationships'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); // solutions are hierarchy children, not relationship children expect(titles).not.toContain('Solution 1'); @@ -231,7 +256,13 @@ describe('expandInclude — relationships', () => { }); it('filters relationships by child type', () => { - const result = expandInclude([oppA], parseIncludeSpec('relationships(assumption)'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [oppA], + parseIncludeSpec('relationships(assumption)'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Assumption 1'); }); @@ -240,7 +271,9 @@ describe('expandInclude — relationships', () => { const result = expandInclude( [oppA], parseIncludeSpec('relationships(opportunity:assumption)'), - ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Assumption 1'); @@ -250,7 +283,9 @@ describe('expandInclude — relationships', () => { const result = expandInclude( [oppA], parseIncludeSpec('relationships(opportunity:assumptions:assumption)'), - ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Assumption 1'); @@ -260,7 +295,9 @@ describe('expandInclude — relationships', () => { const result = expandInclude( [oppA], parseIncludeSpec('relationships(opportunity:risks:assumption)'), - ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, ); const titles = result.map((n) => n.schemaData.title); expect(titles).not.toContain('Assumption 1'); @@ -272,12 +309,14 @@ describe('expandInclude — multiple directives', () => { const result = expandInclude( [sol1], parseIncludeSpec('ancestors(goal), siblings'), - ctx.nodeIndex, ctx.childrenIndex, ctx.augmented, + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, ); const titles = result.map((n) => n.schemaData.title); - expect(titles).toContain('Solution 1'); // matched - expect(titles).toContain('Goal'); // via ancestors(goal) - expect(titles).toContain('Solution 2'); // via siblings + expect(titles).toContain('Solution 1'); // matched + expect(titles).toContain('Goal'); // via ancestors(goal) + expect(titles).toContain('Solution 2'); // via siblings expect(titles).not.toContain('Opportunity A'); // filtered out by ancestors(goal) // No duplicates expect(new Set(titles).size).toBe(titles.length); @@ -286,7 +325,13 @@ describe('expandInclude — multiple directives', () => { describe('expandInclude — preserves matched nodes', () => { it('always includes matched nodes in result', () => { - const result = expandInclude([sol3], parseIncludeSpec('ancestors(goal)'), ctx.nodeIndex, ctx.childrenIndex, ctx.augmented); + const result = expandInclude( + [sol3], + parseIncludeSpec('ancestors(goal)'), + ctx.nodeIndex, + ctx.childrenIndex, + ctx.augmented, + ); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Solution 3'); expect(titles).toContain('Goal'); diff --git a/tests/filter/filter-nodes.test.ts b/tests/filter/filter-nodes.test.ts index facb708..48dd97c 100644 --- a/tests/filter/filter-nodes.test.ts +++ b/tests/filter/filter-nodes.test.ts @@ -80,10 +80,7 @@ describe('filterNodes', () => { describe('SELECT clause expansion', () => { it('expands result with SELECT ancestors when present', async () => { // Matched: solutions. Expanded: + their opportunity ancestor. - const result = await filterNodes( - "SELECT ancestors(opportunity) WHERE resolvedType='solution'", - allNodes, - ); + const result = await filterNodes("SELECT ancestors(opportunity) WHERE resolvedType='solution'", allNodes); const titles = result.map((n) => n.schemaData.title); expect(titles).toContain('Solution 1'); expect(titles).toContain('Active Opportunity'); // ancestor of solution 1 From f20976d36473ad392cd68901f8da2c8ad64797f0 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 17:27:45 +1100 Subject: [PATCH 4/5] fix: make filter evaluation errors hard failures; clarify bare SELECT in docs - Remove warn-and-skip in filterNodes; evaluation errors now propagate - README: clarify that bare SELECT (no WHERE) returns all nodes expanded per spec --- README.md | 2 +- src/filter/filter-nodes.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index aaf4cff..d54e797 100644 --- a/README.md +++ b/README.md @@ -244,7 +244,7 @@ Filter expressions are used with `--filter` and in config `views`. They use a `S |------|---------| | `WHERE {jsonata}` | Return nodes where the JSONata predicate is truthy | | `SELECT {spec} WHERE {jsonata}` | Filter by WHERE, then expand result via SELECT | -| `SELECT {spec}` | Expand from all nodes via SELECT (no WHERE filter) | +| `SELECT {spec}` | Expand from all nodes via SELECT (no WHERE filter — returns all nodes, expanded per spec) | | `{jsonata}` | Bare JSONata, treated as a WHERE predicate (convenience shorthand) | The WHERE predicate is a [JSONata](https://docs.jsonata.org/overview) expression evaluated per node. Within the expression, each node's fields are accessible directly (e.g. `resolvedType`, `status`, any schema fields like `title`). Additionally, two pre-computed traversal arrays are available: diff --git a/src/filter/filter-nodes.ts b/src/filter/filter-nodes.ts index a61396d..b638237 100644 --- a/src/filter/filter-nodes.ts +++ b/src/filter/filter-nodes.ts @@ -68,12 +68,8 @@ export async function filterNodes(expression: string, nodes: SpaceNode[]): Promi // (e.g. `resolvedType='solution'` rather than `current.resolvedType='solution'`). // Also expose `ancestors` and `descendants` directly, and `nodes` for cross-node access. const input = { ...current, nodes: allAugmented }; - try { const result = await expr.evaluate(input); if (result) matched.push(node); - } catch (error) { - console.warn(`Warning: Error evaluating filter expression for node "${title}":`, error); - } } } From 8cbae0de3d9e3c728e6de3ac65cecd4cfced44e6 Mon Sep 17 00:00:00 2001 From: Roger Barnes Date: Wed, 25 Mar 2026 17:50:23 +1100 Subject: [PATCH 5/5] fix: use dot notation for property access (biome useLiteralKeys) --- tests/filter/augment-nodes.test.ts | 4 ++-- tests/filter/filter-nodes.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/filter/augment-nodes.test.ts b/tests/filter/augment-nodes.test.ts index 4d30782..b920617 100644 --- a/tests/filter/augment-nodes.test.ts +++ b/tests/filter/augment-nodes.test.ts @@ -204,8 +204,8 @@ describe('augmentNode', () => { const result = augmentNode(node, nodeIndex, childrenIndex); - expect(result['status']).toBe('active'); - expect(result['resolvedType']).toBe('solution'); + expect(result.status).toBe('active'); + expect(result.resolvedType).toBe('solution'); }); it('includes resolvedParentTitles', () => { diff --git a/tests/filter/filter-nodes.test.ts b/tests/filter/filter-nodes.test.ts index 48dd97c..02a1e77 100644 --- a/tests/filter/filter-nodes.test.ts +++ b/tests/filter/filter-nodes.test.ts @@ -99,7 +99,7 @@ describe('filterNodes', () => { for (const node of result) { // Original SpaceNode has resolvedParents; augmented representation would have ancestors[] expect(node.resolvedParents).toBeDefined(); - expect((node as Record)['ancestors']).toBeUndefined(); + expect(((node as Record).ancestors)).toBeUndefined(); } }); });