From afe9dc9defad5794c7944ca026f4b10926382b19 Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 12:51:06 -0800
Subject: [PATCH 01/17] feat(api)!: normalize getNodeProps() to return Record
(B100)
BREAKING CHANGE: getNodeProps() now returns Record
instead of Map, aligning with getEdgeProps() which
already returns a plain object.
Migration: replace .get('key') with .key or ['key'],
.has('key') with 'key' in props, .size with Object.keys(props).length.
Updated source (query.methods.js, ObserverView.js, QueryBuilder.js),
type definitions (_wiredMethods.d.ts, index.d.ts, type-surface.m8.json),
15 test files, examples, README, and GUIDE.
---
CHANGELOG.md | 4 +++
README.md | 4 +--
contracts/type-surface.m8.json | 4 +--
docs/GUIDE.md | 4 +--
examples/scripts/explore.js | 2 +-
examples/scripts/setup.js | 6 ++--
index.d.ts | 4 +--
src/domain/services/ObserverView.js | 31 +++++++++----------
src/domain/services/QueryBuilder.js | 26 ++++++++--------
src/domain/warp/_wiredMethods.d.ts | 2 +-
src/domain/warp/query.methods.js | 12 +++----
test/integration/api/edge-cases.test.js | 10 +++---
test/integration/api/fork.test.js | 2 +-
test/integration/api/lifecycle.test.js | 4 +--
test/runtime/deno/lifecycle.test.ts | 2 +-
test/type-check/consumer.ts | 4 +--
test/unit/domain/WarpGraph.edgeProps.test.js | 6 ++--
.../domain/WarpGraph.invalidation.test.js | 2 +-
.../domain/WarpGraph.lazyMaterialize.test.js | 2 +-
.../domain/WarpGraph.noCoordination.test.js | 8 ++---
test/unit/domain/WarpGraph.query.test.js | 12 +++----
.../WarpGraph.writerInvalidation.test.js | 2 +-
.../unit/domain/services/ObserverView.test.js | 22 ++++++-------
23 files changed, 88 insertions(+), 87 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cd5faf3..1e3bf351 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING: `PerformanceClockAdapter` and `GlobalClockAdapter` exports (B140)** — both were deprecated re-exports of `ClockAdapter`. Deleted shim files, removed from `index.js`, `index.d.ts`, and `type-surface.m8.json`. Use `ClockAdapter` directly.
+### Breaking
+
+- **BREAKING: `getNodeProps()` returns `Record` instead of `Map` (B100)** — aligns with `getEdgeProps()` which already returns a plain object. Callers must replace `.get('key')` with `.key` or `['key']`, `.has('key')` with `'key' in props`, and `.size` with `Object.keys(props).length`. `ObserverView.getNodeProps()` follows the same change.
+
### Fixed
- **Test hardening (B130)** — replaced private field access (`_idToShaCache`, `_snapshotState`, `_cachedState`) with behavioral assertions in `BitmapIndexReader.test.js`, `PatchBuilderV2.snapshot.test.js`, and `WarpGraph.timing.test.js`.
diff --git a/README.md b/README.md
index acf89d21..e70f3eed 100644
--- a/README.md
+++ b/README.md
@@ -183,7 +183,7 @@ Query methods auto-materialize by default. Just open a graph and start querying:
```javascript
await graph.getNodes(); // ['user:alice', 'user:bob']
await graph.hasNode('user:alice'); // true
-await graph.getNodeProps('user:alice'); // Map { 'name' => 'Alice', 'role' => 'admin' }
+await graph.getNodeProps('user:alice'); // { name: 'Alice', role: 'admin' }
await graph.neighbors('user:alice', 'outgoing'); // [{ nodeId: 'user:bob', label: 'manages', direction: 'outgoing' }]
await graph.getEdges(); // [{ from: 'user:alice', to: 'user:bob', label: 'manages', props: {} }]
await graph.getEdgeProps('user:alice', 'user:bob', 'manages'); // { weight: 0.9 } or null
@@ -371,7 +371,7 @@ const view = await graph.observer('publicApi', {
});
const users = await view.getNodes(); // only user:* nodes
-const props = await view.getNodeProps('user:alice'); // Map without ssn/password
+const props = await view.getNodeProps('user:alice'); // { name: 'Alice', ... } without ssn/password
const result = await view.query().match('user:*').where({ role: 'admin' }).run();
// Measure information loss between two observer perspectives
diff --git a/contracts/type-surface.m8.json b/contracts/type-surface.m8.json
index 879de404..6dbc327d 100644
--- a/contracts/type-surface.m8.json
+++ b/contracts/type-surface.m8.json
@@ -61,7 +61,7 @@
"getNodeProps": {
"async": true,
"params": [{ "name": "nodeId", "type": "string" }],
- "returns": "Promise
-## What's New in v12.4.1
-
-- **JSDoc total coverage** — eliminated all unsafe `{Object}`, `{Function}`, `{*}` type patterns across 135 files (190+ sites), replacing them with precise inline typed shapes.
-- **Zero tsc errors** — fixed tsconfig split-config includes and type divergences; 0 errors across all three tsconfig targets.
-- **JSR dry-run fix** — worked around a deno_ast 0.52.0 panic caused by overlapping text-change entries for duplicate import specifiers.
-- **`check-dts-surface.js` regex fix** — default-export parsing now correctly captures identifiers instead of keywords for `export default class/function` patterns.
+## What's New in v13.0.0
+
+- **BREAKING: `getNodeProps()` returns `Record`** — aligns with `getEdgeProps()`. Replace `.get('key')` with `.key`, `.has('key')` with `'key' in props`, `.size` with `Object.keys(props).length`.
+- **BREAKING: Removed `PerformanceClockAdapter` and `GlobalClockAdapter`** — use `ClockAdapter` directly.
+- **`graph.patchMany()`** — batch multiple patches sequentially; each callback sees prior state.
+- **`git warp bisect`** — binary search over writer patch history to find the first bad commit. O(log N) materializations.
+- **Observer API stable** — `subscribe()` and `watch()` promoted to stable with `@since 13.0.0`.
+- **`BisectService`** — domain service exported for programmatic use.
See the [full changelog](CHANGELOG.md) for details.
diff --git a/ROADMAP.md b/ROADMAP.md
index bc252f37..6bb90976 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,7 @@
# ROADMAP — @git-stunts/git-warp
-> **Current version:** v12.4.1
-> **Last reconciled:** 2026-03-02 (M14 HYGIENE added from HEX_AUDIT; completed items archived to COMPLETED.md; BACKLOG.md retired)
+> **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)
> **Completed milestones:** [docs/ROADMAP/COMPLETED.md](docs/ROADMAP/COMPLETED.md)
---
@@ -25,11 +25,11 @@
### M10.T4 — Causality Bisect Spec
-- **Status:** `PENDING`
+- **Status:** `DONE` (spec existed; implementation completed in M11)
**Items:**
-- **B2 (spec only)** (CAUSALITY BISECT) — design the bisect CLI contract + data model. Commit spec with test vectors. Full implementation deferred to M11 — but the spec lands here so bisect is available as a debugging tool during M10 trust hardening.
+- **B2 (spec only)** ✅ (CAUSALITY BISECT) — Spec committed at `docs/specs/BISECT_V1.md`. Full implementation shipped in M11/v13.0.0.
**M10 Gate:** Signed ingress enforced end-to-end; trust E2E receipts green; B63 GC isolation verified under concurrent writes; B64 sync payload validation green; B65 divergence logging verified; B2 spec committed with test vectors.
@@ -165,37 +165,38 @@ Design-only items. RFCs filed — implementation deferred to future milestones.
---
-## Milestone 11 — COMPASS II
+## Milestone 11 — COMPASS II ✅ COMPLETE (v13.0.0)
**Theme:** Developer experience
**Objective:** Ship bisect, public observer API, and batch patch ergonomics.
**Triage date:** 2026-02-17
+**Completed:** 2026-03-03
### M11.T1 — Causality Bisect (Implementation)
-- **Status:** `PENDING`
+- **Status:** `DONE`
**Items:**
-- **B2 (implementation)** (CAUSALITY BISECT) — full implementation building on M10 spec. Binary search for first bad tick/invariant failure. `git bisect` for WARP.
+- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 6 test vectors.
### M11.T2 — Observer API
-- **Status:** `PENDING`
+- **Status:** `DONE`
**Items:**
-- **B3** (OBSERVER API) — public event contract. Internal soak period over (shipped in PULSE, used internally since). Stabilize the public surface.
+- **B3** ✅ (OBSERVER API) — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0`. Fixed `onError` type to `unknown`. `watch()` pattern type corrected to `string | string[]`.
### M11.T3 — Batch Patch API
-- **Status:** `PENDING`
+- **Status:** `DONE`
**Items:**
-- **B11** (`graph.patchMany(fns)` BATCH API) — sequence multiple patch callbacks atomically, each seeing the ref left by the previous. Natural complement to `graph.patch()`.
+- **B11** ✅ (`graph.patchMany()` BATCH API) — sequential batch helper. Each callback sees state from prior commit. Returns array of SHAs. Inherits reentrancy guard.
-**M11 Gate:** Bisect correctness verified on seeded regressions; observer contract snapshot-tested; patchMany passes no-coordination suite.
+**M11 Gate:** ✅ All gates met. Bisect correctness verified with 6 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
---
@@ -209,10 +210,10 @@ Items picked up opportunistically without blocking milestones. No milestone assi
| ID | Item |
|----|------|
-| B124 | **TRUST PAYLOAD PARITY TESTS** — assert CLI `trust` and `AuditVerifierService.evaluateTrust()` emit shape-compatible error payloads. From BACKLOG 2026-02-27. |
-| B125 | **`CachedValue` NULL-PAYLOAD SEMANTIC TESTS** — document and test whether `null` is a valid cached value. From BACKLOG 2026-02-27. |
+| ~~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** — event system edge case; known bug class that bites silently |
+| ~~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 |
@@ -229,7 +230,7 @@ Items picked up opportunistically without blocking milestones. No milestone assi
| 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** — `CorePersistence` (`WarpPersistence.js`) and `FullPersistence` (`WarpGraph.js`) are identical `CommitPort & BlobPort & TreePort & RefPort` intersections. Consolidate into one canonical typedef and update all import sites. From B145 PR review. |
+| ~~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
@@ -299,7 +300,7 @@ Items parked with explicit conditions for promotion.
| B20 | **TRUST RECORD ROUND-TRIP SNAPSHOT TEST** | Promote if trust record schema changes |
| B21 | **TRUST SCHEMA DISCRIMINATED UNION** | Promote if superRefine causes a bug or blocks a feature |
| B27 | **`TrustKeyStore` PRE-VALIDATED KEY CACHE** | Promote when `verifySignature` appears in any p95 flame graph above 5% of call time |
-| B100 | **MAP vs RECORD ASYMMETRY** — `getNodeProps()` returns Map, `getEdgeProps()` returns Record. Breaking change either way. From B-FEAT-3. | Promote with next major version RFC |
+| ~~B100~~ | ✅ ~~**MAP vs RECORD ASYMMETRY**~~ — `getNodeProps()` now returns `Record`. Done in v13.0.0. | ~~Promote with next major version RFC~~ |
| B101 | **MERMAID `~~~` INVISIBLE-LINK FRAGILITY** — undocumented Mermaid feature for positioning. From B-DIAG-3. | Promote if Mermaid renderer update breaks `~~~` positioning |
---
diff --git a/jsr.json b/jsr.json
index 42e314b6..1ea840e4 100644
--- a/jsr.json
+++ b/jsr.json
@@ -1,6 +1,6 @@
{
"name": "@git-stunts/git-warp",
- "version": "12.4.1",
+ "version": "13.0.0",
"imports": {
"roaring": "npm:roaring@^2.7.0"
},
diff --git a/package.json b/package.json
index 008d7ebc..95d87017 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@git-stunts/git-warp",
- "version": "12.4.1",
+ "version": "13.0.0",
"description": "Deterministic WARP graph over Git: graph-native storage, traversal, and tooling.",
"type": "module",
"license": "Apache-2.0",
From 1ea6bc4ae44b4344fd7d41cd118fc8ca1639ad77 Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 13:39:41 -0800
Subject: [PATCH 11/17] fix(release): resolve TypeScript and declaration
surface errors for v13.0.0
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix WarpGraphInstance.materialize return type (void → WarpStateV5) in CLI types
- Use structural BisectGraph typedef instead of WarpGraph import in BisectService
- Update index.d.ts BisectService constructor to match structural type
- Add BisectService and BisectResult to type-surface.m8.json manifest
---
bin/cli/types.js | 2 +-
contracts/type-surface.m8.json | 2 ++
index.d.ts | 2 +-
src/domain/services/BisectService.js | 8 ++++-
test/unit/domain/WarpGraph.patchMany.test.js | 16 +++++-----
.../domain/services/BisectService.test.js | 32 +++++++++----------
.../services/TrustPayloadParity.test.js | 7 ++--
7 files changed, 39 insertions(+), 30 deletions(-)
diff --git a/bin/cli/types.js b/bin/cli/types.js
index 7eded63d..54144106 100644
--- a/bin/cli/types.js
+++ b/bin/cli/types.js
@@ -15,7 +15,7 @@
/**
* @typedef {Object} WarpGraphInstance
- * @property {(opts?: {ceiling?: number}) => Promise} materialize
+ * @property {(opts?: {ceiling?: number}) => Promise} materialize
* @property {() => Promise>} getNodes
* @property {() => Promise>} getEdges
* @property {() => Promise} createCheckpoint
diff --git a/contracts/type-surface.m8.json b/contracts/type-surface.m8.json
index 6dbc327d..626a1070 100644
--- a/contracts/type-surface.m8.json
+++ b/contracts/type-surface.m8.json
@@ -412,6 +412,7 @@
"BitmapIndexReader": { "kind": "class" },
"IndexRebuildService": { "kind": "class" },
"HealthCheckService": { "kind": "class" },
+ "BisectService": { "kind": "class" },
"CommitDagTraversalService": { "kind": "class" },
"GraphPersistencePort": { "kind": "abstract-class" },
"IndexStoragePort": { "kind": "abstract-class" },
@@ -546,6 +547,7 @@
"PatchEntry": { "kind": "interface" },
"WarpStateV5": { "kind": "interface" },
"BTR": { "kind": "interface" },
+ "BisectResult": { "kind": "interface" },
"BTRVerificationResult": { "kind": "interface" },
"CreateBTROptions": { "kind": "interface" },
"VerifyBTROptions": { "kind": "interface" },
diff --git a/index.d.ts b/index.d.ts
index 76785b44..42336a56 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -988,7 +988,7 @@ export { CommitDagTraversalService as TraversalService };
* @since 13.0.0
*/
export class BisectService {
- constructor(options: { graph: WarpGraph });
+ constructor(options: { graph: { getWriterPatches: WarpGraph['getWriterPatches']; materialize: WarpGraph['materialize'] } });
/**
* Runs bisect on a single writer's patch chain.
diff --git a/src/domain/services/BisectService.js b/src/domain/services/BisectService.js
index 4e04e69a..ff92cffd 100644
--- a/src/domain/services/BisectService.js
+++ b/src/domain/services/BisectService.js
@@ -67,9 +67,15 @@ function resolveCandidates(patches, good, bad) {
return { candidates };
}
+/**
+ * @typedef {Object} BisectGraph
+ * @property {(writerId: string) => Promise>} getWriterPatches
+ * @property {(opts: {ceiling: number}) => Promise} materialize
+ */
+
export default class BisectService {
/**
- * @param {{ graph: import('../WarpGraph.js').default }} options
+ * @param {{ graph: BisectGraph }} options
*/
constructor({ graph }) {
this._graph = graph;
diff --git a/test/unit/domain/WarpGraph.patchMany.test.js b/test/unit/domain/WarpGraph.patchMany.test.js
index d5440af5..04ba9208 100644
--- a/test/unit/domain/WarpGraph.patchMany.test.js
+++ b/test/unit/domain/WarpGraph.patchMany.test.js
@@ -29,7 +29,7 @@ describe('WarpGraph.patchMany()', () => {
autoMaterialize: true,
});
const shas = await graph.patchMany(
- (p) => p.addNode('n:1').setProperty('n:1', 'k', 'v'),
+ (p) => { p.addNode('n:1').setProperty('n:1', 'k', 'v'); },
);
expect(shas).toHaveLength(1);
expect(typeof shas[0]).toBe('string');
@@ -52,9 +52,9 @@ describe('WarpGraph.patchMany()', () => {
autoMaterialize: true,
});
const shas = await graph.patchMany(
- (p) => p.addNode('n:1').setProperty('n:1', 'role', 'admin'),
- (p) => p.addNode('n:2').setProperty('n:2', 'role', 'user'),
- (p) => p.addEdge('n:1', 'n:2', 'manages'),
+ (p) => { p.addNode('n:1').setProperty('n:1', 'role', 'admin'); },
+ (p) => { p.addNode('n:2').setProperty('n:2', 'role', 'user'); },
+ (p) => { p.addEdge('n:1', 'n:2', 'manages'); },
);
expect(shas).toHaveLength(3);
@@ -82,7 +82,7 @@ describe('WarpGraph.patchMany()', () => {
// First patch creates node, second patch sets a property that depends on it
const shas = await graph.patchMany(
- (p) => p.addNode('n:1').setProperty('n:1', 'step', 1),
+ (p) => { p.addNode('n:1').setProperty('n:1', 'step', 1); },
async (p) => {
// Verify node from first patch is visible
const has = await graph.hasNode('n:1');
@@ -111,9 +111,9 @@ describe('WarpGraph.patchMany()', () => {
await expect(
graph.patchMany(
- (p) => p.addNode('n:1'),
+ (p) => { p.addNode('n:1'); },
() => { throw new Error('deliberate'); },
- (p) => p.addNode('n:3'), // should never run
+ (p) => { p.addNode('n:3'); }, // should never run
),
).rejects.toThrow('deliberate');
@@ -139,7 +139,7 @@ describe('WarpGraph.patchMany()', () => {
graph.patchMany(
async () => {
// Nesting patch() inside patchMany should trigger reentrancy guard
- await graph.patch((p) => p.addNode('sneaky'));
+ await graph.patch((p) => { p.addNode('sneaky'); });
},
),
).rejects.toThrow(/not reentrant/);
diff --git a/test/unit/domain/services/BisectService.test.js b/test/unit/domain/services/BisectService.test.js
index 45df2eda..f86aab9f 100644
--- a/test/unit/domain/services/BisectService.test.js
+++ b/test/unit/domain/services/BisectService.test.js
@@ -17,11 +17,11 @@ describe('BisectService', () => {
// Create 5 patches: A, B, C (introduces 'bug'), D, E
const shas = [];
- shas.push(await graph.patch(p => p.addNode('n:1'))); // A
- shas.push(await graph.patch(p => p.addNode('n:2'))); // B
- shas.push(await graph.patch(p => p.addNode('bug'))); // C — first bad
- shas.push(await graph.patch(p => p.addNode('n:3'))); // D
- shas.push(await graph.patch(p => p.addNode('n:4'))); // E
+ shas.push(await graph.patch(p => { p.addNode('n:1'); })); // A
+ shas.push(await graph.patch(p => { p.addNode('n:2'); })); // B
+ shas.push(await graph.patch(p => { p.addNode('bug'); })); // C — first bad
+ shas.push(await graph.patch(p => { p.addNode('n:3'); })); // D
+ shas.push(await graph.patch(p => { p.addNode('n:4'); })); // E
const bisect = new BisectService({ graph });
const result = await bisect.run({
@@ -54,7 +54,7 @@ describe('BisectService', () => {
autoMaterialize: true,
});
- const sha = await graph.patch(p => p.addNode('n:1'));
+ const sha = await graph.patch(p => { p.addNode('n:1'); });
const bisect = new BisectService({ graph });
const result = await bisect.run({
@@ -81,8 +81,8 @@ describe('BisectService', () => {
autoMaterialize: true,
});
- const shaA = await graph.patch(p => p.addNode('n:1')); // A — good
- const shaB = await graph.patch(p => p.addNode('bug')); // B — bad
+ const shaA = await graph.patch(p => { p.addNode('n:1'); }); // A — good
+ const shaB = await graph.patch(p => { p.addNode('bug'); }); // B — bad
const bisect = new BisectService({ graph });
const result = await bisect.run({
@@ -114,8 +114,8 @@ describe('BisectService', () => {
autoMaterialize: true,
});
- const shaA = await graph.patch(p => p.addNode('n:1'));
- const shaB = await graph.patch(p => p.addNode('n:2'));
+ const shaA = await graph.patch(p => { p.addNode('n:1'); });
+ const shaB = await graph.patch(p => { p.addNode('n:2'); });
const bisect = new BisectService({ graph });
// Reversed: good=B (later), bad=A (earlier)
@@ -143,7 +143,7 @@ describe('BisectService', () => {
autoMaterialize: true,
});
- const sha = await graph.patch(p => p.addNode('n:1'));
+ const sha = await graph.patch(p => { p.addNode('n:1'); });
const fakeSha = 'deadbeef'.repeat(5);
const bisect = new BisectService({ graph });
@@ -172,12 +172,12 @@ describe('BisectService', () => {
});
const shas = [];
- shas.push(await graph.patch(p => p.addNode('n:1'))); // A — good
- shas.push(await graph.patch(p => p.addNode('n:2'))); // B
- shas.push(await graph.patch(p => p.addNode('bug'))); // C — first bad
- shas.push(await graph.patch(p => p.addNode('n:3'))); // D — bad
+ shas.push(await graph.patch(p => { p.addNode('n:1'); })); // A — good
+ shas.push(await graph.patch(p => { p.addNode('n:2'); })); // B
+ shas.push(await graph.patch(p => { p.addNode('bug'); })); // C — first bad
+ shas.push(await graph.patch(p => { p.addNode('n:3'); })); // D — bad
- const observedShas = [];
+ /** @type {string[]} */ const observedShas = [];
const bisect = new BisectService({ graph });
const result = await bisect.run({
diff --git a/test/unit/domain/services/TrustPayloadParity.test.js b/test/unit/domain/services/TrustPayloadParity.test.js
index 05123d49..3447f50a 100644
--- a/test/unit/domain/services/TrustPayloadParity.test.js
+++ b/test/unit/domain/services/TrustPayloadParity.test.js
@@ -372,9 +372,9 @@ describe('TrustPayloadParity — error path', () => {
it('error payload evidenceSummary has all zero counters', () => {
const payload = buildErrorPayload('g', { source: 'ref', sourceDetail: null });
-
+ const summary = /** @type {Record} */ (payload.trust.evidenceSummary);
for (const key of REQUIRED_EVIDENCE_KEYS) {
- expect(payload.trust.evidenceSummary[key]).toBe(0);
+ expect(summary[key]).toBe(0);
}
});
@@ -449,8 +449,9 @@ describe('TrustPayloadParity — not-configured path', () => {
it('not_configured evidenceSummary has all zero counters', () => {
const payload = buildNotConfiguredPayload('g');
+ const summary = /** @type {Record} */ (payload.trust.evidenceSummary);
for (const key of REQUIRED_EVIDENCE_KEYS) {
- expect(payload.trust.evidenceSummary[key]).toBe(0);
+ expect(summary[key]).toBe(0);
}
});
});
From b87b83b1812f2762182b65dd797f7cd882d4a68b Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 14:11:41 -0800
Subject: [PATCH 12/17] fix(bisect): harden CLI validation and remove dead code
(B148)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add --writer validation to bisect CLI handler
- Fix exit code constant (USAGE+1 → NOT_FOUND)
- Remove unreachable candidates guard and unused BisectTestFn typedef
- Add all-bad, testFn-throws, and empty-chain bisect test vectors
- Remove unused imports/constants from TrustPayloadParity test
- Fix stale getNodeProps return type in CHANGELOG
- Archive M11 COMPASS II to COMPLETED.md
---
CHANGELOG.md | 3 +-
ROADMAP.md | 35 +-------
bin/cli/commands/bisect.js | 8 +-
docs/ROADMAP/COMPLETED.md | 36 ++++++++
src/domain/services/BisectService.js | 8 --
.../domain/services/BisectService.test.js | 90 +++++++++++++++++++
.../services/TrustPayloadParity.test.js | 5 +-
7 files changed, 138 insertions(+), 47 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca025ac9..133c9241 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fake timer lifecycle (B131)** — moved `vi.useFakeTimers()` from `beforeAll` to `beforeEach` and `vi.useRealTimers()` into `afterEach` in `WarpGraph.watch.test.js`.
- **Test determinism (B132)** — seeded `Math.random()` in benchmarks with Mulberry32 RNG (`0xDEADBEEF`), added `seed: 42` to all fast-check property tests, replaced random delays in stress test with deterministic values.
- **Global mutation documentation (B133)** — documented intentional `globalThis.Buffer` mutation in `noBufferGlobal.test.js` and `crypto.randomUUID()` usage in `SyncAuthService.test.js`.
+- **Code review fixes (B148)** — removed dead code from BisectService, added `--writer` validation to bisect CLI, fixed exit code constant.
## [12.4.1] — 2026-02-28
@@ -1538,7 +1539,7 @@ Implements [Paper III](https://doi.org/10.5281/zenodo.17963669) (Computational H
#### Query API (V7 Task 7)
- **`graph.hasNode(nodeId)`** - Check if node exists in materialized state
-- **`graph.getNodeProps(nodeId)`** - Get all properties for a node as Map
+- **`graph.getNodeProps(nodeId)`** - Get all properties for a node (returns `Record` since v13.0.0)
- **`graph.neighbors(nodeId, dir?, label?)`** - Get neighbors with direction/label filtering
- **`graph.getNodes()`** - Get all visible node IDs
- **`graph.getEdges()`** - Get all visible edges as `{from, to, label}` array
diff --git a/ROADMAP.md b/ROADMAP.md
index 6bb90976..fe50a947 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -167,36 +167,7 @@ Design-only items. RFCs filed — implementation deferred to future milestones.
## Milestone 11 — COMPASS II ✅ COMPLETE (v13.0.0)
-**Theme:** Developer experience
-**Objective:** Ship bisect, public observer API, and batch patch ergonomics.
-**Triage date:** 2026-02-17
-**Completed:** 2026-03-03
-
-### M11.T1 — Causality Bisect (Implementation)
-
-- **Status:** `DONE`
-
-**Items:**
-
-- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 6 test vectors.
-
-### M11.T2 — Observer API
-
-- **Status:** `DONE`
-
-**Items:**
-
-- **B3** ✅ (OBSERVER API) — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0`. Fixed `onError` type to `unknown`. `watch()` pattern type corrected to `string | string[]`.
-
-### M11.T3 — Batch Patch API
-
-- **Status:** `DONE`
-
-**Items:**
-
-- **B11** ✅ (`graph.patchMany()` BATCH API) — sequential batch helper. Each callback sees state from prior commit. Returns array of SHAs. Inherits reentrancy guard.
-
-**M11 Gate:** ✅ All gates met. Bisect correctness verified with 6 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
+Archived to [COMPLETED.md](docs/ROADMAP/COMPLETED.md#milestone-11--compass-ii).
---
@@ -351,10 +322,10 @@ Pick opportunistically between milestones. Recommended order within tiers:
| **Milestone (M13)** | 1 | B116 (internal: DONE; wire-format: DEFERRED) |
| **Milestone (M14)** | 16 | B130–B145 |
| **Standalone** | 39 | B12, B19, B22, B28, B34–B37, B43, B44, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123–B125, B127–B129, B146, B147 |
-| **Standalone (done)** | 23 | B26, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B120–B122, B126 |
+| **Standalone (done)** | 24 | B26, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B120–B122, B126, B148 |
| **Deferred** | 8 | B4, B7, B16, B20, B21, B27, B100, B101 |
| **Rejected** | 7 | B5, B6, B13, B17, B18, B25, B45 |
-| **Total tracked** | **122** (23 done) | |
+| **Total tracked** | **123** (24 done) | |
### STANK.md Cross-Reference
diff --git a/bin/cli/commands/bisect.js b/bin/cli/commands/bisect.js
index b387774d..49218583 100644
--- a/bin/cli/commands/bisect.js
+++ b/bin/cli/commands/bisect.js
@@ -1,5 +1,5 @@
import { execSync } from 'node:child_process';
-import { EXIT_CODES, parseCommandArgs } from '../infrastructure.js';
+import { EXIT_CODES, parseCommandArgs, usageError } from '../infrastructure.js';
import { bisectSchema } from '../schemas.js';
import { openGraph } from '../shared.js';
import BisectService from '../../../src/domain/services/BisectService.js';
@@ -48,6 +48,10 @@ function runTestCommand(testCmd, sha, graphName) {
* @returns {Promise<{payload: unknown, exitCode: number}>}
*/
export default async function handleBisect({ options, args }) {
+ if (options.writer === 'cli') {
+ throw usageError('bisect requires --writer ');
+ }
+
const { good, bad, test: testCmd } = parseBisectArgs(args);
const { graph, graphName } = await openGraph(options);
const writerId = options.writer;
@@ -64,7 +68,7 @@ export default async function handleBisect({ options, args }) {
if (result.result === 'range-error') {
return {
payload: { error: { code: 'E_BISECT_RANGE', message: result.message } },
- exitCode: EXIT_CODES.USAGE + 1, // exit code 2 per spec
+ exitCode: EXIT_CODES.NOT_FOUND,
};
}
diff --git a/docs/ROADMAP/COMPLETED.md b/docs/ROADMAP/COMPLETED.md
index d509a740..f470163b 100644
--- a/docs/ROADMAP/COMPLETED.md
+++ b/docs/ROADMAP/COMPLETED.md
@@ -29,6 +29,7 @@
| M7 | TRUST V1 | v11.1.0 | Cryptographic Identity-Backed Trust |
| M8 | IRONCLAD | v11.x | Type Safety |
| M9 | PARTITION | v12.0.0 | Architectural Decomposition |
+| M11 | COMPASS II | v13.0.0 | Developer Experience |
---
@@ -290,6 +291,41 @@ Investigation revealed the correct approach is a two-phase split:
---
+## Milestone 11 — COMPASS II
+
+**Theme:** Developer experience
+**Objective:** Ship bisect, public observer API, and batch patch ergonomics.
+**Triage date:** 2026-02-17
+**Completed:** 2026-03-03
+
+### M11.T1 — Causality Bisect (Implementation)
+
+- **Status:** `DONE`
+
+**Items:**
+
+- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 6 test vectors.
+
+### M11.T2 — Observer API
+
+- **Status:** `DONE`
+
+**Items:**
+
+- **B3** ✅ (OBSERVER API) — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0`. Fixed `onError` type to `unknown`. `watch()` pattern type corrected to `string | string[]`.
+
+### M11.T3 — Batch Patch API
+
+- **Status:** `DONE`
+
+**Items:**
+
+- **B11** ✅ (`graph.patchMany()` BATCH API) — sequential batch helper. Each callback sees state from prior commit. Returns array of SHAs. Inherits reentrancy guard.
+
+**M11 Gate:** ✅ All gates met. Bisect correctness verified with 6 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
+
+---
+
## Standalone Lane — Completed Items
### Immediate (all done)
diff --git a/src/domain/services/BisectService.js b/src/domain/services/BisectService.js
index ff92cffd..24cfcdd5 100644
--- a/src/domain/services/BisectService.js
+++ b/src/domain/services/BisectService.js
@@ -19,11 +19,6 @@
* @property {string} [message] - Human-readable error message (only when result === 'range-error')
*/
-/**
- * @typedef {Object} BisectTestFn
- * @property {(state: import('./JoinReducer.js').WarpStateV5, sha: string) => Promise} testFn
- */
-
/**
* Builds a "found" result from a candidate entry.
*
@@ -61,9 +56,6 @@ function resolveCandidates(patches, good, bad) {
}
const candidates = patches.slice(goodIdx + 1, badIdx + 1);
- if (candidates.length === 0) {
- return { error: 'no candidates between good and bad' };
- }
return { candidates };
}
diff --git a/test/unit/domain/services/BisectService.test.js b/test/unit/domain/services/BisectService.test.js
index f86aab9f..f8ffa742 100644
--- a/test/unit/domain/services/BisectService.test.js
+++ b/test/unit/domain/services/BisectService.test.js
@@ -200,4 +200,94 @@ describe('BisectService', () => {
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');
+ try {
+ const graph = await WarpGraph.open({
+ persistence: repo.persistence,
+ graphName: 'test',
+ writerId: 'w1',
+ autoMaterialize: true,
+ });
+
+ const shas = [];
+ shas.push(await graph.patch(p => { p.addNode('n:1'); })); // A — good
+ shas.push(await graph.patch(p => { p.addNode('n:2'); })); // B — bad
+ shas.push(await graph.patch(p => { p.addNode('n:3'); })); // C — bad
+ shas.push(await graph.patch(p => { p.addNode('n:4'); })); // D — bad
+
+ const bisect = new BisectService({ graph });
+ const result = await bisect.run({
+ good: shas[0],
+ bad: shas[3],
+ writerId: 'w1',
+ testFn: async () => false, // every state is "bad"
+ });
+
+ expect(result.result).toBe('found');
+ expect(result.firstBadPatch).toBe(shas[1]); // B — first candidate after good
+ } finally {
+ await repo.cleanup();
+ }
+ }, { timeout: 30000 });
+
+ it('vector 8: testFn throws — promise rejects with same error', async () => {
+ const repo = await createGitRepo('bisect-throws');
+ try {
+ const graph = await WarpGraph.open({
+ persistence: repo.persistence,
+ graphName: 'test',
+ writerId: 'w1',
+ autoMaterialize: true,
+ });
+
+ const shas = [];
+ shas.push(await graph.patch(p => { p.addNode('n:1'); }));
+ shas.push(await graph.patch(p => { p.addNode('n:2'); }));
+ shas.push(await graph.patch(p => { p.addNode('n:3'); }));
+
+ const testError = new Error('test function exploded');
+ const bisect = new BisectService({ graph });
+
+ await expect(bisect.run({
+ good: shas[0],
+ bad: shas[2],
+ writerId: 'w1',
+ testFn: async () => { throw testError; },
+ })).rejects.toThrow(testError);
+ } finally {
+ await repo.cleanup();
+ }
+ }, { timeout: 30000 });
+
+ it('vector 9: empty writer chain — range-error', async () => {
+ const repo = await createGitRepo('bisect-empty-writer');
+ try {
+ const graph = await WarpGraph.open({
+ persistence: repo.persistence,
+ graphName: 'test',
+ writerId: 'w1',
+ autoMaterialize: true,
+ });
+
+ // Write patches as w1
+ const sha1 = await graph.patch(p => { p.addNode('n:1'); });
+ const sha2 = await graph.patch(p => { p.addNode('n:2'); });
+
+ const bisect = new BisectService({ graph });
+ // Bisect on w2 who has no patches — SHAs won't be found in w2's chain
+ const result = await bisect.run({
+ good: sha1,
+ bad: sha2,
+ writerId: 'w2',
+ testFn: async () => true,
+ });
+
+ expect(result.result).toBe('range-error');
+ expect(result.message).toBe('good or bad SHA not found in writer chain');
+ } finally {
+ await repo.cleanup();
+ }
+ }, { timeout: 30000 });
});
diff --git a/test/unit/domain/services/TrustPayloadParity.test.js b/test/unit/domain/services/TrustPayloadParity.test.js
index 3447f50a..81600169 100644
--- a/test/unit/domain/services/TrustPayloadParity.test.js
+++ b/test/unit/domain/services/TrustPayloadParity.test.js
@@ -7,7 +7,7 @@
* fields.
*/
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect } from 'vitest';
import { evaluateWriters } from '../../../../src/domain/trust/TrustEvaluator.js';
import { buildState } from '../../../../src/domain/trust/TrustStateBuilder.js';
import { TrustAssessmentSchema } from '../../../../src/domain/trust/schemas.js';
@@ -29,9 +29,6 @@ const ENFORCE_POLICY = Object.freeze({
/** Top-level keys the CLI trust handler adds beyond the evaluator output. */
const CLI_ENVELOPE_KEYS = ['graph'];
-/** Keys the CLI overrides inside the `trust` object. */
-const CLI_TRUST_OVERRIDES = ['status', 'source', 'sourceDetail'];
-
/**
* All keys that must appear in a CLI trust payload's `trust` object.
* Union of evaluator keys + CLI overrides.
From d7cf956668bd1a4e3339548a5f9623c905db5f62 Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 14:29:08 -0800
Subject: [PATCH 13/17] fix(docs): reconcile inventory counts and fix review
nits (B148)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix ROADMAP.md inventory: move B44/B100/B124/B125/B146 to done (24→29)
- Remove M11 from Early Milestones table in COMPLETED.md (scope mismatch)
- Reorder M11 section in COMPLETED.md to numerical position (after M10)
- Fix stale Deno test name ("via Map" → generic)
- Add candidates-nonempty invariant comment in BisectService
---
ROADMAP.md | 8 ++--
docs/ROADMAP/COMPLETED.md | 72 ++++++++++++++--------------
src/domain/services/BisectService.js | 1 +
test/runtime/deno/lifecycle.test.ts | 2 +-
4 files changed, 42 insertions(+), 41 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index fe50a947..3365c76f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -321,11 +321,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** | 39 | B12, B19, B22, B28, B34–B37, B43, B44, B48, B49, B53, B54, B57, B76, B79–B81, B83, B85–B88, B95–B99, B102–B104, B119, B123–B125, B127–B129, B146, B147 |
-| **Standalone (done)** | 24 | B26, B46, B47, B50–B52, B55, B71, B72, B77, B78, B82, B84, B89–B94, B120–B122, B126, B148 |
-| **Deferred** | 8 | B4, B7, B16, B20, B21, B27, B100, B101 |
+| **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 (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** (24 done) | |
+| **Total tracked** | **123** (29 done) | |
### STANK.md Cross-Reference
diff --git a/docs/ROADMAP/COMPLETED.md b/docs/ROADMAP/COMPLETED.md
index f470163b..12713c26 100644
--- a/docs/ROADMAP/COMPLETED.md
+++ b/docs/ROADMAP/COMPLETED.md
@@ -29,7 +29,7 @@
| M7 | TRUST V1 | v11.1.0 | Cryptographic Identity-Backed Trust |
| M8 | IRONCLAD | v11.x | Type Safety |
| M9 | PARTITION | v12.0.0 | Architectural Decomposition |
-| M11 | COMPASS II | v13.0.0 | Developer Experience |
+
---
@@ -68,6 +68,41 @@
---
+## Milestone 11 — COMPASS II
+
+**Theme:** Developer experience
+**Objective:** Ship bisect, public observer API, and batch patch ergonomics.
+**Triage date:** 2026-02-17
+**Completed:** 2026-03-03
+
+### M11.T1 — Causality Bisect (Implementation)
+
+- **Status:** `DONE`
+
+**Items:**
+
+- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 6 test vectors.
+
+### M11.T2 — Observer API
+
+- **Status:** `DONE`
+
+**Items:**
+
+- **B3** ✅ (OBSERVER API) — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0`. Fixed `onError` type to `unknown`. `watch()` pattern type corrected to `string | string[]`.
+
+### M11.T3 — Batch Patch API
+
+- **Status:** `DONE`
+
+**Items:**
+
+- **B11** ✅ (`graph.patchMany()` BATCH API) — sequential batch helper. Each callback sees state from prior commit. Returns array of SHAs. Inherits reentrancy guard.
+
+**M11 Gate:** ✅ All gates met. Bisect correctness verified with 6 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
+
+---
+
## Milestone 12 — SCALPEL
**Theme:** Comprehensive STANK audit cleanup — correctness, performance & code quality
@@ -291,41 +326,6 @@ Investigation revealed the correct approach is a two-phase split:
---
-## Milestone 11 — COMPASS II
-
-**Theme:** Developer experience
-**Objective:** Ship bisect, public observer API, and batch patch ergonomics.
-**Triage date:** 2026-02-17
-**Completed:** 2026-03-03
-
-### M11.T1 — Causality Bisect (Implementation)
-
-- **Status:** `DONE`
-
-**Items:**
-
-- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 6 test vectors.
-
-### M11.T2 — Observer API
-
-- **Status:** `DONE`
-
-**Items:**
-
-- **B3** ✅ (OBSERVER API) — `subscribe()` and `watch()` promoted to `@stability stable` with `@since 13.0.0`. Fixed `onError` type to `unknown`. `watch()` pattern type corrected to `string | string[]`.
-
-### M11.T3 — Batch Patch API
-
-- **Status:** `DONE`
-
-**Items:**
-
-- **B11** ✅ (`graph.patchMany()` BATCH API) — sequential batch helper. Each callback sees state from prior commit. Returns array of SHAs. Inherits reentrancy guard.
-
-**M11 Gate:** ✅ All gates met. Bisect correctness verified with 6 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
-
----
-
## Standalone Lane — Completed Items
### Immediate (all done)
diff --git a/src/domain/services/BisectService.js b/src/domain/services/BisectService.js
index 24cfcdd5..bd0dd79e 100644
--- a/src/domain/services/BisectService.js
+++ b/src/domain/services/BisectService.js
@@ -55,6 +55,7 @@ function resolveCandidates(patches, good, bad) {
return { error: 'good is not an ancestor of bad' };
}
+ // goodIdx < badIdx guarantees at least one candidate in the slice.
const candidates = patches.slice(goodIdx + 1, badIdx + 1);
return { candidates };
}
diff --git a/test/runtime/deno/lifecycle.test.ts b/test/runtime/deno/lifecycle.test.ts
index b28fb18d..a56e401f 100644
--- a/test/runtime/deno/lifecycle.test.ts
+++ b/test/runtime/deno/lifecycle.test.ts
@@ -36,7 +36,7 @@ Deno.test("lifecycle: creates edges and retrieves them", async () => {
}
});
-Deno.test("lifecycle: node properties via Map", async () => {
+Deno.test("lifecycle: node properties", async () => {
const repo = await createTestRepo("lifecycle-props");
try {
const graph = await repo.openGraph("test", "alice");
From c1d73ad333687eabe9f078d34ba87af24192d379 Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 16:29:08 -0800
Subject: [PATCH 14/17] docs(changelog): add B148 follow-up details
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 133c9241..4bc5bf89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fake timer lifecycle (B131)** — moved `vi.useFakeTimers()` from `beforeAll` to `beforeEach` and `vi.useRealTimers()` into `afterEach` in `WarpGraph.watch.test.js`.
- **Test determinism (B132)** — seeded `Math.random()` in benchmarks with Mulberry32 RNG (`0xDEADBEEF`), added `seed: 42` to all fast-check property tests, replaced random delays in stress test with deterministic values.
- **Global mutation documentation (B133)** — documented intentional `globalThis.Buffer` mutation in `noBufferGlobal.test.js` and `crypto.randomUUID()` usage in `SyncAuthService.test.js`.
-- **Code review fixes (B148)** — removed dead code from BisectService, added `--writer` validation to bisect CLI, fixed exit code constant.
+- **Code review fixes (B148)** — removed dead code from BisectService, added `--writer` validation to bisect CLI, fixed exit code constant. Follow-up: reconciled ROADMAP inventory counts (24→29 done), fixed M11 placement in COMPLETED.md, corrected stale Deno test name, added invariant comment in BisectService.
## [12.4.1] — 2026-02-28
From 367efdf86eeb71c01adf741c0754da7812c79599 Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 17:24:08 -0800
Subject: [PATCH 15/17] fix(docs,types): address code review round 2 findings
(B148)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Move B100 from non-standard ### Breaking to ### Changed (CHANGELOG)
- Remove done items B44/B124/B125 from priority sequence (ROADMAP)
- Fix stale "6 test vectors" → "9 test vectors" in M11 gate (COMPLETED)
- Replace BisectResult interface with discriminated union type (index.d.ts)
- Add SHA format validation to bisect --good/--bad (schemas.js)
- Remove spurious double-blank in COMPLETED.md
- Add bisect schema unit tests for SHA validation
---
CHANGELOG.md | 7 ++----
ROADMAP.md | 4 +--
bin/cli/schemas.js | 4 +--
contracts/type-surface.m8.json | 2 +-
docs/ROADMAP/COMPLETED.md | 5 ++--
index.d.ts | 16 ++++++------
src/domain/services/BisectService.js | 22 +++++++++++------
test/unit/cli/schemas.test.js | 37 ++++++++++++++++++++++++++++
8 files changed, 68 insertions(+), 29 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4bc5bf89..6b65e720 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **BREAKING: `getNodeProps()` returns `Record` instead of `Map` (B100)** — aligns with `getEdgeProps()` which already returns a plain object. Callers must replace `.get('key')` with `.key` or `['key']`, `.has('key')` with `'key' in props`, and `.size` with `Object.keys(props).length`. `ObserverView.getNodeProps()` follows the same change.
- **GraphPersistencePort narrowing (B145)** — domain services now declare focused port intersections (`CommitPort & BlobPort`, etc.) in JSDoc instead of the 23-method composite `GraphPersistencePort`. Removed `ConfigPort` from the composite (23 → 21 methods); adapters still implement `configGet`/`configSet` on their prototypes. Zero behavioral change.
- **Codec trailer validation extraction (B134, B138)** — created `TrailerValidation.js` with `requireTrailer()`, `parsePositiveIntTrailer()`, `validateKindDiscriminator()`. All 4 message codec decoders now use shared helpers exclusively. Patch and Checkpoint decoders now also perform semantic field validation (graph name, writer ID, OID, SHA-256) matching the Audit decoder pattern. Internal refactor for valid inputs, with stricter rejection of malformed messages.
- **HTTP adapter shared utilities (B135)** — created `httpAdapterUtils.js` with `MAX_BODY_BYTES`, `readStreamBody()`, `noopLogger`. Eliminates duplication across Node/Bun/Deno HTTP adapters. Internal refactor, no behavioral change.
@@ -25,17 +26,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING: `PerformanceClockAdapter` and `GlobalClockAdapter` exports (B140)** — both were deprecated re-exports of `ClockAdapter`. Deleted shim files, removed from `index.js`, `index.d.ts`, and `type-surface.m8.json`. Use `ClockAdapter` directly.
-### Breaking
-
-- **BREAKING: `getNodeProps()` returns `Record` instead of `Map` (B100)** — aligns with `getEdgeProps()` which already returns a plain object. Callers must replace `.get('key')` with `.key` or `['key']`, `.has('key')` with `'key' in props`, and `.size` with `Object.keys(props).length`. `ObserverView.getNodeProps()` follows the same change.
-
### Fixed
- **Test hardening (B130)** — replaced private field access (`_idToShaCache`, `_snapshotState`, `_cachedState`) with behavioral assertions in `BitmapIndexReader.test.js`, `PatchBuilderV2.snapshot.test.js`, and `WarpGraph.timing.test.js`.
- **Fake timer lifecycle (B131)** — moved `vi.useFakeTimers()` from `beforeAll` to `beforeEach` and `vi.useRealTimers()` into `afterEach` in `WarpGraph.watch.test.js`.
- **Test determinism (B132)** — seeded `Math.random()` in benchmarks with Mulberry32 RNG (`0xDEADBEEF`), added `seed: 42` to all fast-check property tests, replaced random delays in stress test with deterministic values.
- **Global mutation documentation (B133)** — documented intentional `globalThis.Buffer` mutation in `noBufferGlobal.test.js` and `crypto.randomUUID()` usage in `SyncAuthService.test.js`.
-- **Code review fixes (B148)** — removed dead code from BisectService, added `--writer` validation to bisect CLI, fixed exit code constant. Follow-up: reconciled ROADMAP inventory counts (24→29 done), fixed M11 placement in COMPLETED.md, corrected stale Deno test name, added invariant comment in BisectService.
+- **Code review fixes (B148)** — removed dead code from BisectService, added `--writer` validation to bisect CLI, fixed exit code constant. Follow-up: reconciled ROADMAP inventory counts (24→29 done), fixed M11 placement in COMPLETED.md, corrected stale Deno test name, added invariant comment in BisectService. Round 2: moved B100 from `### Breaking` to `### Changed` in CHANGELOG, removed done items from ROADMAP priority tiers, fixed stale test vector counts (6→9), replaced `BisectResult` interface with discriminated union type, added SHA format validation to bisect CLI schema.
## [12.4.1] — 2026-02-28
diff --git a/ROADMAP.md b/ROADMAP.md
index 3365c76f..29dd23b8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -297,8 +297,8 @@ B5, B6, B13, B17, B18, B25, B45 — rejected 2026-02-17 with cause recorded in `
Pick opportunistically between milestones. Recommended order within tiers:
1. ~~**Immediate** (B46, B47, B26, B71, B126)~~ — **ALL DONE.**
-2. **Near-term correctness** (B44, B76, B80, B81, B124) — prioritize items touching core services
-3. **Near-term DX** (B36, B37, B43, B125, B127) — test ergonomics and developer velocity
+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
diff --git a/bin/cli/schemas.js b/bin/cli/schemas.js
index 1a242214..773788db 100644
--- a/bin/cli/schemas.js
+++ b/bin/cli/schemas.js
@@ -181,8 +181,8 @@ export const seekSchema = z.object({
// ============================================================================
export const bisectSchema = z.object({
- good: z.string().min(1, 'Missing value for --good'),
- bad: z.string().min(1, 'Missing value for --bad'),
+ good: z.string().min(1, 'Missing value for --good').regex(/^[0-9a-f]{40}$/, 'Must be a full 40-character hex SHA'),
+ bad: z.string().min(1, 'Missing value for --bad').regex(/^[0-9a-f]{40}$/, 'Must be a full 40-character hex SHA'),
test: z.string().min(1, 'Missing value for --test'),
}).strict();
diff --git a/contracts/type-surface.m8.json b/contracts/type-surface.m8.json
index 626a1070..eb89c7b4 100644
--- a/contracts/type-surface.m8.json
+++ b/contracts/type-surface.m8.json
@@ -547,7 +547,7 @@
"PatchEntry": { "kind": "interface" },
"WarpStateV5": { "kind": "interface" },
"BTR": { "kind": "interface" },
- "BisectResult": { "kind": "interface" },
+ "BisectResult": { "kind": "type" },
"BTRVerificationResult": { "kind": "interface" },
"CreateBTROptions": { "kind": "interface" },
"VerifyBTROptions": { "kind": "interface" },
diff --git a/docs/ROADMAP/COMPLETED.md b/docs/ROADMAP/COMPLETED.md
index 12713c26..6078ded2 100644
--- a/docs/ROADMAP/COMPLETED.md
+++ b/docs/ROADMAP/COMPLETED.md
@@ -30,7 +30,6 @@
| M8 | IRONCLAD | v11.x | Type Safety |
| M9 | PARTITION | v12.0.0 | Architectural Decomposition |
-
---
## Milestone 10 — SENTINEL (completed tasks)
@@ -81,7 +80,7 @@
**Items:**
-- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 6 test vectors.
+- **B2** ✅ (CAUSALITY BISECT) — `BisectService` + `git warp bisect` CLI. Binary search over writer patch chain. O(log N) materializations. 9 test vectors.
### M11.T2 — Observer API
@@ -99,7 +98,7 @@
- **B11** ✅ (`graph.patchMany()` BATCH API) — sequential batch helper. Each callback sees state from prior commit. Returns array of SHAs. Inherits reentrancy guard.
-**M11 Gate:** ✅ All gates met. Bisect correctness verified with 6 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
+**M11 Gate:** ✅ All gates met. Bisect correctness verified with 9 test vectors. Observer API stable with JSDoc annotations. patchMany tested with 6 scenarios including reentrancy guard.
---
diff --git a/index.d.ts b/index.d.ts
index 42336a56..b2f630cd 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -1003,16 +1003,14 @@ export class BisectService {
/**
* Result of a bisect operation.
+ *
+ * Discriminated union on `result`:
+ * - `'found'`: the first bad patch was identified.
+ * - `'range-error'`: the good/bad range was invalid (e.g., SHAs not found, same SHA, not ancestor).
*/
-export interface BisectResult {
- result: 'found' | 'range-error';
- firstBadPatch?: string;
- writerId?: string;
- lamport?: number;
- steps?: number;
- totalCandidates?: number;
- message?: string;
-}
+export type BisectResult =
+ | { result: 'found'; firstBadPatch: string; writerId: string; lamport: number; steps: number; totalCandidates: number }
+ | { result: 'range-error'; message: string };
/**
* Error class for graph traversal operations.
diff --git a/src/domain/services/BisectService.js b/src/domain/services/BisectService.js
index bd0dd79e..00dfddce 100644
--- a/src/domain/services/BisectService.js
+++ b/src/domain/services/BisectService.js
@@ -9,14 +9,22 @@
*/
/**
+ * Result of a bisect operation.
+ *
+ * Discriminated union on `result`:
+ * - `'found'`: firstBadPatch, writerId, lamport, steps, totalCandidates are present.
+ * - `'range-error'`: message is present.
+ *
+ * See `index.d.ts` for the canonical discriminated-union type.
+ *
* @typedef {Object} BisectResult
- * @property {'found'|'range-error'} result
- * @property {string} [firstBadPatch] - SHA of first bad patch (only when result === 'found')
- * @property {string} [writerId] - Writer who authored the bad patch
- * @property {number} [lamport] - Lamport tick of the bad patch
- * @property {number} [steps] - Number of bisect steps performed
- * @property {number} [totalCandidates] - Initial candidate count
- * @property {string} [message] - Human-readable error message (only when result === 'range-error')
+ * @property {'found'|'range-error'} result - Discriminant tag
+ * @property {string} [firstBadPatch] - SHA of first bad patch (when result === 'found')
+ * @property {string} [writerId] - Writer who authored the bad patch (when result === 'found')
+ * @property {number} [lamport] - Lamport tick of the bad patch (when result === 'found')
+ * @property {number} [steps] - Number of bisect steps performed (when result === 'found')
+ * @property {number} [totalCandidates] - Initial candidate count (when result === 'found')
+ * @property {string} [message] - Human-readable error message (when result === 'range-error')
*/
/**
diff --git a/test/unit/cli/schemas.test.js b/test/unit/cli/schemas.test.js
index 9badcff9..dc586a94 100644
--- a/test/unit/cli/schemas.test.js
+++ b/test/unit/cli/schemas.test.js
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
+ bisectSchema,
doctorSchema,
historySchema,
installHooksSchema,
@@ -10,6 +11,42 @@ import {
seekSchema,
} from '../../../bin/cli/schemas.js';
+describe('bisectSchema', () => {
+ const VALID_SHA = 'a'.repeat(40);
+ const VALID_SHA_2 = 'b'.repeat(40);
+
+ it('accepts valid 40-char hex SHAs', () => {
+ const result = bisectSchema.parse({ good: VALID_SHA, bad: VALID_SHA_2, test: 'exit 0' });
+ expect(result.good).toBe(VALID_SHA);
+ expect(result.bad).toBe(VALID_SHA_2);
+ expect(result.test).toBe('exit 0');
+ });
+
+ it('rejects short SHA for --good', () => {
+ expect(() => bisectSchema.parse({ good: 'abc123', bad: VALID_SHA_2, test: 'exit 0' })).toThrow(/40-character hex SHA/);
+ });
+
+ it('rejects short SHA for --bad', () => {
+ expect(() => bisectSchema.parse({ good: VALID_SHA, bad: 'abc123', test: 'exit 0' })).toThrow(/40-character hex SHA/);
+ });
+
+ it('rejects uppercase hex', () => {
+ expect(() => bisectSchema.parse({ good: 'A'.repeat(40), bad: VALID_SHA_2, test: 'exit 0' })).toThrow(/40-character hex SHA/);
+ });
+
+ it('rejects empty --good', () => {
+ expect(() => bisectSchema.parse({ good: '', bad: VALID_SHA_2, test: 'exit 0' })).toThrow();
+ });
+
+ it('rejects empty --test', () => {
+ expect(() => bisectSchema.parse({ good: VALID_SHA, bad: VALID_SHA_2, test: '' })).toThrow();
+ });
+
+ it('rejects unknown keys', () => {
+ expect(() => bisectSchema.parse({ good: VALID_SHA, bad: VALID_SHA_2, test: 'exit 0', unknown: true })).toThrow();
+ });
+});
+
describe('doctorSchema', () => {
it('defaults strict to false', () => {
const result = doctorSchema.parse({});
From 1b3cfccc7fbb09ef5fdd5d315456f0d127fea7f2 Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 18:04:44 -0800
Subject: [PATCH 16/17] fix(security,cli,docs): address CodeRabbit review
feedback (B148)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rethrow ENOENT/EACCES from bisect test command runner instead of
swallowing all execSync errors (bin/cli/commands/bisect.js)
- Use Object.create(null) for property bags in getNodeProps, getEdgeProps,
getEdges, and buildPropsSnapshot to prevent prototype pollution
(src/domain/warp/query.methods.js, src/domain/services/QueryBuilder.js)
- Fix indexed-path null masking in getNodeProps — fall through to linear
scan when reader returns null instead of returning empty object
- Rename propsMap → propsRecord in QueryBuilder for consistency with
Record type
- Reconcile M11 completion status with downstream sequencing text in
ROADMAP.md (3 locations still said "after M14")
- Split dense B148 CHANGELOG bullet into themed sub-bullets
---
CHANGELOG.md | 6 +++++-
ROADMAP.md | 8 ++++----
bin/cli/commands/bisect.js | 10 ++++++++--
src/domain/services/QueryBuilder.js | 16 ++++++++--------
src/domain/warp/query.methods.js | 13 ++++++++-----
5 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b65e720..38b26327 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,7 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Fake timer lifecycle (B131)** — moved `vi.useFakeTimers()` from `beforeAll` to `beforeEach` and `vi.useRealTimers()` into `afterEach` in `WarpGraph.watch.test.js`.
- **Test determinism (B132)** — seeded `Math.random()` in benchmarks with Mulberry32 RNG (`0xDEADBEEF`), added `seed: 42` to all fast-check property tests, replaced random delays in stress test with deterministic values.
- **Global mutation documentation (B133)** — documented intentional `globalThis.Buffer` mutation in `noBufferGlobal.test.js` and `crypto.randomUUID()` usage in `SyncAuthService.test.js`.
-- **Code review fixes (B148)** — removed dead code from BisectService, added `--writer` validation to bisect CLI, fixed exit code constant. Follow-up: reconciled ROADMAP inventory counts (24→29 done), fixed M11 placement in COMPLETED.md, corrected stale Deno test name, added invariant comment in BisectService. Round 2: moved B100 from `### Breaking` to `### Changed` in CHANGELOG, removed done items from ROADMAP priority tiers, fixed stale test vector counts (6→9), replaced `BisectResult` interface with discriminated union type, added SHA format validation to bisect CLI schema.
+- **Code review fixes (B148):**
+ - **CLI hardening** — added `--writer` validation to bisect, SHA format regex on `--good`/`--bad`, rethrow ENOENT/EACCES from test command runner instead of swallowing.
+ - **BisectService cleanup** — removed dead code, added invariant comment, replaced `BisectResult` interface with discriminated union type, fixed exit code constant.
+ - **Prototype-pollution hardening** — `Object.create(null)` for property bags in `getNodeProps`, `getEdgeProps`, `getEdges`, `buildPropsSnapshot`; fixed indexed-path null masking in `getNodeProps`.
+ - **Docs housekeeping** — reconciled ROADMAP inventory counts (24→29 done), fixed M11 sequencing, removed done items from priority tiers, fixed stale test vector counts (6→9), corrected Deno test name, moved B100 to `### Changed`.
## [12.4.1] — 2026-02-28
diff --git a/ROADMAP.md b/ROADMAP.md
index 29dd23b8..5f5069d7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -284,13 +284,13 @@ B5, B6, B13, B17, B18, B25, B45 — rejected 2026-02-17 with cause recorded in `
## Execution Order
-### Milestones: M10 → M12 → M13 → M14 → M11
+### Milestones: M10 → M12 → M13 → M11 → M14
1. **M10 SENTINEL** — Trust + sync safety + correctness — DONE except B2 spec
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. **M14 HYGIENE** — Test quality, DRY extraction, SOLID quick-wins — **NEXT** (from HEX_AUDIT)
-5. **M11 COMPASS II** — Developer experience (B2 impl, B3, B11) — after M14
+5. **M11 COMPASS II** — Developer experience (B2 impl, B3, B11) — ✅ **DONE** (v13.0.0), archived
### Standalone Priority Sequence
@@ -427,11 +427,11 @@ 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** → **M14 HYGIENE** → M11 COMPASS II. Standalone items fill the gaps.
+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.
-M14 HYGIENE is the current priority — test hardening, DRY extraction, and SOLID quick-wins from the HEX_AUDIT. M11 follows after M14.
+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.
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/bin/cli/commands/bisect.js b/bin/cli/commands/bisect.js
index 49218583..17cf7179 100644
--- a/bin/cli/commands/bisect.js
+++ b/bin/cli/commands/bisect.js
@@ -37,8 +37,14 @@ function runTestCommand(testCmd, sha, graphName) {
},
});
return true;
- } catch {
- return false;
+ } catch (/** @type {unknown} */ err) {
+ // Non-zero exit (err.status is a number) → test says "bad"
+ const asRecord = /** @type {Record} */ (err);
+ if (err && typeof asRecord.status === 'number') {
+ return false;
+ }
+ // Spawn failure (ENOENT, EACCES, etc.) → rethrow so the user sees the real error
+ throw err;
}
}
diff --git a/src/domain/services/QueryBuilder.js b/src/domain/services/QueryBuilder.js
index 64e2720d..68805dfe 100644
--- a/src/domain/services/QueryBuilder.js
+++ b/src/domain/services/QueryBuilder.js
@@ -266,7 +266,7 @@ function cloneValue(value) {
*/
function buildPropsSnapshot(propsRecord) {
/** @type {Record} */
- const props = {};
+ const props = Object.create(null);
const keys = Object.keys(propsRecord).sort();
for (const key of keys) {
props[key] = cloneValue(propsRecord[key]);
@@ -307,12 +307,12 @@ function buildEdgesSnapshot(edges, directionKey) {
* The snapshot includes the node's ID, properties, outgoing edges, and incoming edges.
* All data is deeply frozen to prevent mutation.
*
- * @param {{ id: string, propsMap: Record, edgesOut: Array<{label: string, neighborId: string}>, edgesIn: Array<{label: string, neighborId: string}> }} params - Node data
+ * @param {{ id: string, propsRecord: Record, edgesOut: Array<{label: string, neighborId: string}>, edgesIn: Array<{label: string, neighborId: string}> }} params - Node data
* @returns {Readonly} Frozen node snapshot
* @private
*/
-function createNodeSnapshot({ id, propsMap, edgesOut, edgesIn }) {
- const props = buildPropsSnapshot(propsMap);
+function createNodeSnapshot({ id, propsRecord, edgesOut, edgesIn }) {
+ const props = buildPropsSnapshot(propsRecord);
const edgesOutSnapshot = buildEdgesSnapshot(edgesOut, 'to');
const edgesInSnapshot = buildEdgesSnapshot(edgesIn, 'from');
@@ -683,12 +683,12 @@ export default class QueryBuilder {
for (const op of this._operations) {
if (op.type === 'where') {
const snapshots = await batchMap(workingSet, async (nodeId) => {
- const propsMap = await getProps(nodeId);
+ const propsRecord = await getProps(nodeId);
const edgesOut = adjacency.outgoing.get(nodeId) || [];
const edgesIn = adjacency.incoming.get(nodeId) || [];
return {
nodeId,
- snapshot: createNodeSnapshot({ id: nodeId, propsMap, edgesOut, edgesIn }),
+ snapshot: createNodeSnapshot({ id: nodeId, propsRecord, edgesOut, edgesIn }),
};
});
const predicate = /** @type {(node: QueryNodeSnapshot) => boolean} */ (op.fn);
@@ -747,8 +747,8 @@ export default class QueryBuilder {
entry.id = nodeId;
}
if (includeProps) {
- const propsMap = await getProps(nodeId);
- const props = buildPropsSnapshot(propsMap);
+ const propsRecord = await getProps(nodeId);
+ const props = buildPropsSnapshot(propsRecord);
if (selectFields || Object.keys(props).length > 0) {
entry.props = props;
}
diff --git a/src/domain/warp/query.methods.js b/src/domain/warp/query.methods.js
index e97d46a7..77dea119 100644
--- a/src/domain/warp/query.methods.js
+++ b/src/domain/warp/query.methods.js
@@ -47,7 +47,10 @@ export async function getNodeProps(nodeId) {
if (this._propertyReader && this._logicalIndex?.isAlive(nodeId)) {
try {
const record = await this._propertyReader.getNodeProps(nodeId);
- return record || {};
+ if (record !== null) {
+ return record;
+ }
+ // null → index has no data for this node; fall through to linear scan
} catch {
// Fall through to linear scan on index read failures.
}
@@ -61,7 +64,7 @@ export async function getNodeProps(nodeId) {
}
/** @type {Record} */
- const props = {};
+ const props = Object.create(null);
for (const [propKey, register] of s.prop) {
const decoded = decodePropKey(propKey);
if (decoded.nodeId === nodeId) {
@@ -99,7 +102,7 @@ export async function getEdgeProps(from, to, label) {
const birthEvent = s.edgeBirthEvent?.get(edgeKey);
/** @type {Record} */
- const props = {};
+ const props = Object.create(null);
for (const [propKey, register] of s.prop) {
if (!isEdgePropKey(propKey)) {
continue;
@@ -266,7 +269,7 @@ export async function getEdges() {
let bag = edgePropsByKey.get(ek);
if (!bag) {
- bag = {};
+ bag = Object.create(null);
edgePropsByKey.set(ek, bag);
}
bag[decoded.propKey] = register.value;
@@ -277,7 +280,7 @@ export async function getEdges() {
const { from, to, label } = decodeEdgeKey(edgeKey);
if (orsetContains(s.nodeAlive, from) &&
orsetContains(s.nodeAlive, to)) {
- const props = edgePropsByKey.get(edgeKey) || {};
+ const props = edgePropsByKey.get(edgeKey) || Object.create(null);
edges.push({ from, to, label, props });
}
}
From 65f0f56d55b53095d4b21a29d54e7b36078a325e Mon Sep 17 00:00:00 2001
From: James Ross
Date: Tue, 3 Mar 2026 18:12:36 -0800
Subject: [PATCH 17/17] fix(docs,security): address CodeRabbit round 4 nits
(B148)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Reorder milestone list so M11 (#4) precedes M14 (#5), matching the
header sequence M10 → M12 → M13 → M11 → M14
- Clarify inventory total: "123 (29 done)" → "123 total; 29 standalone done"
- Use Object.create(null) for memoized props fallback in QueryBuilder
to maintain null-prototype consistency
---
ROADMAP.md | 8 ++++----
src/domain/services/QueryBuilder.js | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index 5f5069d7..b8872024 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -286,11 +286,11 @@ B5, B6, B13, B17, B18, B25, B45 — rejected 2026-02-17 with cause recorded in `
### Milestones: M10 → M12 → M13 → M11 → M14
-1. **M10 SENTINEL** — Trust + sync safety + correctness — DONE except B2 spec
+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. **M14 HYGIENE** — Test quality, DRY extraction, SOLID quick-wins — **NEXT** (from HEX_AUDIT)
-5. **M11 COMPASS II** — Developer experience (B2 impl, B3, B11) — ✅ **DONE** (v13.0.0), archived
+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
@@ -325,7 +325,7 @@ Pick opportunistically between milestones. Recommended order within tiers:
| **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** (29 done) | |
+| **Total tracked** | **123** total; 29 standalone done | |
### STANK.md Cross-Reference
diff --git a/src/domain/services/QueryBuilder.js b/src/domain/services/QueryBuilder.js
index 68805dfe..244656ce 100644
--- a/src/domain/services/QueryBuilder.js
+++ b/src/domain/services/QueryBuilder.js
@@ -672,7 +672,7 @@ export default class QueryBuilder {
if (cached !== undefined) {
return cached;
}
- const propsRecord = (await this._graph.getNodeProps(nodeId)) || {};
+ const propsRecord = (await this._graph.getNodeProps(nodeId)) || Object.create(null);
propsMemo.set(nodeId, propsRecord);
return propsRecord;
};