diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b764338..150e910f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **5 new graph algorithms in `GraphTraversal`** — `levels()` (longest-path level assignment for DAGs), `transitiveReduction()` (minimal edge set preserving reachability), `transitiveClosure()` (all implied reachability edges with `maxEdges` safety), `rootAncestors()` (find all in-degree-0 ancestors via backward BFS). All methods respect `NeighborProviderPort` abstraction, support `AbortSignal` cancellation, and produce deterministic output. Corresponding `LogicalTraversal` facade methods added. New error code: `E_MAX_EDGES_EXCEEDED`. +- **4 new test fixtures** — `F15_WIDE_DAG_FOR_LEVELS`, `F16_TRANSITIVE_REDUCTION`, `F17_MULTI_ROOT_DAG`, `F18_TRANSITIVE_CLOSURE_CHAIN` in the canonical fixture DSL. +- **BFS reverse reachability verification tests** — confirms `bfs(node, { direction: 'in' })` correctly discovers all backward-reachable ancestors. +- **`roaring-wasm` WASM fallback for Bun/Deno bitmap indexes** — `initRoaring()` now has a three-tier fallback chain: (1) ESM `import('roaring')`, (2) CJS `createRequire('roaring')`, (3) `import('roaring-wasm')` with WASM initialization. The WASM tier activates automatically when native V8 bindings are unavailable (Bun's JSC, Deno). Bitmap index tests (`materializedView`, `materialize.checkpointIndex.notStale`) are no longer excluded from the Bun test suite. Serialization formats are wire-compatible — portable bitmaps produced by native and WASM are byte-identical. + +### Fixed + +- **Roaring native module loading under Bun** — `initRoaring()` now catches dynamic `import('roaring')` failures and falls back to `createRequire()` for direct `.node` binary loading. +- **Stale `nativeAvailability` cache on `initRoaring()` reinit** — `getNativeRoaringAvailable()` now returns the correct value after swapping roaring implementations via `initRoaring(mod)`. Previously, the cached availability from the old module was returned. +- **Lost root causes on roaring load failure** — when all three tiers (native ESM, CJS require, WASM) fail, `initRoaring()` now throws `AggregateError` with per-tier errors instead of a plain `Error`, preserving diagnostic detail. + +### Changed + +- **ROADMAP priority triage** — 45 standalone items sorted into 6 priority tiers (P0–P6) with wave-based execution order and dependency chain mapping. Replaced flat Near-Term table with priority-grouped sub-tables. All milestones (M10–M14) marked complete. Inventory corrected to 133 total tracked items. +- **Vitest 2.1.9 → 4.0.18** — major test framework upgrade. Migrated deprecated `test(name, fn, { timeout })` signatures to `test(name, { timeout }, fn)` across 7 test files (40 call sites). Fixed `vi.fn().mockImplementation()` constructor mocks to use `function` expressions per Vitest 4 requirements. Resolves 5 remaining moderate-severity npm audit advisories (`esbuild` [GHSA-67mh-4wv8-2f99](https://github.com/advisories/GHSA-67mh-4wv8-2f99), `vite`, `@vitest/mocker`, `vite-node`, `vitest`). **`npm audit` now reports 0 vulnerabilities.** + ## [13.0.1] — 2026-03-03 ### Fixed diff --git a/README.md b/README.md index 54747593..90330d45 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ git-warp CLI demo

-## What's New in v13.0.1 +## What's New (Unreleased) +- **5 new graph algorithms** — `levels()`, `transitiveReduction()`, `transitiveClosure()`, `rootAncestors()` in `GraphTraversal`, plus BFS reverse reachability verification. All use `NeighborProviderPort` and support cancellation. +- **`roaring-wasm` WASM fallback for Bun/Deno bitmap indexes** — bitmap indexes now work on Bun (JSC) and Deno via a three-tier fallback: native V8 bindings → CJS require → WASM. Wire-compatible, byte-identical serialization. - **Dev dependency security updates** — resolved 4 high-severity advisories (`tar`, `rollup`, `minimatch`, `@isaacs/brace-expansion`). No runtime dependencies affected. See the [full changelog](CHANGELOG.md) for details. @@ -642,6 +644,7 @@ The codebase follows hexagonal architecture with ports and adapters: | `@git-stunts/trailer-codec` | Git trailer encoding | | `cbor-x` | CBOR binary serialization | | `roaring` | Roaring bitmap indexes (native C++ bindings) | +| `roaring-wasm` | Roaring bitmap WASM fallback (Bun/Deno) | | `zod` | Schema validation | ## Testing diff --git a/ROADMAP.md b/ROADMAP.md index b8872024..cfa2816b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # ROADMAP — @git-stunts/git-warp > **Current version:** v13.0.0 -> **Last reconciled:** 2026-03-03 (v13.0.0 release: M11 COMPASS II complete, B100/B140 breaking, B44/B124/B125/B146 done) +> **Last reconciled:** 2026-03-04 (priority triage: 45 standalone items sorted into P0–P6 tiers, wave-based execution order, dependency chains mapped) > **Completed milestones:** [docs/ROADMAP/COMPLETED.md](docs/ROADMAP/COMPLETED.md) --- @@ -173,89 +173,106 @@ Archived to [COMPLETED.md](docs/ROADMAP/COMPLETED.md#milestone-11--compass-ii). ## Standalone Lane (Ongoing) -Items picked up opportunistically without blocking milestones. No milestone assignment. +45 active items sorted into priority tiers. Guiding principles: (1) harden first — correctness, memory safety, test infra, CI gates before features; (2) large-graph support is forward-looking — medium priority; (3) CI & Tooling items batch into one PR. > Completed standalone items archived in [COMPLETED.md](docs/ROADMAP/COMPLETED.md#standalone-lane--completed-items). -### Near-Term - -| ID | Item | -|----|------| -| ~~B124~~ | ✅ ~~**TRUST PAYLOAD PARITY TESTS**~~ — 22 tests verifying CLI vs service shape parity. Done in v13.0.0. | -| ~~B125~~ | ✅ ~~**`CachedValue` NULL-PAYLOAD SEMANTIC TESTS**~~ — 3 tests documenting null = "no value" sentinel. Done in v13.0.0. | -| B127 | **DENO SMOKE TEST** — `npm run test:deno:smoke` for fast local pre-push confidence without full Docker matrix. From BACKLOG 2026-02-25. | -| ~~B44~~ | ✅ ~~**SUBSCRIBER UNSUBSCRIBE-DURING-CALLBACK E2E**~~ — 3 edge-case tests (cross-unsubscribe, subscribe-during-callback, unsubscribe-in-onError). Done in v13.0.0. | -| B34 | **DOCS: SECURITY_SYNC.md** — extract threat model from JSDoc into operator doc | -| B35 | **DOCS: README INSTALL SECTION** — Quick Install with Docker + native paths | -| B36 | **FLUENT STATE BUILDER FOR TESTS** — `StateBuilder` helper replacing manual `WarpStateV5` literals | -| B37 | **SHARED MOCK PERSISTENCE FIXTURE** — dedup `createMockPersistence()` across trust test files | -| B43 | **VITEST EXPLICIT RUNTIME EXCLUDES** — prevent accidental local runs of Docker-only suites | -| B12 | **DOCS-VERSION-SYNC PRE-COMMIT CHECK** — grep version literals in .md files against `package.json` | -| B48 | **ESLINT BAN `= {}` CONSTRUCTOR DEFAULTS WITH REQUIRED PARAMS** — catches the pattern where `= {}` silently makes required options optional at the type level (found in CommitDagTraversalService, DagTraversal, DagPathFinding, DagTopology, BitmapIndexReader) | -| B49 | **TIGHTEN `checkDeclarations` INLINE COMMENT STRIPPING** — strip trailing `//` and `/* */` comments before checking for `any` in `ts-policy-check.js`; low priority but closes theoretical false-positive gap | -| B53 | **FIX JSR PUBLISH DRY-RUN DENO PANIC** — Deno 2.6.7 `deno_ast` panics on overlapping text changes from duplicate `roaring` import rewrites; either pin Deno version, vendor the import, or file upstream issue and add workaround | -| B54 | **`typedCustom()` ZOD HELPER** — `z.custom()` without a generic yields `unknown` in JS; a JSDoc-friendly wrapper (or `@typedef`-based pattern) would eliminate verbose `/** @type {z.ZodType} */ (z.custom(...))` casts across HttpSyncServer and future Zod schemas | -| B57 | **CI: AUTO-VALIDATE `type-surface.m8.json` AGAINST `index.d.ts`** — add a CI gate or pre-push check that parses the manifest and confirms every declared method/property/return type matches the corresponding signature in `index.d.ts`; prevents drift like the missing `setSeekCache` and `syncWith.state` return found in review | -| B28 | **PURE TYPESCRIPT EXAMPLE APP** — CI compile-only stub (`tsc --noEmit` on minimal TS consumer). | -| B76 | **WARPGRAPH INVISIBLE API SURFACE DOCS** — add `// API Surface` block listing all 40+ dynamically wired methods with source module. Consider generating as build step. From B-AUDIT-4 (STANK). **File:** `src/domain/WarpGraph.js:451-478` | -| B79 | **WARPGRAPH CONSTRUCTOR LIFECYCLE DOCS** — document cache invalidation strategy for 25 instance variables: which operations dirty which caches, which flush them. From B-AUDIT-16 (TSK TSK). **File:** `src/domain/WarpGraph.js:69-198` | -| B80 | **CHECKPOINTSERVICE CONTENT BLOB UNBOUNDED MEMORY** — iterates all properties into single `Set` before tree serialization. Stream content OIDs in batches. From B-AUDIT-10 (JANK). **File:** `src/domain/services/CheckpointService.js:224-226` | -| B81 | **`attachContent` ORPHAN BLOB GUARD** — `attachContent()` unconditionally writes blob before `setProperty()`. Validate before push to prevent orphan blobs. From B-CODE-2. **File:** `src/domain/services/PatchBuilderV2.js` | -| ~~B146~~ | ✅ ~~**UNIFY `CorePersistence` / `FullPersistence` TYPEDEFS**~~ — replaced `FullPersistence` with imported `CorePersistence`. Done in v13.0.0. | -| B147 | **RFC FIELD COUNT DRIFT DETECTOR** — script that counts WarpGraph instance fields (grep `this._` in constructor) and warns if design RFC field counts diverge. Prevents stale numbers in `warpgraph-decomposition.md`. From B145 PR review. | - -### CI & Tooling Pack - -| ID | Item | -|----|------| -| B83 | **DEDUP CI `type-firewall` AND `lint` JOBS** — merge into one job (add `npm audit` to `type-firewall`, drop `lint`) or chain with `needs:`. From B-CI-1. **File:** GitHub workflow file `.github/workflows/ci.yml` | -| B85 | **TYPE-ONLY EXPORT MANIFEST SECTION** — `typeExports` section in `type-surface.m8.json` to catch accidental type removal from `index.d.ts`. From B-CI-3. **Files:** `contracts/type-surface.m8.json`, `scripts/check-dts-surface.js` | -| B86 | **MARKDOWNLINT CI GATE** — catch MD040 (missing code fence language) etc. From B-DOC-1. **File:** GitHub workflow file `.github/workflows/ci.yml` | -| B87 | **CODE SAMPLE LINTER** — syntax-check JS/TS code blocks in markdown files via `eslint-plugin-markdown` or custom extractor. From B-DOC-2. **Files:** new script, `docs/**/*.md` | -| B88 | **MERMAID RENDERING SMOKE TEST** — parse all ` ```mermaid ` blocks with `@mermaid-js/mermaid-cli` in CI. From B-DIAG-2. **File:** GitHub workflow file `.github/workflows/ci.yml` or `scripts/` | -| B119 | **`scripts/pr-ready` MERGE-READINESS CLI** — single tool aggregating unresolved review threads, pending/failed checks, CodeRabbit status/cooldown, and human-review count into one deterministic verdict. Dedupes ~20 BACKLOG items from 6 PR feedback sessions. From BACKLOG 2026-02-27/28. | -| B123 | **BENCHMARK BUDGETS + CI REGRESSION GATE** — define perf thresholds for eager post-commit and materialize hash cost; fail CI on agreed regression. From BACKLOG 2026-02-27. | -| B128 | **DOCS CONSISTENCY PREFLIGHT** — automated pass in `release:preflight` verifying changelog/readme/guide updates for behavior changes in hot paths (materialize, checkpoint, sync). From BACKLOG 2026-02-28. | - -### Surface Validator Pack - -All items target `scripts/check-dts-surface.js`: - -| ID | Item | -|----|------| -| B95 | **NAMESPACE EXPORT SUPPORT** — handle `export declare namespace Foo`. From B-SURF-5. | - -### Type Surface Pack - -| ID | Item | -|----|------| -| B96 | **CONSUMER TEST TYPE-ONLY IMPORT COVERAGE** — exercise all exported types beyond just declaring variables. Types like `OpOutcome`, `TraversalDirection`, `LogLevelValue` aren't tested at all. From B-TYPE-1. **File:** `test/type-check/consumer.ts` | -| B97 | **AUDIT MANIFEST vs `index.js` DRIFT** — manifest has 70 entries, `index.js` has 66 exports. 4 stale or type-only entries need reconciliation. From B-TYPE-2. **Files:** `contracts/type-surface.m8.json`, `index.js` | -| B98 | **TEST-FILE WILDCARD RATCHET** — `ts-policy-check.js` excludes test files entirely. Add separate ratchet with higher threshold or document exclusion as intentional. From B-TYPE-3. **File:** `scripts/ts-policy-check.js` | - -### Content Attachment - -| ID | Item | -|----|------| -| B99 | **DETERMINISM FUZZER FOR TREE CONSTRUCTION** — property-based test randomizing content blob insertion order in `PatchBuilderV2` and content OID iteration order in `CheckpointService.createV5()`, verifying identical tree OID. From B-FEAT-2. **File:** new test in `test/unit/domain/services/` | - -### Conformance Property Pack (B19 + B22) - -Single lightweight property suite — not a milestone anchor: - -- **B19** (CANONICAL SERIALIZATION PROPERTY TESTS) — fuzz `canonicalStringify`; verify idempotency, determinism, round-trip stability. -- **B22** (CANONICAL PARSE DETERMINISM TEST) — verify `canonicalStringify(TrustRecordSchema.parse(record))` produces identical output across repeated calls. - -**Rationale:** Golden fixtures test known paths; property tests test unknown edge combinations. For a deterministic engine, this is not optional forever. Trimmed to a single file covering canonical serialize idempotence + order-invariance. - -### Process (no code) - -| ID | Item | -|----|------| -| B102 | **API EXAMPLES REVIEW CHECKLIST** — add to `CONTRIBUTING.md`: each `createPatch()`/`commit()` uses own builder, async methods `await`ed, examples copy-pasteable. From B-DOC-3. | -| B103 | **BATCH REVIEW FIX COMMITS** — batch all review fixes into one commit before re-requesting CodeRabbit. Reduces duplicate findings across incremental pushes. From B-DX-2. | -| B104 | **MERMAID DIAGRAM CONTENT CHECKLIST** — for diagram migrations: count annotations in source/target, verify edge labels survive, check complexity annotations preserved. From B-DIAG-1. | -| B129 | **CONTRIBUTOR REVIEW-LOOP HYGIENE GUIDE** — add section to `CONTRIBUTING.md` covering commit sizing, CodeRabbit cooldown strategy, and when to request bot review. From BACKLOG 2026-02-27. | +### P0 — Quick Wins (unblock other work, trivial effort) + +No dependencies. Do these first. + +| ID | Item | Effort | +|----|------|--------| +| B154 | **`transitiveReduction` REDUNDANT ADJLIST COPY** — after receiving `_neighborEdgeMap` from topo sort, builds a second `adjList: Map` by extracting neighborIds. Two representations of the same edge set in memory simultaneously. Should use `_neighborEdgeMap` directly, accessing `.neighborId` inline during BFS. **File:** `src/domain/services/GraphTraversal.js`. **Unblocks:** B150 (P4) | XS | +| B97 | **AUDIT MANIFEST vs `index.js` DRIFT** — manifest has 70 entries, `index.js` has 66 exports. 4 stale or type-only entries need reconciliation. From B-TYPE-2. **Files:** `contracts/type-surface.m8.json`, `index.js`. **Unblocks:** B85 → B57 (P2 chain) | S | +| B81 | **`attachContent` ORPHAN BLOB GUARD** — `attachContent()` unconditionally writes blob before `setProperty()`. Validate before push to prevent orphan blobs. From B-CODE-2. **File:** `src/domain/services/PatchBuilderV2.js` | S | + +### P1 — Correctness & Test Infrastructure + +B36 and B37 improve velocity for all future test work — do them early. B19 + B22 batch as one PR (Conformance Property Pack). + +| ID | Item | Effort | +|----|------|--------| +| B36 | **FLUENT STATE BUILDER FOR TESTS** — `StateBuilder` helper replacing manual `WarpStateV5` literals | M | +| B37 | **SHARED MOCK PERSISTENCE FIXTURE** — dedup `createMockPersistence()` across trust test files | S | +| B48 | **ESLINT BAN `= {}` CONSTRUCTOR DEFAULTS WITH REQUIRED PARAMS** — catches the pattern where `= {}` silently makes required options optional at the type level (found in CommitDagTraversalService, DagTraversal, DagPathFinding, DagTopology, BitmapIndexReader) | S | +| B80 | **CHECKPOINTSERVICE CONTENT BLOB UNBOUNDED MEMORY** — iterates all properties into single `Set` before tree serialization. Stream content OIDs in batches. From B-AUDIT-10 (JANK). **File:** `src/domain/services/CheckpointService.js:224-226` | M | +| B99 | **DETERMINISM FUZZER FOR TREE CONSTRUCTION** — property-based test randomizing content blob insertion order in `PatchBuilderV2` and content OID iteration order in `CheckpointService.createV5()`, verifying identical tree OID. From B-FEAT-2. **File:** new test in `test/unit/domain/services/` | M | +| B19 | **CANONICAL SERIALIZATION PROPERTY TESTS** — fuzz `canonicalStringify`; verify idempotency, determinism, round-trip stability. Golden fixtures test known paths; property tests test unknown edge combinations. | S | +| B22 | **CANONICAL PARSE DETERMINISM TEST** — verify `canonicalStringify(TrustRecordSchema.parse(record))` produces identical output across repeated calls. Batch with B19 as one PR. | S | + +### P2 — CI & Tooling (one batch PR) + +**Internal chain:** B97 (P0) → B85 → B57 — must complete P0 first. B123 is the largest item — may need to split out if the PR gets too big. + +| ID | Item | Depends on | Effort | +|----|------|------------|--------| +| B83 | **DEDUP CI `type-firewall` AND `lint` JOBS** — merge into one job (add `npm audit` to `type-firewall`, drop `lint`) or chain with `needs:`. From B-CI-1. **File:** `.github/workflows/ci.yml` | — | S | +| B85 | **TYPE-ONLY EXPORT MANIFEST SECTION** — `typeExports` section in `type-surface.m8.json` to catch accidental type removal from `index.d.ts`. From B-CI-3. **Files:** `contracts/type-surface.m8.json`, `scripts/check-dts-surface.js` | B97 (P0) | S | +| B57 | **CI: AUTO-VALIDATE `type-surface.m8.json` AGAINST `index.d.ts`** — add a CI gate or pre-push check that parses the manifest and confirms every declared method/property/return type matches the corresponding signature in `index.d.ts`; prevents drift like the missing `setSeekCache` and `syncWith.state` return found in review | B97, B85 | M | +| B86 | **MARKDOWNLINT CI GATE** — catch MD040 (missing code fence language) etc. From B-DOC-1. **File:** `.github/workflows/ci.yml` | — | S | +| B87 | **CODE SAMPLE LINTER** — syntax-check JS/TS code blocks in markdown files via `eslint-plugin-markdown` or custom extractor. From B-DOC-2. **Files:** new script, `docs/**/*.md` | — | M | +| B88 | **MERMAID RENDERING SMOKE TEST** — parse all ` ```mermaid ` blocks with `@mermaid-js/mermaid-cli` in CI. From B-DIAG-2. **File:** `.github/workflows/ci.yml` or `scripts/` | — | S | +| B119 | **`scripts/pr-ready` MERGE-READINESS CLI** — single tool aggregating unresolved review threads, pending/failed checks, CodeRabbit status/cooldown, and human-review count into one deterministic verdict. From BACKLOG 2026-02-27/28. | — | M | +| B123 | **BENCHMARK BUDGETS + CI REGRESSION GATE** — define perf thresholds for eager post-commit and materialize hash cost; fail CI on agreed regression. From BACKLOG 2026-02-27. | — | L | +| B128 | **DOCS CONSISTENCY PREFLIGHT** — automated pass in `release:preflight` verifying changelog/readme/guide updates for behavior changes in hot paths (materialize, checkpoint, sync). From BACKLOG 2026-02-28. | — | S | +| B12 | **DOCS-VERSION-SYNC PRE-COMMIT CHECK** — grep version literals in .md files against `package.json` | — | S | +| B43 | **VITEST EXPLICIT RUNTIME EXCLUDES** — prevent accidental local runs of Docker-only suites | — | S | + +### P3 — Type Safety & Surface + +No hard dependencies. Pick up opportunistically after P2. + +| ID | Item | Effort | +|----|------|--------| +| B95 | **NAMESPACE EXPORT SUPPORT** — handle `export declare namespace Foo` in surface validator. From B-SURF-5. **File:** `scripts/check-dts-surface.js` | S | +| B96 | **CONSUMER TEST TYPE-ONLY IMPORT COVERAGE** — exercise all exported types beyond just declaring variables. Types like `OpOutcome`, `TraversalDirection`, `LogLevelValue` aren't tested at all. From B-TYPE-1. **File:** `test/type-check/consumer.ts` | M | +| B98 | **TEST-FILE WILDCARD RATCHET** — `ts-policy-check.js` excludes test files entirely. Add separate ratchet with higher threshold or document exclusion as intentional. From B-TYPE-3. **File:** `scripts/ts-policy-check.js` | S | +| B54 | **`typedCustom()` ZOD HELPER** — `z.custom()` without a generic yields `unknown` in JS; a JSDoc-friendly wrapper (or `@typedef`-based pattern) would eliminate verbose `/** @type {z.ZodType} */ (z.custom(...))` casts across HttpSyncServer and future Zod schemas | S | +| B49 | **TIGHTEN `checkDeclarations` INLINE COMMENT STRIPPING** — strip trailing `//` and `/* */` comments before checking for `any` in `ts-policy-check.js`; low priority but closes theoretical false-positive gap | XS | +| B28 | **PURE TYPESCRIPT EXAMPLE APP** — CI compile-only stub (`tsc --noEmit` on minimal TS consumer). | M | + +### P4 — Large-Graph Performance (forward-looking) + +**Execution order:** B154 (P0) → B153 → B149 + B150 (parallel) → B151 → B152. B153 is the keystone — fixes topo sort memory, which cascades to B149 and B150. + +| ID | Item | Depends on | Effort | +|----|------|------------|--------| +| B153 | **`topologicalSort` LIGHTWEIGHT MODE** — discovery phase unconditionally builds `adjList` + `neighborEdgeMap` (O(V+E)) even when `_returnAdjList` is false. Add a `_lightweight` mode that only tracks in-degree counts during discovery and re-fetches neighbors from provider during Kahn processing. Reduces topo sort memory from O(V+E) to O(V). Root cause behind B149/B150 — both inherit full-graph materialization from their topo sort call. **File:** `src/domain/services/GraphTraversal.js` | — | M | +| B149 | **LARGE-GRAPH `levels()` — TWO-PASS STREAMING** — `levels()` currently holds O(V+E) via `topologicalSort({ _returnAdjList: true })`. Refactor to two-pass: (1) topo sort discards edge cache, (2) DP pass re-fetches neighbors from provider. Reduces steady-state memory from O(V+E) to O(V). Trade-off: one extra I/O pass over edges. **File:** `src/domain/services/GraphTraversal.js` | B153 | M | +| B150 | **LARGE-GRAPH `transitiveReduction()` — ON-DEMAND NEIGHBOR FETCH** — `transitiveReduction()` holds full adjacency list from topo sort AND builds a second `Map` for per-node BFS. Refactor BFS phase to call `getNeighbors()` on demand instead of caching. Reduces memory from O(V+E) to O(V) working set per BFS sweep. Trade-off: redundant provider calls. Consider provider-level LRU to amortize. **File:** `src/domain/services/GraphTraversal.js` | B153, B154 (P0) | M | +| B151 | **LARGE-GRAPH `transitiveClosure()` — STREAMING OUTPUT** — `transitiveClosure()` collects all O(V²) reachability edges in an array before returning. For large graphs this can exhaust memory even with `maxEdges`. Refactor to async iterator/generator that yields `{from, to}` pairs as they're discovered. Per-node BFS working memory is already O(V); the bottleneck is the output array. **File:** `src/domain/services/GraphTraversal.js` | — | M | +| B152 | **ASYNC GENERATOR TRAVERSAL API** — streaming variants of all GraphTraversal algorithms (`bfsStream()`, `dfsStream()`, etc.) returning `AsyncGenerator` instead of collected arrays. Enables early break, backpressure, and pipeline composition. Array-returning methods become sugar over `collect()`. Generalizes B151 to the full traversal surface. **File:** `src/domain/services/GraphTraversal.js` | B151 | L | + +### P5 — Features & Visualization + +| ID | Item | Effort | +|----|------|--------| +| B155 | **`levels()` AS LIGHTWEIGHT `--view` LAYOUT** — `levels()` is exactly the Y-axis assignment a layered DAG layout needs. For simple DAGs, `levels()` + left-to-right X sweep could produce clean layouts without the 2.5MB ELK import. Offer `--view --layout=levels` as an instant rendering mode, reserving ELK for complex graphs. **Files:** `src/visualization/layouts/`, `bin/cli/commands/view.js` | M | +| B156 | **STRUCTURAL DIFF VIA TRANSITIVE REDUCTION** — compute `transitiveReduction(stateA)` vs `transitiveReduction(stateB)` to produce a compact structural diff that strips implied edges and shows only "load-bearing" changes. Natural fit for H1 (Time-Travel Delta Engine) as `warp diff --mode=structural`. | L | + +### P6 — Documentation & Process + +Low urgency. Fold into PRs that already touch related files. + +| ID | Item | Effort | +|----|------|--------| +| B34 | **DOCS: SECURITY_SYNC.md** — extract threat model from JSDoc into operator doc | M | +| B35 | **DOCS: README INSTALL SECTION** — Quick Install with Docker + native paths | S | +| B76 | **WARPGRAPH INVISIBLE API SURFACE DOCS** — add `// API Surface` block listing all 40+ dynamically wired methods with source module. Consider generating as build step. From B-AUDIT-4 (STANK). **File:** `src/domain/WarpGraph.js:451-478` | M | +| B79 | **WARPGRAPH CONSTRUCTOR LIFECYCLE DOCS** — document cache invalidation strategy for 25 instance variables: which operations dirty which caches, which flush them. From B-AUDIT-16 (TSK TSK). **File:** `src/domain/WarpGraph.js:69-198`. **Depends on:** B143 RFC (exists) | M | +| B102 | **API EXAMPLES REVIEW CHECKLIST** — add to `CONTRIBUTING.md`: each `createPatch()`/`commit()` uses own builder, async methods `await`ed, examples copy-pasteable. From B-DOC-3. | S | +| B103 | **BATCH REVIEW FIX COMMITS** — batch all review fixes into one commit before re-requesting CodeRabbit. Reduces duplicate findings across incremental pushes. From B-DX-2. | XS | +| B104 | **MERMAID DIAGRAM CONTENT CHECKLIST** — for diagram migrations: count annotations in source/target, verify edge labels survive, check complexity annotations preserved. From B-DIAG-1. | XS | +| B129 | **CONTRIBUTOR REVIEW-LOOP HYGIENE GUIDE** — add section to `CONTRIBUTING.md` covering commit sizing, CodeRabbit cooldown strategy, and when to request bot review. From BACKLOG 2026-02-27. | S | +| B147 | **RFC FIELD COUNT DRIFT DETECTOR** — script that counts WarpGraph instance fields (grep `this._` in constructor) and warns if design RFC field counts diverge. Prevents stale numbers in `warpgraph-decomposition.md`. From B145 PR review. **Depends on:** B143 RFC (exists) | S | + +### Uncategorized / Platform + +| ID | Item | Effort | +|----|------|--------| +| B53 | **FIX JSR PUBLISH DRY-RUN DENO PANIC** — Deno 2.6.7 `deno_ast` panics on overlapping text changes from duplicate `roaring` import rewrites; either pin Deno version, vendor the import, or file upstream issue and add workaround. Promote if JSR publish becomes imminent. | M | +| B127 | **DENO SMOKE TEST** — `npm run test:deno:smoke` for fast local pre-push confidence without full Docker matrix. From BACKLOG 2026-02-25. | S | --- @@ -284,29 +301,72 @@ B5, B6, B13, B17, B18, B25, B45 — rejected 2026-02-17 with cause recorded in ` ## Execution Order -### Milestones: M10 → M12 → M13 → M11 → M14 +### Milestones (all complete) 1. **M10 SENTINEL** — Trust + sync safety + correctness — **DONE** -2. **M12 SCALPEL** — STANK audit cleanup (minus edge prop encoding) — **DONE** (all tasks complete, gate verified) -3. **M13 SCALPEL II** — Edge property canonicalization — **DONE** (internal model complete; wire-format cutover deferred by ADR 3) -4. **M11 COMPASS II** — Developer experience (B2 impl, B3, B11) — ✅ **DONE** (v13.0.0), archived -5. **M14 HYGIENE** — Test quality, DRY extraction, SOLID quick-wins — **NEXT** (from HEX_AUDIT) - -### Standalone Priority Sequence - -Pick opportunistically between milestones. Recommended order within tiers: - -1. ~~**Immediate** (B46, B47, B26, B71, B126)~~ — **ALL DONE.** -2. **Near-term correctness** (B76, B80, B81) — prioritize items touching core services -3. **Near-term DX** (B36, B37, B43, B127) — test ergonomics and developer velocity -4. **Near-term docs/types** (B34, B35) — alignment and documentation -5. **Near-term tooling** (B12, B48, B49, B53, B54, B57, B28) — remaining type safety items -6. **CI & Tooling Pack** (B83, B85–B88, B119, B123, B128) — batch as one PR -7. **Surface Validator Pack** (B95) — only namespace export support remains -8. **Type Surface Pack** (B96–B98) — batch as one PR -9. **Content Attachment** (B99) — standalone property test -10. **Conformance Property Pack** (B19, B22) — standalone property suite -11. **Process** (B102–B104, B129) — fold into CONTRIBUTING.md when touching that file +2. **M12 SCALPEL** — STANK audit cleanup (minus edge prop encoding) — **DONE** +3. **M13 SCALPEL II** — Edge property canonicalization — **DONE** (internal; wire-format deferred by ADR 3) +4. **M11 COMPASS II** — Developer experience — ✅ **DONE** (v13.0.0), archived +5. **M14 HYGIENE** — Test quality, DRY extraction, SOLID quick-wins — **DONE** + +### Standalone Execution Waves + +Guiding principles: (1) harden first — correctness, memory safety, test infra, CI gates before features; (2) large-graph support is forward-looking — medium priority; (3) CI & Tooling items batch into one PR. + +#### Wave 1: Foundation (P0 + P1 start) + +1. **B154** — transitiveReduction adjList dedup (XS) +2. **B97** — Manifest audit (S, unblocks P2 chain) +3. **B81** — Orphan blob guard (S, correctness) +4. **B36** — Fluent StateBuilder (M, test DX) +5. **B37** — Shared mock persistence (S, test DRY) + +#### Wave 2: Correctness (P1 finish) + +6. **B48** — ESLint `= {}` defaults rule (S) +7. **B80** — CheckpointService memory streaming (M) +8. **B19 + B22** — Conformance property pack (S, one PR) +9. **B99** — Determinism fuzzer (M) + +#### Wave 3: CI & Tooling (P2, one batch PR) + +10. **B83, B85, B57, B86, B87, B88, B119, B123, B128, B12, B43** + +Internal chain: B97 (P0, Wave 1) → B85 → B57. B123 is the largest — may split out. + +#### Wave 4: Type Surface (P3) + +11. **B95, B96, B98, B54, B49** — batch or cherry-pick +12. **B28** — TypeScript example app + +#### Wave 5: Large-Graph (P4) + +13. **B153** — topologicalSort lightweight mode (keystone) +14. **B149 + B150** — levels + transitiveReduction streaming (parallel after B153) +15. **B151** — transitiveClosure streaming output +16. **B152** — full async generator API + +#### Wave 6: Features + Docs (P5 + P6) + +17. **B155** — levels() as --view layout +18. **B156** — structural diff (if H1 is in play) +19. Docs/process items (B34, B35, B76, B79, B102–B104, B129, B147) folded into related PRs + +### Dependency Chains + +```text +B97 (P0) ──→ B85 (P2) ──→ B57 (P2) + manifest auto-validate + +B153 (P4) ──→ B149 (P4) levels() streaming + └──→ B150 (P4) transitiveReduction() streaming + ↑ +B154 (P0) ─────┘ adjList dedup (quick fix) + +B151 (P4) ──→ B152 (P4) closure streaming → full async generator API + +B36 (P1) ──→ (improves velocity for B99, B19, B22, future tests) +``` --- @@ -321,11 +381,11 @@ Pick opportunistically between milestones. Recommended order within tiers: | **Milestone (M12)** | 18 | B66, B67, B70, B73, B75, B105–B115, B117, B118 | | **Milestone (M13)** | 1 | B116 (internal: DONE; wire-format: DEFERRED) | | **Milestone (M14)** | 16 | B130–B145 | -| **Standalone** | 35 | B12, B19, B22, B28, B34–B37, B43, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123, B127–B129, B147 | +| **Standalone** | 45 | B12, B19, B22, B28, B34–B37, B43, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123, B127–B129, B147, B149–B156 | | **Standalone (done)** | 29 | B26, B44, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B100, B120–B122, B124, B125, B126, B146, B148 | | **Deferred** | 7 | B4, B7, B16, B20, B21, B27, B101 | | **Rejected** | 7 | B5, B6, B13, B17, B18, B25, B45 | -| **Total tracked** | **123** total; 29 standalone done | | +| **Total tracked** | **133** total; 29 standalone done | | ### STANK.md Cross-Reference @@ -427,11 +487,9 @@ Pick opportunistically between milestones. Recommended order within tiers: ## Final Command Every milestone has a hard gate. No milestone blurs into the next. -Execution: M10 SENTINEL → **M12 SCALPEL** → **M13 SCALPEL II** → **M11 COMPASS II** → **M14 HYGIENE**. M11 is complete and archived. Standalone items fill the gaps. - -M12 is complete (including T8/T9). M13 internal canonicalization (ADR 1) is complete — canonical `NodePropSet`/`EdgePropSet` semantics, wire gate split, reserved-byte validation, version namespace separation. The persisted wire-format half of B116 is deferred by ADR 2 and governed by ADR 3 readiness gates. +All milestones are complete: M10 → M12 → M13 (internal) → M11 → M14. M13 wire-format cutover remains deferred by ADR 3 readiness gates. -M14 HYGIENE is the current priority — test hardening, DRY extraction, and SOLID quick-wins from the HEX_AUDIT. M11 is complete and archived in COMPLETED.md. +The active backlog is **45 standalone items** sorted into **6 priority tiers** (P0–P6) with **6 execution waves**. Wave 1 (foundation) targets quick wins and test infrastructure. See [Execution Order](#execution-order) for the full sequence. Rejected items live in `GRAVEYARD.md`. Resurrections require an RFC. `BACKLOG.md` retired — all intake goes directly into this file (policy in `CLAUDE.md`). diff --git a/docker-compose.test.yml b/docker-compose.test.yml index da25a12d..6ec20fb8 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -32,14 +32,16 @@ services: command: ["sh", "-c", "npx vitest run test/unit test/integration && bats test/bats/"] profiles: [node22, full] - # Bun: API integration tests only (CLI is Node-only) + # Bun: API integration tests only (CLI is Node-only). + # Bitmap index tests pass via roaring-wasm WASM fallback (native roaring uses + # V8 C++ API, incompatible with Bun's JSC runtime). test-bun: build: context: .. dockerfile: git-warp/docker/Dockerfile.bun environment: - GIT_STUNTS_DOCKER=1 - command: ["bunx", "vitest", "run", "test/integration/api/"] + command: bunx vitest run test/integration/api/ profiles: [bun, full] # Deno: API integration tests via Deno.test() wrappers diff --git a/docker/Dockerfile.bun b/docker/Dockerfile.bun index bc0d4f90..ae43a541 100644 --- a/docker/Dockerfile.bun +++ b/docker/Dockerfile.bun @@ -3,12 +3,8 @@ # CLI tests are excluded — the CLI uses node: built-ins. # Build context is the parent monorepo directory (context: ..). FROM oven/bun:1.2-slim -# make/g++: native module compilation (roaring bitmaps). -# No bats/python3 — BATS CLI tests are Node-only. RUN apt-get update && apt-get install -y --no-install-recommends \ git \ - make \ - g++ \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY git-warp/package*.json ./ @@ -26,4 +22,7 @@ RUN git init -q \ RUN useradd -m warp && chown -R warp:warp /app USER warp ENV GIT_STUNTS_DOCKER=1 +# Bitmap index tests now pass under Bun via the roaring-wasm WASM fallback. +# The native roaring package (V8 C++ API) is incompatible with Bun's JSC runtime, +# but initRoaring() falls through to roaring-wasm automatically. CMD ["bunx", "vitest", "run", "test/integration/api/"] diff --git a/docs/ROADMAP/COMPLETED.md b/docs/ROADMAP/COMPLETED.md index 6078ded2..eeb85509 100644 --- a/docs/ROADMAP/COMPLETED.md +++ b/docs/ROADMAP/COMPLETED.md @@ -368,3 +368,13 @@ Investigation revealed the correct approach is a two-phase split: | B92 | ~~**SURFACE VALIDATOR UNIT TESTS**~~ — **DONE.** 34 tests for `parseExportBlock`, `extractJsExports`, `extractDtsExports`. | | B93 | ~~**DEDUP EXPORT PARSING LOGIC**~~ — **DONE.** `parseExportBlock()` extracted as shared helper; `collectExportBlocks()` internal. | | B94 | ~~**STANDALONE EXPORT DECLARATIONS**~~ — **DONE.** `extractJsExports` now handles `export const/function/class`. | + +### v13.0.0 Standalone Items (completed) + +| ID | Item | +|----|------| +| B44 | ~~**SUBSCRIBER UNSUBSCRIBE-DURING-CALLBACK E2E**~~ — **DONE (v13.0.0).** 3 edge-case tests (cross-unsubscribe, subscribe-during-callback, unsubscribe-in-onError). | +| B124 | ~~**TRUST PAYLOAD PARITY TESTS**~~ — **DONE (v13.0.0).** 22 tests verifying CLI vs service shape parity. | +| B125 | ~~**`CachedValue` NULL-PAYLOAD SEMANTIC TESTS**~~ — **DONE (v13.0.0).** 3 tests documenting null = "no value" sentinel. | +| B146 | ~~**UNIFY `CorePersistence` / `FullPersistence` TYPEDEFS**~~ — **DONE (v13.0.0).** Replaced `FullPersistence` with imported `CorePersistence`. | +| B148 | ~~**REVIEW NITS + INVENTORY RECONCILIATION**~~ — **DONE (v13.0.0).** Fix inventory counts, COMPLETED.md ordering, stale Deno test name, BisectService invariant comment. | diff --git a/index.d.ts b/index.d.ts index b2f630cd..8ee27844 100644 --- a/index.d.ts +++ b/index.d.ts @@ -295,6 +295,27 @@ export interface LogicalTraversal { labelFilter?: string | string[]; signal?: AbortSignal; }): Promise<{ path: string[]; totalCost: number }>; + levels(start: string | string[], options?: { + dir?: 'out' | 'in' | 'both'; + labelFilter?: string | string[]; + signal?: AbortSignal; + }): Promise<{ levels: Map; maxLevel: number }>; + transitiveReduction(start: string | string[], options?: { + dir?: 'out' | 'in' | 'both'; + labelFilter?: string | string[]; + signal?: AbortSignal; + }): Promise<{ edges: Array<{ from: string; to: string; label: string }>; removed: number }>; + transitiveClosure(start: string | string[], options?: { + dir?: 'out' | 'in' | 'both'; + labelFilter?: string | string[]; + maxEdges?: number; + signal?: AbortSignal; + }): Promise<{ edges: Array<{ from: string; to: string }> }>; + rootAncestors(start: string, options?: { + labelFilter?: string | string[]; + maxDepth?: number; + signal?: AbortSignal; + }): Promise<{ roots: string[] }>; } /** diff --git a/package-lock.json b/package-lock.json index 1e0ec691..8d83a46e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@git-stunts/git-warp", - "version": "13.0.0", + "version": "13.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@git-stunts/git-warp", - "version": "13.0.0", + "version": "13.0.1", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", @@ -20,6 +20,7 @@ "elkjs": "^0.11.0", "figures": "^6.0.1", "roaring": "^2.7.0", + "roaring-wasm": "^1.1.0", "string-width": "^7.1.0", "wrap-ansi": "^9.0.0", "zod": "3.24.1" @@ -40,7 +41,7 @@ "prettier": "^3.4.2", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", - "vitest": "^2.1.8" + "vitest": "^4.0.18" }, "engines": { "node": ">=22.0.0" @@ -135,9 +136,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -148,13 +149,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -165,13 +166,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -182,13 +183,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -199,13 +200,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -216,13 +217,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -233,13 +234,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -250,13 +251,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -267,13 +268,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -284,13 +285,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -301,13 +302,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -318,13 +319,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -335,13 +336,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -352,13 +353,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -369,13 +370,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -386,13 +387,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -403,13 +404,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -420,13 +421,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -437,13 +455,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -454,13 +489,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -471,13 +523,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -488,13 +540,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -505,13 +557,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -522,7 +574,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1220,6 +1272,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1501,38 +1578,40 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", + "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0" + "vite": "^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -1544,70 +1623,66 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1862,16 +1937,6 @@ "node": ">=8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cacache": { "version": "20.0.3", "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", @@ -1999,18 +2064,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -2027,16 +2085,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -2207,16 +2255,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2352,9 +2390,9 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2362,32 +2400,35 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/escape-string-regexp": { @@ -3349,13 +3390,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "11.2.4", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", @@ -3789,6 +3823,17 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/open": { "version": "7.4.2", "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", @@ -3983,22 +4028,12 @@ } }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4020,9 +4055,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -4169,6 +4204,15 @@ } } }, + "node_modules/roaring-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/roaring-wasm/-/roaring-wasm-1.1.0.tgz", + "integrity": "sha512-mhNqA0BOqIW7k4ZYSYe3kCyvn5T3VWT+2661G7fZH0C6XcVkGoTDLAqne7b47xCNQE6LhuYviMKBnzbOiBXkdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -4451,11 +4495,14 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -4474,30 +4521,10 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", "dev": true, "license": "MIT", "engines": { @@ -4663,21 +4690,24 @@ } }, "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4686,19 +4716,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -4719,74 +4755,60 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, @@ -4794,10 +4816,19 @@ "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { "optional": true }, "@vitest/ui": { diff --git a/package.json b/package.json index ca6d8925..ae899dcb 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "elkjs": "^0.11.0", "figures": "^6.0.1", "roaring": "^2.7.0", + "roaring-wasm": "^1.1.0", "string-width": "^7.1.0", "wrap-ansi": "^9.0.0", "zod": "3.24.1" @@ -122,7 +123,7 @@ "prettier": "^3.4.2", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0", - "vitest": "^2.1.8" + "vitest": "^4.0.18" }, "keywords": [ "git", diff --git a/src/domain/services/GraphTraversal.js b/src/domain/services/GraphTraversal.js index 0763e3ff..6b7eb9cf 100644 --- a/src/domain/services/GraphTraversal.js +++ b/src/domain/services/GraphTraversal.js @@ -955,6 +955,347 @@ export default class GraphTraversal { return { path, totalCost: /** @type {number} */ (dist.get(goal)), stats: this._stats(sorted.length, rs) }; } + // ==== Section 5: Graph Analysis (levels, rootAncestors, transitiveReduction, transitiveClosure) ==== + + /** + * Longest-path level assignment (DAGs only). + * + * Each node's level is its longest-path distance from any root. + * Roots (in-degree 0 within the reachable subgraph) get level 0. + * + * @param {{ start: string | string[], direction?: Direction, options?: NeighborOptions, maxNodes?: number, signal?: AbortSignal }} params + * @returns {Promise<{levels: Map, maxLevel: number, stats: TraversalStats}>} + * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles + * @throws {TraversalError} code 'INVALID_START' if start node missing + */ + async levels({ + start, direction = 'out', options, + maxNodes = DEFAULT_MAX_NODES, + signal, + }) { + // Topo sort with cycle detection + neighbor edge map reuse + const { sorted, _neighborEdgeMap } = await this.topologicalSort({ + start, + direction, + options, + maxNodes, + throwOnCycle: true, + signal, + _returnAdjList: true, + }); + + const rs = this._newRunStats(); + + // DP forward: level[v] = max(level[v], level[u] + 1) + /** @type {Map} */ + const levelMap = new Map(); + for (const nodeId of sorted) { + if (!levelMap.has(nodeId)) { + levelMap.set(nodeId, 0); + } + } + + let maxLevel = 0; + for (const nodeId of sorted) { + checkAborted(signal, 'levels'); + const currentLevel = /** @type {number} */ (levelMap.get(nodeId)); + const neighbors = _neighborEdgeMap + ? (_neighborEdgeMap.get(nodeId) || []) + : await this._getNeighbors(nodeId, direction, rs, options); + rs.edgesTraversed += neighbors.length; + + for (const { neighborId } of neighbors) { + const neighborLevel = levelMap.get(neighborId) ?? 0; + const candidate = currentLevel + 1; + if (candidate > neighborLevel) { + levelMap.set(neighborId, candidate); + if (candidate > maxLevel) { + maxLevel = candidate; + } + } + } + } + + return { levels: levelMap, maxLevel, stats: this._stats(sorted.length, rs) }; + } + + /** + * Find all root ancestors (in-degree-0 nodes) reachable backward from start. + * + * Works on cyclic graphs — uses BFS reachability. + * + * @param {{ start: string, options?: NeighborOptions, maxNodes?: number, maxDepth?: number, signal?: AbortSignal }} params + * @returns {Promise<{roots: string[], stats: TraversalStats}>} + * @throws {TraversalError} code 'INVALID_START' if start node missing + */ + async rootAncestors({ + start, options, + maxNodes = DEFAULT_MAX_NODES, + maxDepth = DEFAULT_MAX_DEPTH, + signal, + }) { + // BFS backward from start + const { nodes: visited, stats: bfsStats } = await this.bfs({ + start, + direction: 'in', + options, + maxNodes, + maxDepth, + signal, + }); + + const rs = this._newRunStats(); + + // Check each visited node: if it has no incoming neighbors, it's a root + /** @type {string[]} */ + const roots = []; + for (const nodeId of visited) { + checkAborted(signal, 'rootAncestors'); + const inNeighbors = await this._getNeighbors(nodeId, 'in', rs, options); + rs.edgesTraversed += inNeighbors.length; + if (inNeighbors.length === 0) { + roots.push(nodeId); + } + } + + // Sort lexicographically for determinism + roots.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + + return { + roots, + stats: { + nodesVisited: bfsStats.nodesVisited, + edgesTraversed: bfsStats.edgesTraversed + rs.edgesTraversed, + cacheHits: bfsStats.cacheHits + rs.cacheHits, + cacheMisses: bfsStats.cacheMisses + rs.cacheMisses, + }, + }; + } + + /** + * Transitive reduction — minimal edge set preserving reachability (DAGs only). + * + * For each node u with direct successors, BFS from u's grandchildren + * to find which direct successors are also reachable via longer paths. + * Those direct edges are redundant and removed. + * + * @param {{ start: string | string[], direction?: Direction, options?: NeighborOptions, maxNodes?: number, signal?: AbortSignal }} params + * @returns {Promise<{edges: Array<{from: string, to: string, label: string}>, removed: number, stats: TraversalStats}>} + * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles + * @throws {TraversalError} code 'INVALID_START' if start node missing + */ + async transitiveReduction({ + start, direction = 'out', options, + maxNodes = DEFAULT_MAX_NODES, + signal, + }) { + // Topo sort with cycle detection + neighbor edge map reuse + const { sorted, _neighborEdgeMap } = await this.topologicalSort({ + start, + direction, + options, + maxNodes, + throwOnCycle: true, + signal, + _returnAdjList: true, + }); + + const rs = this._newRunStats(); + /** @type {Map} */ + const adjList = new Map(); + + // Build adjacency list from topo sort data + for (const nodeId of sorted) { + const neighbors = _neighborEdgeMap + ? (_neighborEdgeMap.get(nodeId) || []) + : await this._getNeighbors(nodeId, direction, rs, options); + adjList.set(nodeId, neighbors.map((n) => n.neighborId)); + } + + // For each node, find redundant edges via DFS/BFS from grandchildren + /** @type {Set} — keys are "from\0to" */ + const redundant = new Set(); + + for (const u of sorted) { + checkAborted(signal, 'transitiveReduction'); + const directSuccessors = adjList.get(u) || []; + if (directSuccessors.length <= 1) { + continue; // Cannot have redundant edges with 0 or 1 successor + } + + const directSet = new Set(directSuccessors); + + // BFS from all grandchildren (successors-of-successors) + // Any direct successor reachable from a grandchild is redundant + /** @type {Set} */ + const visited = new Set(); + /** @type {string[]} */ + let frontier = []; + + for (const s of directSuccessors) { + const sSuccessors = adjList.get(s) || []; + for (const gc of sSuccessors) { + if (!visited.has(gc)) { + visited.add(gc); + frontier.push(gc); + } + } + } + + // BFS forward from grandchildren + while (frontier.length > 0) { + /** @type {string[]} */ + const nextFrontier = []; + for (const nodeId of frontier) { + if (directSet.has(nodeId)) { + redundant.add(`${u}\0${nodeId}`); + } + const successors = adjList.get(nodeId) || []; + for (const s of successors) { + if (!visited.has(s)) { + visited.add(s); + nextFrontier.push(s); + } + } + } + frontier = nextFrontier; + } + } + + // Collect non-redundant edges with labels from the original neighbor data + /** @type {Array<{from: string, to: string, label: string}>} */ + const edges = []; + let removed = 0; + + for (const nodeId of sorted) { + const neighbors = _neighborEdgeMap + ? (_neighborEdgeMap.get(nodeId) || []) + : []; + for (const { neighborId, label } of neighbors) { + if (redundant.has(`${nodeId}\0${neighborId}`)) { + removed++; + } else { + edges.push({ from: nodeId, to: neighborId, label }); + } + } + } + + // Sort edges for determinism + edges.sort((a, b) => { + if (a.from < b.from) { return -1; } + if (a.from > b.from) { return 1; } + if (a.to < b.to) { return -1; } + if (a.to > b.to) { return 1; } + if (a.label < b.label) { return -1; } + if (a.label > b.label) { return 1; } + return 0; + }); + + return { edges, removed, stats: this._stats(sorted.length, rs) }; + } + + /** + * Transitive closure — all implied reachability edges. + * + * For each node, BFS to find all reachable nodes and emit an edge + * for each pair. Works on cyclic graphs. + * + * @param {{ start: string | string[], direction?: Direction, options?: NeighborOptions, maxNodes?: number, maxEdges?: number, signal?: AbortSignal }} params + * @returns {Promise<{edges: Array<{from: string, to: string}>, stats: TraversalStats}>} + * @throws {TraversalError} code 'INVALID_START' if start node missing + * @throws {TraversalError} code 'E_MAX_EDGES_EXCEEDED' if closure exceeds maxEdges + */ + async transitiveClosure({ + start, direction = 'out', options, + maxNodes = DEFAULT_MAX_NODES, + maxEdges = 1000000, + signal, + }) { + const rs = this._newRunStats(); + const starts = [...new Set(Array.isArray(start) ? start : [start])]; + for (const s of starts) { + await this._validateStart(s); + } + + // Phase 1: Discover all reachable nodes via BFS from all starts + const allVisited = new Set(); + /** @type {string[]} */ + const queue = [...starts]; + let qHead = 0; + for (const s of starts) { + allVisited.add(s); + } + + while (qHead < queue.length) { + if (allVisited.size % 1000 === 0) { + checkAborted(signal, 'transitiveClosure'); + } + if (allVisited.size >= maxNodes) { + break; + } + const nodeId = /** @type {string} */ (queue[qHead++]); + const neighbors = await this._getNeighbors(nodeId, direction, rs, options); + rs.edgesTraversed += neighbors.length; + for (const { neighborId } of neighbors) { + if (!allVisited.has(neighborId)) { + allVisited.add(neighborId); + queue.push(neighborId); + } + } + } + + // Phase 2: For each node, BFS to collect all reachable nodes + /** @type {Array<{from: string, to: string}>} */ + const edges = []; + let edgeCount = 0; + + const nodeList = [...allVisited].sort(); + + for (const fromNode of nodeList) { + checkAborted(signal, 'transitiveClosure'); + + // BFS from fromNode + const visited = new Set([fromNode]); + /** @type {string[]} */ + let frontier = [fromNode]; + + while (frontier.length > 0) { + /** @type {string[]} */ + const nextFrontier = []; + for (const nodeId of frontier) { + const neighbors = await this._getNeighbors(nodeId, direction, rs, options); + rs.edgesTraversed += neighbors.length; + for (const { neighborId } of neighbors) { + if (!visited.has(neighborId)) { + visited.add(neighborId); + nextFrontier.push(neighborId); + edgeCount++; + if (edgeCount > maxEdges) { + throw new TraversalError( + `Transitive closure exceeds maxEdges limit (${maxEdges})`, + { code: 'E_MAX_EDGES_EXCEEDED', context: { maxEdges, edgesSoFar: edgeCount } }, + ); + } + edges.push({ from: fromNode, to: neighborId }); + } + } + } + frontier = nextFrontier; + } + } + + // Sort edges for determinism + edges.sort((a, b) => { + if (a.from < b.from) { return -1; } + if (a.from > b.from) { return 1; } + if (a.to < b.to) { return -1; } + if (a.to > b.to) { return 1; } + return 0; + }); + + return { edges, stats: this._stats(allVisited.size, rs) }; + } + // ==== Private Helpers ==== /** diff --git a/src/domain/services/LogicalTraversal.js b/src/domain/services/LogicalTraversal.js index ef1dfba2..6ec57255 100644 --- a/src/domain/services/LogicalTraversal.js +++ b/src/domain/services/LogicalTraversal.js @@ -417,4 +417,122 @@ export default class LogicalTraversal { }); return { path, totalCost }; } + + /** + * Longest-path level assignment (DAGs only). + * + * @param {string|string[]} start - One or more start nodes + * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], signal?: AbortSignal }} [options] - Traversal options + * @returns {Promise<{levels: Map, maxLevel: number}>} + * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles + * @throws {TraversalError} code 'NODE_NOT_FOUND' if a start node does not exist + */ + async levels(start, options = {}) { + const { engine, direction, options: opts } = await this._prepareEngine(options); + + const starts = Array.isArray(start) ? start : [start]; + for (const s of starts) { + if (!(await this._graph.hasNode(s))) { + throw new TraversalError(`Start node not found: ${s}`, { + code: 'NODE_NOT_FOUND', + context: { start: s }, + }); + } + } + + const { levels, maxLevel } = await engine.levels({ + start, + direction, + options: opts, + maxNodes: Infinity, + signal: options.signal, + }); + return { levels, maxLevel }; + } + + /** + * Transitive reduction — minimal edge set preserving reachability (DAGs only). + * + * @param {string|string[]} start - One or more start nodes + * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], signal?: AbortSignal }} [options] - Traversal options + * @returns {Promise<{edges: Array<{from: string, to: string, label: string}>, removed: number}>} + * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles + * @throws {TraversalError} code 'NODE_NOT_FOUND' if a start node does not exist + */ + async transitiveReduction(start, options = {}) { + const { engine, direction, options: opts } = await this._prepareEngine(options); + + const starts = Array.isArray(start) ? start : [start]; + for (const s of starts) { + if (!(await this._graph.hasNode(s))) { + throw new TraversalError(`Start node not found: ${s}`, { + code: 'NODE_NOT_FOUND', + context: { start: s }, + }); + } + } + + const { edges, removed } = await engine.transitiveReduction({ + start, + direction, + options: opts, + maxNodes: Infinity, + signal: options.signal, + }); + return { edges, removed }; + } + + /** + * Transitive closure — all implied reachability edges. + * + * @param {string|string[]} start - One or more start nodes + * @param {{ dir?: 'out'|'in'|'both', labelFilter?: string|string[], maxEdges?: number, signal?: AbortSignal }} [options] - Traversal options + * @returns {Promise<{edges: Array<{from: string, to: string}>}>} + * @throws {TraversalError} code 'E_MAX_EDGES_EXCEEDED' if closure exceeds maxEdges + * @throws {TraversalError} code 'NODE_NOT_FOUND' if a start node does not exist + */ + async transitiveClosure(start, options = {}) { + const { engine, direction, options: opts } = await this._prepareEngine(options); + + const starts = Array.isArray(start) ? start : [start]; + for (const s of starts) { + if (!(await this._graph.hasNode(s))) { + throw new TraversalError(`Start node not found: ${s}`, { + code: 'NODE_NOT_FOUND', + context: { start: s }, + }); + } + } + + const { edges } = await engine.transitiveClosure({ + start, + direction, + options: opts, + maxNodes: Infinity, + maxEdges: options.maxEdges, + signal: options.signal, + }); + return { edges }; + } + + /** + * Find all root ancestors (in-degree-0 nodes) reachable backward from start. + * + * @param {string} start - Starting node ID + * @param {{ labelFilter?: string|string[], maxDepth?: number, signal?: AbortSignal }} [options] - Traversal options + * @returns {Promise<{roots: string[]}>} + * @throws {TraversalError} code 'NODE_NOT_FOUND' if start node does not exist + */ + async rootAncestors(start, options = {}) { + const { engine, options: opts, depthLimit } = await this._prepare(start, options); + + const { roots } = await engine.rootAncestors({ + start, + options: opts, + maxNodes: Infinity, + maxDepth: options.maxDepth ?? depthLimit, + signal: options.signal, + }); + return { roots }; + } } diff --git a/src/domain/utils/roaring.js b/src/domain/utils/roaring.js index 79ab922d..d88c9284 100644 --- a/src/domain/utils/roaring.js +++ b/src/domain/utils/roaring.js @@ -97,27 +97,126 @@ function loadRoaring() { return roaringModule; } +/** + * Adapts the `roaring-wasm` module to match the `roaring` native API surface. + * + * The WASM module is already largely compatible (serialize/deserialize accept + * booleans), but it lacks `isNativelyInstalled` which `getNativeRoaringAvailable()` + * probes. This shim adds it so downstream code works without branching. + * + * @param {RoaringModule} wasmMod - The loaded `roaring-wasm` module + * @returns {RoaringModule} Adapted module with `isNativelyInstalled` shim + * @private + */ +function adaptWasmApi(wasmMod) { + wasmMod.RoaringBitmap32.isNativelyInstalled = () => false; + return wasmMod; +} + +/** + * Tier 1: ESM dynamic import of native roaring. + * @param {Error[]} errors - Collects per-tier failures for diagnostics + * @returns {Promise} + * @private + */ +async function tryNativeImport(errors) { + try { + return /** @type {RoaringModule} */ (await import('roaring')); + } catch (err) { + errors.push(err instanceof Error ? err : new Error(String(err))); + return null; + } +} + +/** + * Tier 2: CJS require() — works when Vite intercepts import() but can't + * transform native C++ addons. + * @param {Error[]} errors - Collects per-tier failures for diagnostics + * @returns {Promise} + * @private + */ +async function tryCjsRequire(errors) { + try { + const { createRequire } = await import('node:module'); + const req = createRequire(import.meta.url); + return /** @type {RoaringModule} */ (req('roaring')); + } catch (err) { + errors.push(err instanceof Error ? err : new Error(String(err))); + return null; + } +} + +/** + * Tier 3: WASM fallback — works on Bun (JSC) and Deno without native bindings. + * @param {Error[]} errors - Collects per-tier failures for diagnostics + * @returns {Promise} + * @private + */ +async function tryWasmFallback(errors) { + try { + const wasmMod = await import('roaring-wasm'); + if (typeof wasmMod.roaringLibraryInitialize === 'function') { + await wasmMod.roaringLibraryInitialize(); + } + return adaptWasmApi(/** @type {RoaringModule} */ (wasmMod)); + } catch (err) { + errors.push(err instanceof Error ? err : new Error(String(err))); + return null; + } +} + +/** + * Unwraps ESM default-export wrappers on a loaded roaring module. + * Handles both ESM `{ default: { RoaringBitmap32 } }` and CJS `module.exports`. + * @param {RoaringModule} mod + * @returns {RoaringModule} + * @private + */ +function unwrapDefault(mod) { + if (mod.default && mod.default.RoaringBitmap32) { + return /** @type {RoaringModule} */ (mod.default); + } + return mod; +} + /** * Initializes the roaring module. Must be called before getRoaringBitmap32(). * This is called automatically via top-level await when the module is imported, * but can also be called manually with a pre-loaded module for testing. * + * Fallback chain: + * Tier 1: await import('roaring') — ESM native V8 bindings + * Tier 2: createRequire('roaring') — CJS native (Vite workaround) + * Tier 3: await import('roaring-wasm') — WASM portable fallback + * * @param {RoaringModule} [mod] - Pre-loaded roaring module (for testing/DI) * @returns {Promise} */ export async function initRoaring(mod) { if (mod) { - roaringModule = mod; + roaringModule = unwrapDefault(mod); + nativeAvailability = NOT_CHECKED; initError = null; return; } + if (roaringModule) { + return; + } + /** @type {Error[]} */ + const loadErrors = []; + roaringModule = + (await tryNativeImport(loadErrors)) ?? + (await tryCjsRequire(loadErrors)) ?? + (await tryWasmFallback(loadErrors)); if (!roaringModule) { - roaringModule = /** @type {RoaringModule} */ (await import('roaring')); - // Handle both ESM default export and CJS module.exports - if (roaringModule.default && roaringModule.default.RoaringBitmap32) { - roaringModule = roaringModule.default; - } + throw new AggregateError( + loadErrors, + 'Failed to load roaring via import(), require(), and roaring-wasm', + ); } + roaringModule = unwrapDefault(roaringModule); + nativeAvailability = NOT_CHECKED; + initError = null; } // Auto-initialize on module load (top-level await) diff --git a/test/helpers/fixtureDsl.js b/test/helpers/fixtureDsl.js index 1bf56440..f172b558 100644 --- a/test/helpers/fixtureDsl.js +++ b/test/helpers/fixtureDsl.js @@ -458,6 +458,90 @@ export const F14_NODE_WEIGHTS = new Map([ ['END', 0], ]); +/** + * F15 — WIDE_DAG_FOR_LEVELS + * + * Tests longest-path level assignment. + * A→B, A→C, B→D, D→E, C→E. + * Longest path to each: A=0, B=1, C=1, D=2, E=3 (via A→B→D→E, not A→C→E). + * + * A + * / \ + * B C + * | \ + * D | + * \ / + * E + */ +export const F15_WIDE_DAG_FOR_LEVELS = makeFixture({ + nodes: ['A', 'B', 'C', 'D', 'E'], + edges: [ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'B', to: 'D' }, + { from: 'D', to: 'E' }, + { from: 'C', to: 'E' }, + ], +}); + +/** + * F16 — TRANSITIVE_REDUCTION + * + * A→B, A→C (redundant), B→C. + * Transitive reduction removes A→C because A→B→C already reaches C. + * + * A ──→ B + * \ ↓ + * └→ C + */ +export const F16_TRANSITIVE_REDUCTION = makeFixture({ + nodes: ['A', 'B', 'C'], + edges: [ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'B', to: 'C' }, + ], +}); + +/** + * F17 — MULTI_ROOT_DAG + * + * Two root nodes (in-degree 0) converge on D. + * R1→A→D, R2→B→D, R2→C→D. + * rootAncestors(D) should return [R1, R2]. + * + * R1 → A ──┐ + * ↓ + * R2 → B → D + * └→ C ──┘ + */ +export const F17_MULTI_ROOT_DAG = makeFixture({ + nodes: ['R1', 'R2', 'A', 'B', 'C', 'D'], + edges: [ + { from: 'R1', to: 'A' }, + { from: 'A', to: 'D' }, + { from: 'R2', to: 'B' }, + { from: 'R2', to: 'C' }, + { from: 'B', to: 'D' }, + { from: 'C', to: 'D' }, + ], +}); + +/** + * F18 — TRANSITIVE_CLOSURE_CHAIN + * + * A→B→C→D. Linear chain. + * Transitive closure adds: A→C, A→D, B→D = 3 new edges + 3 existing = 6 total. + */ +export const F18_TRANSITIVE_CLOSURE_CHAIN = makeFixture({ + nodes: ['A', 'B', 'C', 'D'], + edges: [ + { from: 'A', to: 'B' }, + { from: 'B', to: 'C' }, + { from: 'C', to: 'D' }, + ], +}); + // ── Utility: weight function from a Map ───────────────────────────────────── /** diff --git a/test/unit/cli/doctor.test.js b/test/unit/cli/doctor.test.js index e3e4350f..fdc8fe3d 100644 --- a/test/unit/cli/doctor.test.js +++ b/test/unit/cli/doctor.test.js @@ -12,12 +12,14 @@ vi.mock('../../../bin/cli/shared.js', () => ({ // Mock HealthCheckService vi.mock('../../../src/domain/services/HealthCheckService.js', () => ({ - default: vi.fn().mockImplementation(() => ({ - getHealth: vi.fn().mockResolvedValue({ - status: 'healthy', - components: { repository: { status: 'healthy', latencyMs: 1 } }, - }), - })), + default: vi.fn().mockImplementation(function () { + return { + getHealth: vi.fn().mockResolvedValue({ + status: 'healthy', + components: { repository: { status: 'healthy', latencyMs: 1 } }, + }), + }; + }), })); // Mock ClockAdapter diff --git a/test/unit/domain/WarpGraph.cascadeDelete.test.js b/test/unit/domain/WarpGraph.cascadeDelete.test.js index c4212292..fc52f82e 100644 --- a/test/unit/domain/WarpGraph.cascadeDelete.test.js +++ b/test/unit/domain/WarpGraph.cascadeDelete.test.js @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import WarpGraph from '../../../src/domain/WarpGraph.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; -describe('Cascade delete mode (HS/DELGUARD/3)', () => { +describe('Cascade delete mode (HS/DELGUARD/3)', { timeout: 15000 }, () => { it('cascade delete generates EdgeRemove ops for 3 connected edges + NodeRemove', async () => { const repo = await createGitRepo('cascade'); try { @@ -49,7 +49,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('materialized state has no dangling edges after cascade delete', async () => { const repo = await createGitRepo('cascade'); @@ -90,7 +90,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('cascade delete on node with no edges produces only NodeRemove', async () => { const repo = await createGitRepo('cascade'); @@ -121,7 +121,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('cascade delete handles both incoming and outgoing edges', async () => { const repo = await createGitRepo('cascade'); @@ -174,7 +174,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('cascade delete handles self-loop edge correctly', async () => { const repo = await createGitRepo('cascade'); @@ -220,7 +220,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('generated EdgeRemove ops appear in committed patch (auditable)', async () => { const repo = await createGitRepo('cascade'); @@ -264,7 +264,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('cascade mode preserves unrelated edges', async () => { const repo = await createGitRepo('cascade'); @@ -306,7 +306,7 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); it('without cascade mode, removeNode does not generate EdgeRemove ops', async () => { const repo = await createGitRepo('cascade'); @@ -338,5 +338,5 @@ describe('Cascade delete mode (HS/DELGUARD/3)', () => { } finally { await repo.cleanup(); } - }, { timeout: 15000 }); + }); }); diff --git a/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js b/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js index 434b9e4e..8f3d14c4 100644 --- a/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js +++ b/test/unit/domain/WarpGraph.deleteGuardEnforce.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import WarpGraph from '../../../src/domain/WarpGraph.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; -describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { +describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', { timeout: 15000 }, () => { /** @type {any} */ let repo; @@ -40,7 +40,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(() => patch.removeNode('n1')).toThrow( /Cannot delete node 'n1': node has attached data.*propert/ ); - }, { timeout: 15000 }); + }); it('throws when deleting a node that has edges', async () => { repo = await createGitRepo('delguard'); @@ -65,7 +65,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(() => patch.removeNode('n1')).toThrow( /Cannot delete node 'n1': node has attached data.*edge/ ); - }, { timeout: 15000 }); + }); it('throws when deleting a node that is an edge target', async () => { repo = await createGitRepo('delguard'); @@ -90,7 +90,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(() => patch.removeNode('n2')).toThrow( /Cannot delete node 'n2': node has attached data.*edge/ ); - }, { timeout: 15000 }); + }); it('succeeds when deleting a node with no attached data', async () => { repo = await createGitRepo('delguard'); @@ -115,7 +115,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(typeof sha).toBe('string'); expect(sha.length).toBe(40); - }, { timeout: 15000 }); + }); it('mentions both edges and properties in error when both exist', async () => { repo = await createGitRepo('delguard'); @@ -140,7 +140,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(() => patch.removeNode('n1')).toThrow( /1 edge\(s\) and 1 propert/ ); - }, { timeout: 15000 }); + }); it('error message suggests cascade mode', async () => { repo = await createGitRepo('delguard'); @@ -162,7 +162,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(() => patch.removeNode('n1')).toThrow( /set onDeleteWithData to 'cascade'/ ); - }, { timeout: 15000 }); + }); }); // --------------------------------------------------------------------------- @@ -206,7 +206,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(warnSpy).toHaveBeenCalledOnce(); expect(warnSpy.mock.calls[0][0]).toMatch(/Deleting node 'n1'/); expect(warnSpy.mock.calls[0][0]).toMatch(/propert/); - }, { timeout: 15000 }); + }); it('logs warning via logger when deleting node with edges', async () => { repo = await createGitRepo('delguard'); @@ -234,7 +234,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(typeof sha).toBe('string'); expect(warnSpy).toHaveBeenCalled(); expect(warnSpy.mock.calls[0][0]).toMatch(/edge/); - }, { timeout: 15000 }); + }); it('does not warn when deleting node with no attached data', async () => { repo = await createGitRepo('delguard'); @@ -260,7 +260,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(typeof sha).toBe('string'); expect(warnSpy).not.toHaveBeenCalled(); - }, { timeout: 15000 }); + }); }); // --------------------------------------------------------------------------- @@ -291,7 +291,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(() => patch.removeNode('n1')).toThrow( /Cannot delete node 'n1'/ ); - }, { timeout: 15000 }); + }); it('warn mode works through writer().commitPatch()', async () => { repo = await createGitRepo('delguard'); @@ -319,7 +319,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(typeof sha).toBe('string'); expect(warnSpy).toHaveBeenCalledOnce(); expect(warnSpy.mock.calls[0][0]).toMatch(/propert/); - }, { timeout: 15000 }); + }); }); // --------------------------------------------------------------------------- @@ -354,7 +354,7 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { expect(typeof sha).toBe('string'); expect(warnSpy).not.toHaveBeenCalled(); - }, { timeout: 15000 }); + }); }); // --------------------------------------------------------------------------- @@ -375,6 +375,6 @@ describe('WarpGraph deleteGuard enforcement (HS/DELGUARD/2)', () => { // removeNode should not throw because there's no state to check against const patch = await graph.createPatch(); expect(() => patch.removeNode('n1')).not.toThrow(); - }, { timeout: 15000 }); + }); }); }); diff --git a/test/unit/domain/WarpGraph.noCoordination.test.js b/test/unit/domain/WarpGraph.noCoordination.test.js index 50cd64bb..5d7b7057 100644 --- a/test/unit/domain/WarpGraph.noCoordination.test.js +++ b/test/unit/domain/WarpGraph.noCoordination.test.js @@ -16,7 +16,7 @@ async function assertLinearWriterChain(/** @type {any} */ persistence, /** @type } describe('No-coordination regression suite', () => { - it('keeps writer refs linear after sync cycles', async () => { + it('keeps writer refs linear after sync cycles', { timeout: 20000 }, async () => { const repoA = await createGitRepo('nocoord'); const repoB = await createGitRepo('nocoord'); @@ -67,7 +67,7 @@ describe('No-coordination regression suite', () => { await repoA.cleanup(); await repoB.cleanup(); } - }, { timeout: 20000 }); + }); it('does not enumerate other writer heads during commit', async () => { const repo = await createGitRepo('nocoord'); @@ -89,7 +89,7 @@ describe('No-coordination regression suite', () => { } }); - it('survives random sync/commit interleavings without merge commits', async () => { + it('survives random sync/commit interleavings without merge commits', { timeout: 30000 }, async () => { const opArb = fc.array( fc.constantFrom('commitA', 'commitB', 'syncAB', 'syncBA'), { minLength: 1, maxLength: 6 } @@ -138,10 +138,10 @@ describe('No-coordination regression suite', () => { }), { seed: 4242, numRuns: 8 } ); - }, { timeout: 30000 }); + }); describe('Lamport clock global-max monotonicity', () => { - it('first-time writer beats existing writer when it materializes first', async () => { + it('first-time writer beats existing writer when it materializes first', { timeout: 20000 }, async () => { // Regression: when writer B makes its very first commit to a repo where writer A // has already committed at tick N, B must commit at tick > N so its operations // win the LWW CRDT tiebreaker — not lose to A's tick-1 commit. @@ -191,9 +191,9 @@ describe('No-coordination regression suite', () => { } finally { await repo.cleanup(); } - }, { timeout: 20000 }); + }); - it('_maxObservedLamport is updated after each commit on the same instance', async () => { + it('_maxObservedLamport is updated after each commit on the same instance', { timeout: 10000 }, async () => { const repo = await createGitRepo('lamport-mono'); try { const graph = await WarpGraph.open({ @@ -219,9 +219,9 @@ describe('No-coordination regression suite', () => { } finally { await repo.cleanup(); } - }, { timeout: 10000 }); + }); - it('cross-writer PropSet LWW: later writer override is not silently discarded', async () => { + it('cross-writer PropSet LWW: later writer override is not silently discarded', { timeout: 20000 }, async () => { // Regression for: "Lamport clock doesn't advance past cross-writer ticks // during materialize()" — Writer A commits setProperty at tick N, Writer B // materializes (observes A), then commits setProperty for the same key. @@ -284,9 +284,9 @@ describe('No-coordination regression suite', () => { } finally { await repo.cleanup(); } - }, { timeout: 20000 }); + }); - it('materialize updates _maxObservedLamport from observed patches', async () => { + it('materialize updates _maxObservedLamport from observed patches', { timeout: 10000 }, async () => { const repo = await createGitRepo('lamport-mono'); try { // Seed with writer-z at tick 1 @@ -317,7 +317,7 @@ describe('No-coordination regression suite', () => { } finally { await repo.cleanup(); } - }, { timeout: 10000 }); + }); }); }); diff --git a/test/unit/domain/WarpGraph.patchMany.test.js b/test/unit/domain/WarpGraph.patchMany.test.js index 04ba9208..c42b1769 100644 --- a/test/unit/domain/WarpGraph.patchMany.test.js +++ b/test/unit/domain/WarpGraph.patchMany.test.js @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import WarpGraph from '../../../src/domain/WarpGraph.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; -describe('WarpGraph.patchMany()', () => { +describe('WarpGraph.patchMany()', { timeout: 30000 }, () => { it('returns empty array when called with no arguments', async () => { const repo = await createGitRepo('patchMany-empty'); try { @@ -147,4 +147,4 @@ describe('WarpGraph.patchMany()', () => { await repo.cleanup(); } }); -}, { timeout: 30000 }); +}); diff --git a/test/unit/domain/WarpGraph.syncMaterialize.test.js b/test/unit/domain/WarpGraph.syncMaterialize.test.js index e3cf1bfa..8ae17d0e 100644 --- a/test/unit/domain/WarpGraph.syncMaterialize.test.js +++ b/test/unit/domain/WarpGraph.syncMaterialize.test.js @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import WarpGraph from '../../../src/domain/WarpGraph.js'; import { createGitRepo } from '../../helpers/warpGraphTestUtils.js'; -describe('syncWith({ materialize }) option', () => { +describe('syncWith({ materialize }) option', { timeout: 20000 }, () => { it('syncWith(peer, { materialize: true }) returns fresh state in result', async () => { const repoA = await createGitRepo('syncmat'); const repoB = await createGitRepo('syncmat'); @@ -38,7 +38,7 @@ describe('syncWith({ materialize }) option', () => { await repoA.cleanup(); await repoB.cleanup(); } - }, { timeout: 20000 }); + }); it('syncWith(peer) (default) does NOT auto-materialize — result has no state field', async () => { const repoA = await createGitRepo('syncmat'); @@ -69,7 +69,7 @@ describe('syncWith({ materialize }) option', () => { await repoA.cleanup(); await repoB.cleanup(); } - }, { timeout: 20000 }); + }); it('sync applies 0 patches + materialize:true — materialize still runs', async () => { const repoA = await createGitRepo('syncmat'); @@ -102,5 +102,5 @@ describe('syncWith({ materialize }) option', () => { await repoA.cleanup(); await repoB.cleanup(); } - }, { timeout: 20000 }); + }); }); diff --git a/test/unit/domain/services/BisectService.test.js b/test/unit/domain/services/BisectService.test.js index f8ffa742..4c490ed1 100644 --- a/test/unit/domain/services/BisectService.test.js +++ b/test/unit/domain/services/BisectService.test.js @@ -4,7 +4,7 @@ import BisectService from '../../../../src/domain/services/BisectService.js'; import { orsetContains } from '../../../../src/domain/crdt/ORSet.js'; import { createGitRepo } from '../../../helpers/warpGraphTestUtils.js'; -describe('BisectService', () => { +describe('BisectService', { timeout: 30000 }, () => { it('vector 1: linear chain — finds first bad patch', async () => { const repo = await createGitRepo('bisect-linear'); try { @@ -42,7 +42,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 2: same good and bad — range-error', async () => { const repo = await createGitRepo('bisect-same'); @@ -69,7 +69,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 3: single step — A→B, good=A bad=B → result=B, 0 steps', async () => { const repo = await createGitRepo('bisect-single'); @@ -102,7 +102,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 4: good is not ancestor of bad — range-error', async () => { const repo = await createGitRepo('bisect-reversed'); @@ -131,7 +131,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 5: SHA not found in chain — range-error', async () => { const repo = await createGitRepo('bisect-notfound'); @@ -159,7 +159,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 6: testFn receives candidate SHA', async () => { const repo = await createGitRepo('bisect-sha-arg'); @@ -199,7 +199,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 7: all-bad — first candidate after good is the first bad patch', async () => { const repo = await createGitRepo('bisect-all-bad'); @@ -230,7 +230,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 8: testFn throws — promise rejects with same error', async () => { const repo = await createGitRepo('bisect-throws'); @@ -259,7 +259,7 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); it('vector 9: empty writer chain — range-error', async () => { const repo = await createGitRepo('bisect-empty-writer'); @@ -289,5 +289,5 @@ describe('BisectService', () => { } finally { await repo.cleanup(); } - }, { timeout: 30000 }); + }); }); diff --git a/test/unit/domain/services/GraphTraversal.bfs.test.js b/test/unit/domain/services/GraphTraversal.bfs.test.js index c71f8b00..53664e76 100644 --- a/test/unit/domain/services/GraphTraversal.bfs.test.js +++ b/test/unit/domain/services/GraphTraversal.bfs.test.js @@ -10,6 +10,7 @@ import { F3_DIAMOND_EQUAL_PATHS, F9_UNICODE_CODEPOINT_ORDER, F13_BFS_MULTI_PARENT_DEDUP, + F17_MULTI_ROOT_DAG, } from '../../../helpers/fixtureDsl.js'; describe('GraphTraversal.bfs', () => { @@ -135,6 +136,35 @@ describe('GraphTraversal.bfs', () => { }); }); + // Reverse reachability — BFS with direction: 'in' + describe('reverse reachability (direction: "in")', () => { + it('F17 — BFS backward from D reaches all ancestors', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + const { nodes } = await engine.bfs({ start: 'D', direction: 'in' }); + + // D has incoming from A, B, C; A has incoming from R1; B,C from R2 + expect(nodes.sort()).toEqual(['A', 'B', 'C', 'D', 'R1', 'R2']); + }); + + it('F3 — BFS backward from D finds complete reverse graph', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { nodes } = await engine.bfs({ start: 'D', direction: 'in' }); + + // D←B←A, D←C←A + expect(nodes).toEqual(['D', 'B', 'C', 'A']); + }); + + it('BFS backward from root node returns only itself', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + const { nodes } = await engine.bfs({ start: 'R1', direction: 'in' }); + + expect(nodes).toEqual(['R1']); + }); + }); + // M18 — start node validation describe('start node validation (M18)', () => { it('throws INVALID_START for a nonexistent start node', async () => { diff --git a/test/unit/domain/services/GraphTraversal.crossProvider.test.js b/test/unit/domain/services/GraphTraversal.crossProvider.test.js index 35a91aef..cfe190ba 100644 --- a/test/unit/domain/services/GraphTraversal.crossProvider.test.js +++ b/test/unit/domain/services/GraphTraversal.crossProvider.test.js @@ -19,6 +19,10 @@ import { F5_WEIGHTS, F8_TOPO_CYCLE_3, F9_UNICODE_CODEPOINT_ORDER, + F15_WIDE_DAG_FOR_LEVELS, + F16_TRANSITIVE_REDUCTION, + F17_MULTI_ROOT_DAG, + F18_TRANSITIVE_CLOSURE_CHAIN, makeWeightFn, } from '../../../helpers/fixtureDsl.js'; @@ -112,4 +116,48 @@ describe('Cross-provider equivalence', () => { expect(nodes).toEqual(['D', 'B', 'C', 'A']); }); }); + + describe('levels: F15 wide DAG', () => { + forEachProvider(F15_WIDE_DAG_FOR_LEVELS, async (/** @type {*} */ engine) => { + const { levels, maxLevel } = await engine.levels({ start: 'A' }); + expect(levels.get('A')).toBe(0); + expect(levels.get('B')).toBe(1); + expect(levels.get('C')).toBe(1); + expect(levels.get('D')).toBe(2); + expect(levels.get('E')).toBe(3); + expect(maxLevel).toBe(3); + }); + }); + + describe('transitiveReduction: F16', () => { + forEachProvider(F16_TRANSITIVE_REDUCTION, async (/** @type {*} */ engine) => { + const { edges, removed } = await engine.transitiveReduction({ start: 'A' }); + expect(removed).toBe(1); + expect(edges).toEqual([ + { from: 'A', to: 'B', label: '' }, + { from: 'B', to: 'C', label: '' }, + ]); + }); + }); + + describe('transitiveClosure: F18 chain', () => { + forEachProvider(F18_TRANSITIVE_CLOSURE_CHAIN, async (/** @type {*} */ engine) => { + const { edges } = await engine.transitiveClosure({ start: 'A' }); + expect(edges).toEqual([ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'A', to: 'D' }, + { from: 'B', to: 'C' }, + { from: 'B', to: 'D' }, + { from: 'C', to: 'D' }, + ]); + }); + }); + + describe('rootAncestors: F17 multi-root', () => { + forEachProvider(F17_MULTI_ROOT_DAG, async (/** @type {*} */ engine) => { + const { roots } = await engine.rootAncestors({ start: 'D' }); + expect(roots).toEqual(['R1', 'R2']); + }); + }); }); diff --git a/test/unit/domain/services/GraphTraversal.levels.test.js b/test/unit/domain/services/GraphTraversal.levels.test.js new file mode 100644 index 00000000..5f734691 --- /dev/null +++ b/test/unit/domain/services/GraphTraversal.levels.test.js @@ -0,0 +1,114 @@ +/** + * GraphTraversal.levels() — longest-path level assignment. + */ + +import { describe, it, expect } from 'vitest'; +import GraphTraversal from '../../../../src/domain/services/GraphTraversal.js'; +import { + makeAdjacencyProvider, + F3_DIAMOND_EQUAL_PATHS, + F8_TOPO_CYCLE_3, + F15_WIDE_DAG_FOR_LEVELS, +} from '../../../helpers/fixtureDsl.js'; + +describe('GraphTraversal.levels()', () => { + describe('F15 — wide DAG level assignment', () => { + it('assigns longest-path levels', async () => { + const provider = makeAdjacencyProvider(F15_WIDE_DAG_FOR_LEVELS); + const engine = new GraphTraversal({ provider }); + const { levels, maxLevel } = await engine.levels({ start: 'A' }); + + expect(levels.get('A')).toBe(0); + expect(levels.get('B')).toBe(1); + expect(levels.get('C')).toBe(1); + expect(levels.get('D')).toBe(2); + expect(levels.get('E')).toBe(3); + expect(maxLevel).toBe(3); + }); + + it('returns stats', async () => { + const provider = makeAdjacencyProvider(F15_WIDE_DAG_FOR_LEVELS); + const engine = new GraphTraversal({ provider }); + const { stats } = await engine.levels({ start: 'A' }); + + expect(stats.nodesVisited).toBe(5); + expect(stats.edgesTraversed).toBeGreaterThanOrEqual(5); + }); + }); + + describe('F3 — diamond equal paths', () => { + it('assigns correct levels for diamond', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { levels, maxLevel } = await engine.levels({ start: 'A' }); + + expect(levels.get('A')).toBe(0); + expect(levels.get('B')).toBe(1); + expect(levels.get('C')).toBe(1); + expect(levels.get('D')).toBe(2); + expect(maxLevel).toBe(2); + }); + }); + + describe('single node', () => { + it('assigns level 0 to a lone start', async () => { + const provider = makeAdjacencyProvider(F15_WIDE_DAG_FOR_LEVELS); + const engine = new GraphTraversal({ provider }); + const { levels, maxLevel } = await engine.levels({ start: 'E' }); + + expect(levels.get('E')).toBe(0); + expect(maxLevel).toBe(0); + expect(levels.size).toBe(1); + }); + }); + + describe('multiple starts', () => { + it('accepts array of start nodes', async () => { + const provider = makeAdjacencyProvider(F15_WIDE_DAG_FOR_LEVELS); + const engine = new GraphTraversal({ provider }); + const { levels } = await engine.levels({ start: ['A', 'B'] }); + + expect(levels.get('A')).toBe(0); + expect(levels.get('B')).toBe(1); + }); + }); + + describe('cycle detection', () => { + it('throws ERR_GRAPH_HAS_CYCLES on cyclic graph', async () => { + const provider = makeAdjacencyProvider(F8_TOPO_CYCLE_3); + const engine = new GraphTraversal({ provider }); + + await expect(engine.levels({ start: 'A' })).rejects.toThrow( + expect.objectContaining({ + code: 'ERR_GRAPH_HAS_CYCLES', + }), + ); + }); + }); + + describe('INVALID_START', () => { + it('throws when start node does not exist', async () => { + const provider = makeAdjacencyProvider(F15_WIDE_DAG_FOR_LEVELS); + const engine = new GraphTraversal({ provider }); + + await expect(engine.levels({ start: 'NOPE' })).rejects.toThrow( + expect.objectContaining({ + code: 'INVALID_START', + }), + ); + }); + }); + + describe('AbortSignal', () => { + it('respects cancellation', async () => { + const provider = makeAdjacencyProvider(F15_WIDE_DAG_FOR_LEVELS); + const engine = new GraphTraversal({ provider }); + const controller = new AbortController(); + controller.abort(); + + await expect( + engine.levels({ start: 'A', signal: controller.signal }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/test/unit/domain/services/GraphTraversal.rootAncestors.test.js b/test/unit/domain/services/GraphTraversal.rootAncestors.test.js new file mode 100644 index 00000000..f02eb59b --- /dev/null +++ b/test/unit/domain/services/GraphTraversal.rootAncestors.test.js @@ -0,0 +1,124 @@ +/** + * GraphTraversal.rootAncestors() — find in-degree-0 ancestors. + */ + +import { describe, it, expect } from 'vitest'; +import GraphTraversal from '../../../../src/domain/services/GraphTraversal.js'; +import { + makeAdjacencyProvider, + makeFixture, + F3_DIAMOND_EQUAL_PATHS, + F8_TOPO_CYCLE_3, + F17_MULTI_ROOT_DAG, +} from '../../../helpers/fixtureDsl.js'; + +describe('GraphTraversal.rootAncestors()', () => { + describe('F17 — multi-root DAG', () => { + it('finds all root ancestors from leaf node', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + const { roots } = await engine.rootAncestors({ start: 'D' }); + + expect(roots).toEqual(['R1', 'R2']); + }); + + it('returns the node itself if it is a root', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + const { roots } = await engine.rootAncestors({ start: 'R1' }); + + expect(roots).toEqual(['R1']); + }); + + it('finds roots from intermediate node', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + const { roots } = await engine.rootAncestors({ start: 'B' }); + + expect(roots).toEqual(['R2']); + }); + }); + + describe('F3 — diamond', () => { + it('finds single root from leaf', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { roots } = await engine.rootAncestors({ start: 'D' }); + + expect(roots).toEqual(['A']); + }); + + it('returns root as its own ancestor', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { roots } = await engine.rootAncestors({ start: 'A' }); + + expect(roots).toEqual(['A']); + }); + }); + + describe('cyclic graph', () => { + it('works on cyclic graphs (BFS reachability)', async () => { + const provider = makeAdjacencyProvider(F8_TOPO_CYCLE_3); + const engine = new GraphTraversal({ provider }); + // All nodes in a cycle have in-degree > 0, so no roots + const { roots } = await engine.rootAncestors({ start: 'A' }); + + expect(roots).toEqual([]); + }); + }); + + describe('disconnected root', () => { + it('finds only backward-reachable roots', async () => { + const fixture = makeFixture({ + nodes: ['X', 'Y', 'Z'], + edges: [ + { from: 'X', to: 'Y' }, + ], + }); + const provider = makeAdjacencyProvider(fixture); + const engine = new GraphTraversal({ provider }); + // Y has root X backward; Z is disconnected + const { roots } = await engine.rootAncestors({ start: 'Y' }); + + expect(roots).toEqual(['X']); + }); + }); + + describe('INVALID_START', () => { + it('throws when start node does not exist', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + + await expect(engine.rootAncestors({ start: 'NOPE' })).rejects.toThrow( + expect.objectContaining({ + code: 'INVALID_START', + }), + ); + }); + }); + + describe('maxDepth', () => { + it('respects maxDepth limit', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + // maxDepth=1 from D reaches A, B, C but NOT R1, R2 + const { roots } = await engine.rootAncestors({ start: 'D', maxDepth: 1 }); + + // A, B, C all have incoming edges so none are roots within depth 1 + // D itself has incoming edges, so it's not a root either + expect(roots).toEqual([]); + }); + }); + + describe('stats', () => { + it('returns traversal stats', async () => { + const provider = makeAdjacencyProvider(F17_MULTI_ROOT_DAG); + const engine = new GraphTraversal({ provider }); + const { stats } = await engine.rootAncestors({ start: 'D' }); + + expect(stats.nodesVisited).toBeGreaterThan(0); + expect(stats.edgesTraversed).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/unit/domain/services/GraphTraversal.transitiveClosure.test.js b/test/unit/domain/services/GraphTraversal.transitiveClosure.test.js new file mode 100644 index 00000000..7c926dce --- /dev/null +++ b/test/unit/domain/services/GraphTraversal.transitiveClosure.test.js @@ -0,0 +1,145 @@ +/** + * GraphTraversal.transitiveClosure() — all implied reachability edges. + */ + +import { describe, it, expect } from 'vitest'; +import GraphTraversal from '../../../../src/domain/services/GraphTraversal.js'; +import { + makeAdjacencyProvider, + makeFixture, + F3_DIAMOND_EQUAL_PATHS, + F8_TOPO_CYCLE_3, + F18_TRANSITIVE_CLOSURE_CHAIN, +} from '../../../helpers/fixtureDsl.js'; + +describe('GraphTraversal.transitiveClosure()', () => { + describe('F18 — linear chain A→B→C→D', () => { + it('produces 6 reachability edges', async () => { + const provider = makeAdjacencyProvider(F18_TRANSITIVE_CLOSURE_CHAIN); + const engine = new GraphTraversal({ provider }); + const { edges } = await engine.transitiveClosure({ start: 'A' }); + + // A→B, A→C, A→D, B→C, B→D, C→D + expect(edges).toEqual([ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'A', to: 'D' }, + { from: 'B', to: 'C' }, + { from: 'B', to: 'D' }, + { from: 'C', to: 'D' }, + ]); + }); + }); + + describe('F3 — diamond', () => { + it('includes both paths plus transitive A→D', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { edges } = await engine.transitiveClosure({ start: 'A' }); + + // A→B, A→C, A→D, B→D, C→D + expect(edges).toEqual([ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'A', to: 'D' }, + { from: 'B', to: 'D' }, + { from: 'C', to: 'D' }, + ]); + }); + }); + + describe('cyclic graph', () => { + it('works on cyclic graphs', async () => { + const provider = makeAdjacencyProvider(F8_TOPO_CYCLE_3); + const engine = new GraphTraversal({ provider }); + const { edges } = await engine.transitiveClosure({ start: 'A' }); + + // A→B, A→C, B→A, B→C, C→A, C→B — full reachability + expect(edges).toEqual([ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'B', to: 'A' }, + { from: 'B', to: 'C' }, + { from: 'C', to: 'A' }, + { from: 'C', to: 'B' }, + ]); + }); + }); + + describe('single node', () => { + it('returns empty edges for isolated node', async () => { + const fixture = makeFixture({ + nodes: ['X'], + edges: [], + }); + const provider = makeAdjacencyProvider(fixture); + const engine = new GraphTraversal({ provider }); + const { edges } = await engine.transitiveClosure({ start: 'X' }); + + expect(edges).toEqual([]); + }); + }); + + describe('maxEdges safety', () => { + it('throws E_MAX_EDGES_EXCEEDED when limit hit', async () => { + const provider = makeAdjacencyProvider(F18_TRANSITIVE_CLOSURE_CHAIN); + const engine = new GraphTraversal({ provider }); + + await expect( + engine.transitiveClosure({ start: 'A', maxEdges: 3 }), + ).rejects.toThrow( + expect.objectContaining({ + code: 'E_MAX_EDGES_EXCEEDED', + }), + ); + }); + + it('succeeds when edges within limit', async () => { + const provider = makeAdjacencyProvider(F18_TRANSITIVE_CLOSURE_CHAIN); + const engine = new GraphTraversal({ provider }); + const { edges } = await engine.transitiveClosure({ start: 'A', maxEdges: 6 }); + + expect(edges.length).toBe(6); + }); + }); + + describe('INVALID_START', () => { + it('throws when start node does not exist', async () => { + const provider = makeAdjacencyProvider(F18_TRANSITIVE_CLOSURE_CHAIN); + const engine = new GraphTraversal({ provider }); + + await expect(engine.transitiveClosure({ start: 'NOPE' })).rejects.toThrow( + expect.objectContaining({ + code: 'INVALID_START', + }), + ); + }); + }); + + describe('deterministic output', () => { + it('edges are sorted lexicographically', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { edges } = await engine.transitiveClosure({ start: 'A' }); + + for (let i = 1; i < edges.length; i++) { + const prev = edges[i - 1]; + const curr = edges[i]; + const cmp = prev.from < curr.from ? -1 : prev.from > curr.from ? 1 : + prev.to < curr.to ? -1 : prev.to > curr.to ? 1 : 0; + expect(cmp).toBeLessThanOrEqual(0); + } + }); + }); + + describe('stats', () => { + it('returns traversal stats', async () => { + const provider = makeAdjacencyProvider(F18_TRANSITIVE_CLOSURE_CHAIN); + const engine = new GraphTraversal({ provider }); + const { stats } = await engine.transitiveClosure({ start: 'A' }); + + expect(stats.nodesVisited).toBe(4); + expect(stats.edgesTraversed).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/unit/domain/services/GraphTraversal.transitiveReduction.test.js b/test/unit/domain/services/GraphTraversal.transitiveReduction.test.js new file mode 100644 index 00000000..9e01737c --- /dev/null +++ b/test/unit/domain/services/GraphTraversal.transitiveReduction.test.js @@ -0,0 +1,153 @@ +/** + * GraphTraversal.transitiveReduction() — minimal edge set preserving reachability. + */ + +import { describe, it, expect } from 'vitest'; +import GraphTraversal from '../../../../src/domain/services/GraphTraversal.js'; +import { + makeAdjacencyProvider, + makeFixture, + F3_DIAMOND_EQUAL_PATHS, + F8_TOPO_CYCLE_3, + F16_TRANSITIVE_REDUCTION, +} from '../../../helpers/fixtureDsl.js'; + +describe('GraphTraversal.transitiveReduction()', () => { + describe('F16 — redundant edge removal', () => { + it('removes redundant A→C edge', async () => { + const provider = makeAdjacencyProvider(F16_TRANSITIVE_REDUCTION); + const engine = new GraphTraversal({ provider }); + const { edges, removed } = await engine.transitiveReduction({ start: 'A' }); + + expect(removed).toBe(1); + // A→B and B→C should remain; A→C removed + expect(edges).toEqual([ + { from: 'A', to: 'B', label: '' }, + { from: 'B', to: 'C', label: '' }, + ]); + }); + }); + + describe('F3 — diamond (no redundant edges)', () => { + it('preserves all edges in diamond', async () => { + const provider = makeAdjacencyProvider(F3_DIAMOND_EQUAL_PATHS); + const engine = new GraphTraversal({ provider }); + const { edges, removed } = await engine.transitiveReduction({ start: 'A' }); + + // A→B, A→C, B→D, C→D — none redundant + expect(removed).toBe(0); + expect(edges).toEqual([ + { from: 'A', to: 'B', label: '' }, + { from: 'A', to: 'C', label: '' }, + { from: 'B', to: 'D', label: '' }, + { from: 'C', to: 'D', label: '' }, + ]); + }); + }); + + describe('chain (no redundant edges)', () => { + it('preserves all edges in linear chain', async () => { + const fixture = makeFixture({ + nodes: ['A', 'B', 'C'], + edges: [ + { from: 'A', to: 'B' }, + { from: 'B', to: 'C' }, + ], + }); + const provider = makeAdjacencyProvider(fixture); + const engine = new GraphTraversal({ provider }); + const { edges, removed } = await engine.transitiveReduction({ start: 'A' }); + + expect(removed).toBe(0); + expect(edges).toEqual([ + { from: 'A', to: 'B', label: '' }, + { from: 'B', to: 'C', label: '' }, + ]); + }); + }); + + describe('multiple redundant edges', () => { + it('removes all transitively implied edges', async () => { + // A→B, A→C, A→D (redundant), B→C, B→D (redundant), C→D + const fixture = makeFixture({ + nodes: ['A', 'B', 'C', 'D'], + edges: [ + { from: 'A', to: 'B' }, + { from: 'A', to: 'C' }, + { from: 'A', to: 'D' }, + { from: 'B', to: 'C' }, + { from: 'B', to: 'D' }, + { from: 'C', to: 'D' }, + ], + }); + const provider = makeAdjacencyProvider(fixture); + const engine = new GraphTraversal({ provider }); + const { edges, removed } = await engine.transitiveReduction({ start: 'A' }); + + expect(removed).toBe(3); // A→C, A→D, B→D + expect(edges).toEqual([ + { from: 'A', to: 'B', label: '' }, + { from: 'B', to: 'C', label: '' }, + { from: 'C', to: 'D', label: '' }, + ]); + }); + }); + + describe('preserves labels', () => { + it('edge labels survive reduction', async () => { + const fixture = makeFixture({ + nodes: ['A', 'B', 'C'], + edges: [ + { from: 'A', to: 'B', label: 'manages' }, + { from: 'B', to: 'C', label: 'owns' }, + { from: 'A', to: 'C', label: 'redundant' }, + ], + }); + const provider = makeAdjacencyProvider(fixture); + const engine = new GraphTraversal({ provider }); + const { edges, removed } = await engine.transitiveReduction({ start: 'A' }); + + expect(removed).toBe(1); + expect(edges).toEqual([ + { from: 'A', to: 'B', label: 'manages' }, + { from: 'B', to: 'C', label: 'owns' }, + ]); + }); + }); + + describe('cycle detection', () => { + it('throws ERR_GRAPH_HAS_CYCLES', async () => { + const provider = makeAdjacencyProvider(F8_TOPO_CYCLE_3); + const engine = new GraphTraversal({ provider }); + + await expect(engine.transitiveReduction({ start: 'A' })).rejects.toThrow( + expect.objectContaining({ + code: 'ERR_GRAPH_HAS_CYCLES', + }), + ); + }); + }); + + describe('INVALID_START', () => { + it('throws when start node does not exist', async () => { + const provider = makeAdjacencyProvider(F16_TRANSITIVE_REDUCTION); + const engine = new GraphTraversal({ provider }); + + await expect(engine.transitiveReduction({ start: 'NOPE' })).rejects.toThrow( + expect.objectContaining({ + code: 'INVALID_START', + }), + ); + }); + }); + + describe('stats', () => { + it('returns traversal stats', async () => { + const provider = makeAdjacencyProvider(F16_TRANSITIVE_REDUCTION); + const engine = new GraphTraversal({ provider }); + const { stats } = await engine.transitiveReduction({ start: 'A' }); + + expect(stats.nodesVisited).toBe(3); + }); + }); +}); diff --git a/test/unit/domain/services/IndexRebuildService.deep.test.js b/test/unit/domain/services/IndexRebuildService.deep.test.js index ac20366c..d806d478 100644 --- a/test/unit/domain/services/IndexRebuildService.deep.test.js +++ b/test/unit/domain/services/IndexRebuildService.deep.test.js @@ -3,7 +3,7 @@ import IndexRebuildService from '../../../../src/domain/services/IndexRebuildSer import GraphNode from '../../../../src/domain/entities/GraphNode.js'; describe('IndexRebuildService Deep DAG Test', () => { - it('handles 10,000 node chain without stack overflow', async () => { + it('handles 10,000 node chain without stack overflow', { timeout: 30000 }, async () => { const CHAIN_LENGTH = 10_000; // Generate a linear chain: node0 <- node1 <- node2 <- ... <- node9999 @@ -60,7 +60,7 @@ describe('IndexRebuildService Deep DAG Test', () => { treeEntries.forEach(/** @param {any} entry */ entry => { expect(entry).toMatch(/^100644 blob blob\d+\t(meta|shards)_.+\.json$/); }); - }, 30000); // 30 second timeout for large test + }); it('handles wide DAG (node with 1000 parents) without issues', async () => { const PARENT_COUNT = 1000; diff --git a/test/unit/domain/services/SyncController.test.js b/test/unit/domain/services/SyncController.test.js index efa74c44..914fcbfe 100644 --- a/test/unit/domain/services/SyncController.test.js +++ b/test/unit/domain/services/SyncController.test.js @@ -9,9 +9,11 @@ const { timeoutMock, retryMock, httpSyncServerMock } = vi.hoisted(() => { return await fn(ac.signal); }); const retryMock = vi.fn(async (/** @type {Function} */ fn) => await fn()); - const httpSyncServerMock = vi.fn().mockImplementation(() => ({ - listen: vi.fn().mockResolvedValue({ close: vi.fn(), url: 'http://127.0.0.1:3000/sync' }), - })); + const httpSyncServerMock = vi.fn().mockImplementation(function () { + return { + listen: vi.fn().mockResolvedValue({ close: vi.fn(), url: 'http://127.0.0.1:3000/sync' }), + }; + }); return { timeoutMock, retryMock, httpSyncServerMock }; }); diff --git a/test/unit/domain/utils/roaring.test.js b/test/unit/domain/utils/roaring.test.js new file mode 100644 index 00000000..2b36fe1a --- /dev/null +++ b/test/unit/domain/utils/roaring.test.js @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** @type {typeof import('../../../../src/domain/utils/roaring.js')} */ +let roaringMod; + +beforeEach(async () => { + vi.resetModules(); + roaringMod = await import('../../../../src/domain/utils/roaring.js'); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('initRoaring', () => { + it('resets nativeAvailability when called with a new module', async () => { + const { initRoaring, getNativeRoaringAvailable } = roaringMod; + + // Probe native availability to cache a value from the real module + const first = getNativeRoaringAvailable(); + expect([true, false, null]).toContain(first); + + // Reinit with a fake module where isNativelyInstalled => false + const fakeMod = { + RoaringBitmap32: Object.assign(function FakeBitmap() {}, { + isNativelyInstalled: () => false, + }), + }; + await initRoaring(fakeMod); + + // After reinit, availability must reflect the NEW module + const second = getNativeRoaringAvailable(); + expect(second).toBe(false); + }); + + it('resets nativeAvailability on fresh load path', async () => { + const { initRoaring, getNativeRoaringAvailable } = roaringMod; + + // First call caches availability + getNativeRoaringAvailable(); + + // Reinit with a native-style module + const nativeMod = { + RoaringBitmap32: Object.assign(function NativeBitmap() {}, { + isNativelyInstalled: () => true, + }), + }; + await initRoaring(nativeMod); + expect(getNativeRoaringAvailable()).toBe(true); + + // Reinit again with WASM-style module + const wasmMod = { + RoaringBitmap32: Object.assign(function WasmBitmap() {}, { + isNativelyInstalled: () => false, + }), + }; + await initRoaring(wasmMod); + expect(getNativeRoaringAvailable()).toBe(false); + }); + + it('unwraps default exports when called with a module', async () => { + const { initRoaring, getRoaringBitmap32 } = roaringMod; + + const innerBitmap = Object.assign(function WrappedBitmap() {}, { + isNativelyInstalled: () => false, + }); + const wrappedMod = /** @type {import('../../../../src/domain/utils/roaring.js').RoaringModule} */ ( + /** @type {unknown} */ ({ + default: { RoaringBitmap32: innerBitmap }, + RoaringBitmap32: undefined, + }) + ); + await initRoaring(wrappedMod); + + // Should have unwrapped to the inner module + expect(getRoaringBitmap32()).toBe(innerBitmap); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 4a697ee0..9fb58f65 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,11 +1,21 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + // Externalize the roaring native module from Vite's transform pipeline. + // roaring contains a .node C++ addon that Vite cannot bundle/transform. + ssr: { + external: ['roaring', 'roaring-wasm'], + }, test: { include: [ '**/*.{test,spec}.?(c|m)[jt]s?(x)', '**/benchmark/*.benchmark.js', ], testTimeout: 60000, // 60s timeout for benchmark tests + server: { + deps: { + external: [/roaring/], + }, + }, }, });