diff --git a/package.json b/package.json index c3d0d0b..857bc1d 100644 --- a/package.json +++ b/package.json @@ -3,17 +3,41 @@ "version": "0.1.0", "description": "TypeScript graph database abstraction layer with type-safe node/edge management and schema validation", "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, "main": "dist/index.js", + "module": "./dist/index.js", "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "sideEffects": false, "scripts": { "build": "tsup src/index.ts --format esm,cjs --dts && npm run check:dts", "check:dts": "node -e \"require('node:fs').accessSync('dist/index.d.ts'); console.log('dist/index.d.ts present')\"", + "prepack": "npm run build", "test": "vitest run", "test:watch": "vitest watch", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit" }, - "keywords": ["graph", "database", "typescript", "schema"], + "keywords": [ + "graph", + "database", + "typescript", + "schema" + ], "author": "", "license": "MIT", "devDependencies": { diff --git a/src/operations/traverse.ts b/src/operations/traverse.ts index 275e820..8770263 100644 --- a/src/operations/traverse.ts +++ b/src/operations/traverse.ts @@ -61,6 +61,18 @@ export interface TraverseOptions { /** * Only include nodes of these types in the result. * If not specified, all node types are included. + * + * @remarks + * This gates only which nodes appear in the returned array -- it does not + * prune the traversal. Filtered-out nodes are still traversed *through*: + * their edges are expanded and they are marked visited, exactly like any + * other node. For example, with `nodeTypes: ['Person']`, a path + * `A(Person) -> X(Company) -> B(Person)` yields `['A', 'B']`: `X` is + * visited and its edges are expanded, but it is omitted from the result. + * + * Pass-through is the deliberate choice here: a caller who wants prune + * semantics cannot reconstruct pass-through, whereas a caller who wants only + * the listed types can already filter the returned array. */ nodeTypes?: string[]; } @@ -124,17 +136,19 @@ export function traverse( if (visited.has(nodeId)) { continue; } + visited.add(nodeId); - // Check node type filter + // Check node type filter. This gates only whether the node appears in + // `result` -- it does not prune the traversal. Filtered-out nodes are + // still marked visited (above) and their edges are still expanded + // (below), so a path through a filtered node still reaches whatever + // lies beyond it (IF-3-RR0 pass-through semantics). const node = storage.getNode(nodeId); - if (nodeTypes && node && !nodeTypes.includes(node.type)) { - // Skip this node but don't add to visited (allow reaching through it) - continue; + const passesTypeFilter = !nodeTypes || !node || nodeTypes.includes(node.type); + if (passesTypeFilter) { + result.push(nodeId); } - visited.add(nodeId); - result.push(nodeId); - // Check depth limit (0 means unlimited) if (maxDepth > 0 && depth >= maxDepth) { continue; diff --git a/tests/unit/operations.test.ts b/tests/unit/operations.test.ts index 796dad9..eff02c5 100644 --- a/tests/unit/operations.test.ts +++ b/tests/unit/operations.test.ts @@ -11,7 +11,7 @@ * - Subgraph operations: subgraph() */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MemoryStorage } from '../../src/storage/index.js'; import type { Storage } from '../../src/storage/index.js'; import type { Node, Edge } from '../../src/types/index.js'; @@ -230,6 +230,54 @@ describe('Graph Operations', () => { expect(visited).not.toContain('E'); }); + it('should pass through a filtered node to reach nodes beyond it', () => { + // A(Person) -> X(Company) -> B(Person), nodeTypes: ['Person'] + // X is not a leaf here (unlike D/E in the base graph), so this distinguishes + // pass-through (X visited + expanded, B reached) from prune (X dropped, B unreachable). + storage.addNode({ id: 'PT_A', type: 'Person', properties: {} }); + storage.addNode({ id: 'PT_X', type: 'Company', properties: {} }); + storage.addNode({ id: 'PT_B', type: 'Person', properties: {} }); + storage.addEdge({ id: 'pt_e1', type: 'WORKS_AT', source: 'PT_A', target: 'PT_X', properties: {} }); + storage.addEdge({ id: 'pt_e2', type: 'WORKS_AT', source: 'PT_X', target: 'PT_B', properties: {} }); + + const visited = traverse(storage, 'PT_A', { nodeTypes: ['Person'] }); + + expect(visited).toContain('PT_A'); + expect(visited).toContain('PT_B'); + expect(visited).not.toContain('PT_X'); + }); + + it('should process a filtered node reachable via two paths exactly once (re-enqueue regression)', () => { + // PT2_S -> PT2_M1 -> PT2_F + // PT2_S -> PT2_M2 -> PT2_F + // PT2_F -> PT2_G + // PT2_F (Company) is reachable via two paths; it must be looked up (processed) only + // once, not once per incoming path, and traversal must still reach PT2_G through it. + storage.addNode({ id: 'PT2_S', type: 'Person', properties: {} }); + storage.addNode({ id: 'PT2_M1', type: 'Person', properties: {} }); + storage.addNode({ id: 'PT2_M2', type: 'Person', properties: {} }); + storage.addNode({ id: 'PT2_F', type: 'Company', properties: {} }); + storage.addNode({ id: 'PT2_G', type: 'Person', properties: {} }); + storage.addEdge({ id: 'pt2_e1', type: 'REL', source: 'PT2_S', target: 'PT2_M1', properties: {} }); + storage.addEdge({ id: 'pt2_e2', type: 'REL', source: 'PT2_S', target: 'PT2_M2', properties: {} }); + storage.addEdge({ id: 'pt2_e3', type: 'REL', source: 'PT2_M1', target: 'PT2_F', properties: {} }); + storage.addEdge({ id: 'pt2_e4', type: 'REL', source: 'PT2_M2', target: 'PT2_F', properties: {} }); + storage.addEdge({ id: 'pt2_e5', type: 'REL', source: 'PT2_F', target: 'PT2_G', properties: {} }); + + const getNodeSpy = vi.spyOn(storage, 'getNode'); + + const visited = traverse(storage, 'PT2_S', { nodeTypes: ['Person'] }); + + expect(visited).toContain('PT2_S'); + expect(visited).toContain('PT2_M1'); + expect(visited).toContain('PT2_M2'); + expect(visited).toContain('PT2_G'); + expect(visited).not.toContain('PT2_F'); + + const filteredNodeLookups = getNodeSpy.mock.calls.filter(([id]) => id === 'PT2_F').length; + expect(filteredNodeLookups).toBe(1); + }); + it('should return only start node for isolated traversal', () => { storage.addNode({ id: 'Z', type: 'Isolated', properties: {} }); const visited = traverse(storage, 'Z');