From 4b1127dd17b21d2f079d6b93763250acf7485386 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 11:38:25 -0700 Subject: [PATCH 01/10] fix: throw on missing content blob oids --- CHANGELOG.md | 1 + README.md | 4 +- ROADMAP.md | 4 +- docs/specs/CONTENT_ATTACHMENT.md | 5 +- .../adapters/GitGraphAdapter.js | 32 ++++++++++++ .../api/content-attachment.test.js | 20 ++++++++ .../domain/services/GitGraphAdapter.test.js | 49 +++++++++++++++++++ 7 files changed, 110 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466d1c2a..a0978edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Missing content blob OIDs now throw instead of reading as empty bytes** — `GitGraphAdapter.readBlob()` now disambiguates real zero-byte blobs from swallowed missing-object reads by checking object existence when a blob stream collects to zero bytes. Corrupted `_content` / edge-content references now surface `PersistenceError(E_MISSING_OBJECT)` through `getContent()` / `getEdgeContent()` instead of returning a truthy empty buffer. - **Deno CI resolver drift** — The Deno test image now imports a Node 22 npm toolchain from `node:22-slim`, installs dependencies with `npm ci`, and runs tests with `--node-modules-dir=manual`, avoiding runtime npm re-resolution of `cbor-extract` optional platform packages while keeping the container on the repo’s supported Node engine line. - **Markdown code-sample linter edge cases** — The Markdown JS/TS sample linter now recognizes fenced code blocks indented by up to three spaces, rejects malformed mixed-marker fences, fails on unterminated JS/TS fences, and parses snippets with the repository’s configured TypeScript target from `tsconfig.base.json`. - **B87 review follow-ups** — Clarified the ADR folds snippet as a wholly proposed `graph.view()` sketch, corrected the pre-push quick-mode gate label to Gate 8, aligned the local hook’s gate numbers with CI for faster failure triage, and removed the self-expiring `pending merge` wording from the completed-roadmap archive entry. diff --git a/README.md b/README.md index c0de9771..f5bdc2d7 100644 --- a/README.md +++ b/README.md @@ -462,7 +462,7 @@ await patch.attachContent('adr:0007', '# ADR 0007\n\nDecision text...'); // asyn await patch.commit(); // Read content back -const buffer = await graph.getContent('adr:0007'); // Buffer | null +const buffer = await graph.getContent('adr:0007'); // Uint8Array | null const oid = await graph.getContentOid('adr:0007'); // hex SHA or null // Edge content works the same way (assumes nodes and edge already exist) @@ -472,7 +472,7 @@ await patch2.commit(); const edgeBuf = await graph.getEdgeContent('a', 'b', 'rel'); ``` -Content blobs survive `git gc` — their OIDs are embedded in the patch commit tree and checkpoint tree, keeping them reachable. +Content blobs survive `git gc` — their OIDs are embedded in the patch commit tree and checkpoint tree, keeping them reachable. If a live `_content` reference points at a missing blob anyway (for example due to manual corruption), `getContent()` / `getEdgeContent()` throw instead of silently returning empty bytes. ### Writer API diff --git a/ROADMAP.md b/ROADMAP.md index d8ff691e..194d277f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -173,7 +173,9 @@ Archived to [COMPLETED.md](docs/ROADMAP/COMPLETED.md#milestone-11--compass-ii). ## Standalone Lane (Ongoing) -32 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. +26 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. + +Small GitHub issues may also land as issue-sourced slices without a numbered `B` item when the GitHub issue itself is the backlog record. In those cases, `CHANGELOG.md` and the issue state are the canonical progress markers until the work merges. > Completed standalone items archived in [COMPLETED.md](docs/ROADMAP/COMPLETED.md#standalone-lane--completed-items). diff --git a/docs/specs/CONTENT_ATTACHMENT.md b/docs/specs/CONTENT_ATTACHMENT.md index e400f6b8..ceb24899 100644 --- a/docs/specs/CONTENT_ATTACHMENT.md +++ b/docs/specs/CONTENT_ATTACHMENT.md @@ -104,7 +104,7 @@ Both methods are async (they call `writeBlob()` internally) and return the build #### Read API (WarpGraph) ```javascript -const buffer = await graph.getContent('adr:0007'); // Buffer | null +const buffer = await graph.getContent('adr:0007'); // Uint8Array | null const oid = await graph.getContentOid('adr:0007'); // string | null // Edge content @@ -112,7 +112,8 @@ const edgeBuf = await graph.getEdgeContent('a', 'b', 'rel'); const edgeOid = await graph.getEdgeContentOid('a', 'b', 'rel'); ``` -`getContent()` returns a raw `Buffer`. Consumers wanting text call `.toString('utf8')`. +`getContent()` returns raw `Uint8Array` bytes. Consumers wanting text should decode with `new TextDecoder().decode(buffer)`. +If `_content` points at a missing blob OID, `getContent()` throws instead of silently returning empty bytes. #### Constant diff --git a/src/infrastructure/adapters/GitGraphAdapter.js b/src/infrastructure/adapters/GitGraphAdapter.js index d05c771c..09823bce 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.js +++ b/src/infrastructure/adapters/GitGraphAdapter.js @@ -264,6 +264,32 @@ function wrapGitError(err, hint = {}) { return err; } +/** + * Distinguishes a legitimate zero-byte blob from a missing object when a blob + * stream returns no bytes. Some plumbing implementations surface the missing + * object case as an empty collect result instead of throwing. + * + * @param {GitGraphAdapter} adapter + * @param {string} oid + * @returns {Promise} + */ +async function assertBlobExistsForEmptyRead(adapter, oid) { + try { + await adapter._executeWithRetry({ args: ['cat-file', '-e', oid] }); + } catch (err) { + const gitErr = /** @type {GitError} */ (err); + const wrapped = wrapGitError(gitErr, { oid }); + if (wrapped === gitErr && (getExitCode(gitErr) === 1 || getExitCode(gitErr) === 128)) { + throw new PersistenceError( + `Missing Git object: ${oid}`, + PersistenceError.E_MISSING_OBJECT, + { cause: /** @type {Error} */ (gitErr), context: { oid } }, + ); + } + throw wrapped; + } +} + /** * Concrete implementation of {@link GraphPersistencePort} using Git plumbing commands. * @@ -651,6 +677,12 @@ export default class GitGraphAdapter extends GraphPersistencePort { args: ['cat-file', 'blob', oid] }); const raw = await stream.collect({ asString: false }); + // Some executeStream implementations can surface a missing object as an + // empty collect result instead of throwing. Distinguish that from a real + // zero-byte blob with an explicit existence check. + if (raw.length === 0) { + await assertBlobExistsForEmptyRead(this, oid); + } // Return as-is — plumbing returns Buffer (which IS-A Uint8Array) return /** @type {Uint8Array} */ (raw); } catch (err) { diff --git a/test/integration/api/content-attachment.test.js b/test/integration/api/content-attachment.test.js index 2fc3a22b..f8383649 100644 --- a/test/integration/api/content-attachment.test.js +++ b/test/integration/api/content-attachment.test.js @@ -223,4 +223,24 @@ describe('API: Content Attachment', () => { expect(content).toBeInstanceOf(Uint8Array); expect(content).toEqual(binary); }); + + it('throws when _content points at a missing blob OID', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const patch = await graph.createPatch(); + patch.addNode('doc:1'); + await patch.attachContent('doc:1', 'hello'); + await patch.commit(); + + await graph.materialize(); + + const patch2 = await graph.createPatch(); + patch2.setProperty('doc:1', '_content', 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + await patch2.commit(); + + await graph.materialize(); + + await expect(graph.getContent('doc:1')) + .rejects.toThrow(/Missing Git object|Blob not found|bad object/i); + }); }); diff --git a/test/unit/domain/services/GitGraphAdapter.test.js b/test/unit/domain/services/GitGraphAdapter.test.js index 4c1d2b8e..153a4332 100644 --- a/test/unit/domain/services/GitGraphAdapter.test.js +++ b/test/unit/domain/services/GitGraphAdapter.test.js @@ -2,6 +2,55 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import GitGraphAdapter from '../../../../src/infrastructure/adapters/GitGraphAdapter.js'; describe('GitGraphAdapter', () => { + describe('readBlob()', () => { + /** @type {any} */ + let mockPlumbing; + /** @type {any} */ + let adapter; + + beforeEach(() => { + mockPlumbing = { + emptyTree: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + execute: vi.fn(), + executeStream: vi.fn(), + }; + adapter = new GitGraphAdapter({ plumbing: mockPlumbing }); + }); + + it('throws E_MISSING_OBJECT when blob stream is empty and object does not exist', async () => { + mockPlumbing.executeStream.mockResolvedValue({ + collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), + }); + const err = /** @type {any} */ (new Error('fatal: bad object deadbeef')); + err.details = { code: 128, stderr: 'fatal: bad object deadbeef' }; + mockPlumbing.execute.mockRejectedValue(err); + + await expect(adapter.readBlob('deadbeef')) + .rejects.toMatchObject({ + code: 'E_MISSING_OBJECT', + message: 'Missing Git object: deadbeef', + }); + + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['cat-file', '-e', 'deadbeef'], + }); + }); + + it('returns empty blob bytes when the object exists', async () => { + mockPlumbing.executeStream.mockResolvedValue({ + collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), + }); + mockPlumbing.execute.mockResolvedValue(''); + + const result = await adapter.readBlob('abcd'); + + expect(result).toEqual(Buffer.alloc(0)); + expect(mockPlumbing.execute).toHaveBeenCalledWith({ + args: ['cat-file', '-e', 'abcd'], + }); + }); + }); + describe('getNodeInfo()', () => { /** @type {any} */ let mockPlumbing; From 48cb8e46285eb0071ca15b707b8969fc0152912e Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 11:40:03 -0700 Subject: [PATCH 02/10] fix: keep blob existence check inside adapter --- .../adapters/GitGraphAdapter.js | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/infrastructure/adapters/GitGraphAdapter.js b/src/infrastructure/adapters/GitGraphAdapter.js index 09823bce..d5c7dc95 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.js +++ b/src/infrastructure/adapters/GitGraphAdapter.js @@ -264,32 +264,6 @@ function wrapGitError(err, hint = {}) { return err; } -/** - * Distinguishes a legitimate zero-byte blob from a missing object when a blob - * stream returns no bytes. Some plumbing implementations surface the missing - * object case as an empty collect result instead of throwing. - * - * @param {GitGraphAdapter} adapter - * @param {string} oid - * @returns {Promise} - */ -async function assertBlobExistsForEmptyRead(adapter, oid) { - try { - await adapter._executeWithRetry({ args: ['cat-file', '-e', oid] }); - } catch (err) { - const gitErr = /** @type {GitError} */ (err); - const wrapped = wrapGitError(gitErr, { oid }); - if (wrapped === gitErr && (getExitCode(gitErr) === 1 || getExitCode(gitErr) === 128)) { - throw new PersistenceError( - `Missing Git object: ${oid}`, - PersistenceError.E_MISSING_OBJECT, - { cause: /** @type {Error} */ (gitErr), context: { oid } }, - ); - } - throw wrapped; - } -} - /** * Concrete implementation of {@link GraphPersistencePort} using Git plumbing commands. * @@ -384,6 +358,32 @@ export default class GitGraphAdapter extends GraphPersistencePort { return await retry(() => this.plumbing.execute(options), this._retryOptions); } + /** + * Distinguishes a legitimate zero-byte blob from a missing object when a + * blob stream returns no bytes. Some plumbing implementations surface the + * missing object case as an empty collect result instead of throwing. + * + * @param {string} oid + * @returns {Promise} + * @private + */ + async _assertBlobExistsForEmptyRead(oid) { + try { + await this._executeWithRetry({ args: ['cat-file', '-e', oid] }); + } catch (err) { + const gitErr = /** @type {GitError} */ (err); + const wrapped = wrapGitError(gitErr, { oid }); + if (wrapped === gitErr && (getExitCode(gitErr) === 1 || getExitCode(gitErr) === 128)) { + throw new PersistenceError( + `Missing Git object: ${oid}`, + PersistenceError.E_MISSING_OBJECT, + { cause: /** @type {Error} */ (gitErr), context: { oid } }, + ); + } + throw wrapped; + } + } + /** * The well-known SHA for Git's empty tree object. * @type {string} @@ -681,7 +681,7 @@ export default class GitGraphAdapter extends GraphPersistencePort { // empty collect result instead of throwing. Distinguish that from a real // zero-byte blob with an explicit existence check. if (raw.length === 0) { - await assertBlobExistsForEmptyRead(this, oid); + await this._assertBlobExistsForEmptyRead(oid); } // Return as-is — plumbing returns Buffer (which IS-A Uint8Array) return /** @type {Uint8Array} */ (raw); From d51096c47abde8d78dc20496785cdcbf931f2fd9 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 12:34:36 -0700 Subject: [PATCH 03/10] fix: align missing-content errors across backends --- ROADMAP.md | 2 -- src/infrastructure/adapters/CasBlobAdapter.js | 7 ++++-- .../api/content-attachment.test.js | 20 ++++++++++++++++ test/unit/domain/WarpGraph.content.test.js | 24 +++++++++++++++++++ .../adapters/CasBlobAdapter.test.js | 13 ++++++---- 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 194d277f..7969d759 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -175,8 +175,6 @@ Archived to [COMPLETED.md](docs/ROADMAP/COMPLETED.md#milestone-11--compass-ii). 26 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. -Small GitHub issues may also land as issue-sourced slices without a numbered `B` item when the GitHub issue itself is the backlog record. In those cases, `CHANGELOG.md` and the issue state are the canonical progress markers until the work merges. - > Completed standalone items archived in [COMPLETED.md](docs/ROADMAP/COMPLETED.md#standalone-lane--completed-items). ### P0 — Quick Wins (unblock other work, trivial effort) diff --git a/src/infrastructure/adapters/CasBlobAdapter.js b/src/infrastructure/adapters/CasBlobAdapter.js index 49a9701d..29ef2c6d 100644 --- a/src/infrastructure/adapters/CasBlobAdapter.js +++ b/src/infrastructure/adapters/CasBlobAdapter.js @@ -13,6 +13,7 @@ */ import BlobStoragePort from '../../ports/BlobStoragePort.js'; +import PersistenceError from '../../domain/errors/PersistenceError.js'; import { createLazyCas } from './lazyCasInit.js'; import LoggerObservabilityBridge from './LoggerObservabilityBridge.js'; import { Readable } from 'node:stream'; @@ -148,8 +149,10 @@ export default class CasBlobAdapter extends BlobStoragePort { } const blob = await this._persistence.readBlob(oid); if (blob === null || blob === undefined) { - throw new Error( - `Blob not found: OID "${oid}" is neither a CAS manifest nor a readable Git blob`, + throw new PersistenceError( + `Missing Git object: ${oid}`, + PersistenceError.E_MISSING_OBJECT, + { context: { oid } }, ); } return blob; diff --git a/test/integration/api/content-attachment.test.js b/test/integration/api/content-attachment.test.js index f8383649..21373e9d 100644 --- a/test/integration/api/content-attachment.test.js +++ b/test/integration/api/content-attachment.test.js @@ -243,4 +243,24 @@ describe('API: Content Attachment', () => { await expect(graph.getContent('doc:1')) .rejects.toThrow(/Missing Git object|Blob not found|bad object/i); }); + + it('throws when edge _content points at a missing blob OID', async () => { + const graph = await repo.openGraph('test', 'alice'); + + const patch = await graph.createPatch(); + patch.addNode('a').addNode('b').addEdge('a', 'b', 'rel'); + await patch.attachEdgeContent('a', 'b', 'rel', 'edge payload'); + await patch.commit(); + + await graph.materialize(); + + const patch2 = await graph.createPatch(); + patch2.setEdgeProperty('a', 'b', 'rel', '_content', 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + await patch2.commit(); + + await graph.materialize(); + + await expect(graph.getEdgeContent('a', 'b', 'rel')) + .rejects.toThrow(/Missing Git object|Blob not found|bad object/i); + }); }); diff --git a/test/unit/domain/WarpGraph.content.test.js b/test/unit/domain/WarpGraph.content.test.js index 986a06fb..192fcd02 100644 --- a/test/unit/domain/WarpGraph.content.test.js +++ b/test/unit/domain/WarpGraph.content.test.js @@ -4,6 +4,7 @@ import { createEmptyStateV5, encodeEdgeKey, encodeEdgePropKey } from '../../../s import { orsetAdd } from '../../../src/domain/crdt/ORSet.js'; import { createDot } from '../../../src/domain/crdt/Dot.js'; import { encodePropKey } from '../../../src/domain/services/KeyCodec.js'; +import PersistenceError from '../../../src/domain/errors/PersistenceError.js'; function setupGraphState(/** @type {any} */ graph, /** @type {any} */ seedFn) { const state = createEmptyStateV5(); @@ -156,6 +157,29 @@ describe('WarpGraph content attachment (query methods)', () => { expect(content).toEqual(rawBuf); expect(mockPersistence.readBlob).toHaveBeenCalledWith('raw-oid'); }); + + it('preserves E_MISSING_OBJECT from blobStorage.retrieve()', async () => { + const blobStorage = { + store: vi.fn(), + retrieve: vi.fn().mockRejectedValue( + new PersistenceError( + 'Missing Git object: cas-tree-oid', + PersistenceError.E_MISSING_OBJECT, + { context: { oid: 'cas-tree-oid' } }, + ), + ), + }; + /** @type {any} */ (graph)._blobStorage = blobStorage; + + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'doc:1', 1); + const propKey = encodePropKey('doc:1', '_content'); + state.prop.set(propKey, { eventId: null, value: 'cas-tree-oid' }); + }); + + await expect(graph.getContent('doc:1')) + .rejects.toMatchObject({ code: PersistenceError.E_MISSING_OBJECT }); + }); }); describe('getEdgeContent() with blobStorage', () => { diff --git a/test/unit/infrastructure/adapters/CasBlobAdapter.test.js b/test/unit/infrastructure/adapters/CasBlobAdapter.test.js index 842ef577..6c987c71 100644 --- a/test/unit/infrastructure/adapters/CasBlobAdapter.test.js +++ b/test/unit/infrastructure/adapters/CasBlobAdapter.test.js @@ -33,6 +33,9 @@ const { default: CasBlobAdapter } = await import( const { default: BlobStoragePort } = await import( '../../../../src/ports/BlobStoragePort.js' ); +const { default: PersistenceError } = await import( + '../../../../src/domain/errors/PersistenceError.js' +); // --------------------------------------------------------------------------- // Helpers @@ -284,7 +287,7 @@ describe('CasBlobAdapter', () => { expect(persistence.readBlob).toHaveBeenCalledWith('missing-oid'); }); - it('throws descriptive error when legacy fallback readBlob returns null', async () => { + it('throws E_MISSING_OBJECT when legacy fallback readBlob returns null', async () => { const persistence = makePersistence(); persistence.readBlob.mockResolvedValue(null); const casErr = Object.assign(new Error('No manifest entry'), { code: 'MANIFEST_NOT_FOUND' }); @@ -295,9 +298,11 @@ describe('CasBlobAdapter', () => { persistence, }); - await expect(adapter.retrieve('ghost-oid')).rejects.toThrow( - 'Blob not found: OID "ghost-oid" is neither a CAS manifest nor a readable Git blob', - ); + await expect(adapter.retrieve('ghost-oid')) + .rejects.toMatchObject({ + code: PersistenceError.E_MISSING_OBJECT, + message: 'Missing Git object: ghost-oid', + }); expect(persistence.readBlob).toHaveBeenCalledWith('ghost-oid'); }); From 84c90a08c112c06d818ebb2ffbe6dd2c886e0825 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 12:46:10 -0700 Subject: [PATCH 04/10] test: make pre-push hook harness portable --- scripts/hooks/pre-push | 46 +++++++++++++++++++------ test/unit/scripts/pre-push-hook.test.js | 11 ++++-- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index e91b3e7f..f88d28af 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -14,6 +14,32 @@ if [ -z "$ROOT" ]; then fi cd "$ROOT" +command_exists() { + launcher="$1" + cmd="$2" + if [ -n "$launcher" ]; then + [ -f "$cmd" ] + else + command -v "$cmd" >/dev/null 2>&1 + fi +} + +run_tool() { + launcher="$1" + cmd="$2" + shift 2 + if [ -n "$launcher" ]; then + "$launcher" "$cmd" "$@" + else + "$cmd" "$@" + fi +} + +NPM_BIN="${WARP_NPM_BIN:-npm}" +NPM_LAUNCHER="${WARP_NPM_LAUNCHER:-}" +LINKCHECK_BIN="${WARP_LINKCHECK_BIN:-lychee}" +LINKCHECK_LAUNCHER="${WARP_LINKCHECK_LAUNCHER:-}" + # ── Quick mode: skip unit tests when WARP_QUICK_PUSH=1 or true ────────── QUICK=0 if [ "$WARP_QUICK_PUSH" = "1" ] || [ "$WARP_QUICK_PUSH" = "true" ]; then @@ -26,9 +52,9 @@ echo " IRONCLAD M9 — pre-push type firewall" echo "══════════════════════════════════════════════════════════" # ── Link check (optional) ────────────────────────────────────────────────── -if command -v lychee >/dev/null 2>&1; then +if command_exists "$LINKCHECK_LAUNCHER" "$LINKCHECK_BIN"; then echo "[Gate 0] Link check..." - lychee --config .lychee.toml '**/*.md' + run_tool "$LINKCHECK_LAUNCHER" "$LINKCHECK_BIN" --config .lychee.toml '**/*.md' else echo "[Gate 0] Link check skipped (lychee not installed)" fi @@ -36,19 +62,19 @@ fi # ── Gates 1-7 in parallel (all are read-only) ───────────────────────────── echo "[Gates 1-7] Running lint + typecheck + policy + consumer type test + surface validator + markdown gates..." -npm run lint & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run lint & LINT_PID=$! -npm run typecheck & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run typecheck & TC_PID=$! -npm run typecheck:policy & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run typecheck:policy & POLICY_PID=$! -npm run typecheck:consumer & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run typecheck:consumer & CONSUMER_PID=$! -npm run typecheck:surface & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run typecheck:surface & SURFACE_PID=$! -npm run lint:md & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run lint:md & MD_PID=$! -npm run lint:md:code & +run_tool "$NPM_LAUNCHER" "$NPM_BIN" run lint:md:code & MD_CODE_PID=$! wait $LINT_PID || { echo ""; echo "BLOCKED — Gate 4 FAILED: ESLint (includes no-explicit-any, no-unsafe-*)"; exit 1; } @@ -66,7 +92,7 @@ if [ "$QUICK" = "1" ]; then echo "[Gate 8] Skipped (WARP_QUICK_PUSH quick mode)" else echo "[Gate 8] Running unit tests..." - npm run test:local || { echo ""; echo "BLOCKED — Gate 8 FAILED: Unit tests"; exit 1; } + run_tool "$NPM_LAUNCHER" "$NPM_BIN" run test:local || { echo ""; echo "BLOCKED — Gate 8 FAILED: Unit tests"; exit 1; } fi echo "══════════════════════════════════════════════════════════" diff --git a/test/unit/scripts/pre-push-hook.test.js b/test/unit/scripts/pre-push-hook.test.js index 2adc0dba..9f8e487f 100644 --- a/test/unit/scripts/pre-push-hook.test.js +++ b/test/unit/scripts/pre-push-hook.test.js @@ -56,11 +56,13 @@ function readLog(filePath) { function runPrePushHook(options = {}) { const { quick = false, failCommand = null } = options; const binDir = createTempDir(); + const npmBin = join(binDir, 'npm'); const npmLog = join(binDir, 'npm.log'); const lycheeLog = join(binDir, 'lychee.log'); + const linkcheckBin = join(binDir, 'warp-linkcheck-stub'); writeExecutable( - join(binDir, 'npm'), + npmBin, [ '#!/bin/sh', 'set -eu', @@ -79,7 +81,7 @@ function runPrePushHook(options = {}) { ); writeExecutable( - join(binDir, 'lychee'), + linkcheckBin, [ '#!/bin/sh', 'set -eu', @@ -92,9 +94,12 @@ function runPrePushHook(options = {}) { /** @type {Record} */ const env = { ...process.env, - PATH: `${binDir}:${process.env.PATH}`, WARP_NPM_LOG: npmLog, WARP_LYCHEE_LOG: lycheeLog, + WARP_NPM_BIN: npmBin, + WARP_NPM_LAUNCHER: 'sh', + WARP_LINKCHECK_BIN: linkcheckBin, + WARP_LINKCHECK_LAUNCHER: 'sh', }; if (quick) { From 078fe76d10b4a055d425511b9be992b40e0968c8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 14:55:57 -0700 Subject: [PATCH 05/10] test: tighten missing-content regressions --- .../api/content-attachment.test.js | 5 ++-- test/unit/domain/WarpGraph.content.test.js | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/test/integration/api/content-attachment.test.js b/test/integration/api/content-attachment.test.js index 21373e9d..a2bb79db 100644 --- a/test/integration/api/content-attachment.test.js +++ b/test/integration/api/content-attachment.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { execSync } from 'node:child_process'; import { createTestRepo } from './helpers/setup.js'; +import PersistenceError from '../../../src/domain/errors/PersistenceError.js'; describe('API: Content Attachment', () => { /** @type {any} */ @@ -241,7 +242,7 @@ describe('API: Content Attachment', () => { await graph.materialize(); await expect(graph.getContent('doc:1')) - .rejects.toThrow(/Missing Git object|Blob not found|bad object/i); + .rejects.toMatchObject({ code: PersistenceError.E_MISSING_OBJECT }); }); it('throws when edge _content points at a missing blob OID', async () => { @@ -261,6 +262,6 @@ describe('API: Content Attachment', () => { await graph.materialize(); await expect(graph.getEdgeContent('a', 'b', 'rel')) - .rejects.toThrow(/Missing Git object|Blob not found|bad object/i); + .rejects.toMatchObject({ code: PersistenceError.E_MISSING_OBJECT }); }); }); diff --git a/test/unit/domain/WarpGraph.content.test.js b/test/unit/domain/WarpGraph.content.test.js index 192fcd02..a9b46ec8 100644 --- a/test/unit/domain/WarpGraph.content.test.js +++ b/test/unit/domain/WarpGraph.content.test.js @@ -205,6 +205,34 @@ describe('WarpGraph content attachment (query methods)', () => { expect(blobStorage.retrieve).toHaveBeenCalledWith('cas-edge-oid'); expect(mockPersistence.readBlob).not.toHaveBeenCalled(); }); + + it('preserves E_MISSING_OBJECT from blobStorage.retrieve()', async () => { + const blobStorage = { + store: vi.fn(), + retrieve: vi.fn().mockRejectedValue( + new PersistenceError( + 'Missing Git object: cas-edge-oid', + PersistenceError.E_MISSING_OBJECT, + { context: { oid: 'cas-edge-oid' } }, + ), + ), + }; + /** @type {any} */ (graph)._blobStorage = blobStorage; + + setupGraphState(graph, (/** @type {any} */ state) => { + addNode(state, 'a', 1); + addNode(state, 'b', 2); + addEdge(state, 'a', 'b', 'rel', 3); + const propKey = encodeEdgePropKey('a', 'b', 'rel', '_content'); + state.prop.set(propKey, { + eventId: { lamport: 2, writerId: 'w1', patchSha: 'aabbccdd', opIndex: 0 }, + value: 'cas-edge-oid', + }); + }); + + await expect(graph.getEdgeContent('a', 'b', 'rel')) + .rejects.toMatchObject({ code: PersistenceError.E_MISSING_OBJECT }); + }); }); describe('getEdgeContentOid()', () => { From d5258ffedf66937e3d6d2d7b818ab82519fb435d Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 14:56:05 -0700 Subject: [PATCH 06/10] docs: remove roadmap count churn from issue-46 --- ROADMAP.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7969d759..d8ff691e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -173,7 +173,7 @@ Archived to [COMPLETED.md](docs/ROADMAP/COMPLETED.md#milestone-11--compass-ii). ## Standalone Lane (Ongoing) -26 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. +32 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). From 27ae8c4d427b51d9b8ee33a096a9e3ae093eaa19 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 14:59:18 -0700 Subject: [PATCH 07/10] fix: narrow empty-read missing-object fallback --- docs/specs/CONTENT_ATTACHMENT.md | 1 + src/infrastructure/adapters/GitGraphAdapter.js | 5 ++++- test/unit/domain/services/GitGraphAdapter.test.js | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/specs/CONTENT_ATTACHMENT.md b/docs/specs/CONTENT_ATTACHMENT.md index ceb24899..a090e62f 100644 --- a/docs/specs/CONTENT_ATTACHMENT.md +++ b/docs/specs/CONTENT_ATTACHMENT.md @@ -114,6 +114,7 @@ const edgeOid = await graph.getEdgeContentOid('a', 'b', 'rel'); `getContent()` returns raw `Uint8Array` bytes. Consumers wanting text should decode with `new TextDecoder().decode(buffer)`. If `_content` points at a missing blob OID, `getContent()` throws instead of silently returning empty bytes. +`getEdgeContent()` has the same byte-decoding and missing-blob semantics for edge `_content` references. #### Constant diff --git a/src/infrastructure/adapters/GitGraphAdapter.js b/src/infrastructure/adapters/GitGraphAdapter.js index d5c7dc95..512138fc 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.js +++ b/src/infrastructure/adapters/GitGraphAdapter.js @@ -373,7 +373,10 @@ export default class GitGraphAdapter extends GraphPersistencePort { } catch (err) { const gitErr = /** @type {GitError} */ (err); const wrapped = wrapGitError(gitErr, { oid }); - if (wrapped === gitErr && (getExitCode(gitErr) === 1 || getExitCode(gitErr) === 128)) { + const exitCode = getExitCode(gitErr); + const text = errorSearchText(gitErr); + const ambiguousMissingObject = exitCode === 1 && text.trim() === ''; + if (wrapped === gitErr && ambiguousMissingObject) { throw new PersistenceError( `Missing Git object: ${oid}`, PersistenceError.E_MISSING_OBJECT, diff --git a/test/unit/domain/services/GitGraphAdapter.test.js b/test/unit/domain/services/GitGraphAdapter.test.js index 153a4332..dc6ec4be 100644 --- a/test/unit/domain/services/GitGraphAdapter.test.js +++ b/test/unit/domain/services/GitGraphAdapter.test.js @@ -36,6 +36,18 @@ describe('GitGraphAdapter', () => { }); }); + it('rethrows unrelated exit-128 errors from the existence check', async () => { + mockPlumbing.executeStream.mockResolvedValue({ + collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), + }); + const err = /** @type {any} */ (new Error('fatal: not a git repository')); + err.details = { code: 128, stderr: 'fatal: not a git repository (or any of the parent directories): .git' }; + mockPlumbing.execute.mockRejectedValue(err); + + await expect(adapter.readBlob('deadbeef')) + .rejects.toBe(err); + }); + it('returns empty blob bytes when the object exists', async () => { mockPlumbing.executeStream.mockResolvedValue({ collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), From 0976680ae84bf262a57ebd886361d1d69b8ec835 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 15:27:54 -0700 Subject: [PATCH 08/10] fix: tighten issue-46 review follow-ups --- scripts/hooks/pre-push | 2 +- src/domain/warp/query.methods.js | 14 ++++++++------ test/unit/domain/services/GitGraphAdapter.test.js | 15 +++++++++++++++ test/unit/scripts/pre-push-hook.test.js | 15 +++++++++++++-- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push index f88d28af..fbf57f39 100755 --- a/scripts/hooks/pre-push +++ b/scripts/hooks/pre-push @@ -18,7 +18,7 @@ command_exists() { launcher="$1" cmd="$2" if [ -n "$launcher" ]; then - [ -f "$cmd" ] + command -v "$launcher" >/dev/null 2>&1 && [ -f "$cmd" ] && [ -r "$cmd" ] else command -v "$cmd" >/dev/null 2>&1 fi diff --git a/src/domain/warp/query.methods.js b/src/domain/warp/query.methods.js index 20e6812c..4be3bcbd 100644 --- a/src/domain/warp/query.methods.js +++ b/src/domain/warp/query.methods.js @@ -368,9 +368,10 @@ export async function getContentOid(nodeId) { * @this {import('../WarpGraph.js').default} * @param {string} nodeId - The node ID to get content for * @returns {Promise} Content bytes or null - * @throws {Error} If the referenced blob OID is not in the object store - * (e.g., garbage-collected despite anchoring). Callers should handle this - * if operating on repos with aggressive GC or partial clones. + * @throws {import('../errors/PersistenceError.js').default} If the referenced + * blob OID is not in the object store (code: `E_MISSING_OBJECT`), such as + * after repository corruption, aggressive GC, or a partial clone missing the + * blob object. */ export async function getContent(nodeId) { const oid = await getContentOid.call(this, nodeId); @@ -414,9 +415,10 @@ export async function getEdgeContentOid(from, to, label) { * @param {string} to - Target node ID * @param {string} label - Edge label * @returns {Promise} Content bytes or null - * @throws {Error} If the referenced blob OID is not in the object store - * (e.g., garbage-collected despite anchoring). Callers should handle this - * if operating on repos with aggressive GC or partial clones. + * @throws {import('../errors/PersistenceError.js').default} If the referenced + * blob OID is not in the object store (code: `E_MISSING_OBJECT`), such as + * after repository corruption, aggressive GC, or a partial clone missing the + * blob object. */ export async function getEdgeContent(from, to, label) { const oid = await getEdgeContentOid.call(this, from, to, label); diff --git a/test/unit/domain/services/GitGraphAdapter.test.js b/test/unit/domain/services/GitGraphAdapter.test.js index dc6ec4be..4a13b40d 100644 --- a/test/unit/domain/services/GitGraphAdapter.test.js +++ b/test/unit/domain/services/GitGraphAdapter.test.js @@ -36,6 +36,21 @@ describe('GitGraphAdapter', () => { }); }); + it('throws E_MISSING_OBJECT for ambiguous exit-1 empty-read failures', async () => { + mockPlumbing.executeStream.mockResolvedValue({ + collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), + }); + const err = /** @type {any} */ (new Error('')); + err.details = { code: 1, stderr: '' }; + mockPlumbing.execute.mockRejectedValue(err); + + await expect(adapter.readBlob('deadbeef')) + .rejects.toMatchObject({ + code: 'E_MISSING_OBJECT', + message: 'Missing Git object: deadbeef', + }); + }); + it('rethrows unrelated exit-128 errors from the existence check', async () => { mockPlumbing.executeStream.mockResolvedValue({ collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), diff --git a/test/unit/scripts/pre-push-hook.test.js b/test/unit/scripts/pre-push-hook.test.js index 9f8e487f..e1a96f0e 100644 --- a/test/unit/scripts/pre-push-hook.test.js +++ b/test/unit/scripts/pre-push-hook.test.js @@ -51,10 +51,10 @@ function readLog(filePath) { } /** - * @param {{ quick?: boolean, failCommand?: string|null }} [options] + * @param {{ quick?: boolean, failCommand?: string|null, linkcheckReadable?: boolean }} [options] */ function runPrePushHook(options = {}) { - const { quick = false, failCommand = null } = options; + const { quick = false, failCommand = null, linkcheckReadable = true } = options; const binDir = createTempDir(); const npmBin = join(binDir, 'npm'); const npmLog = join(binDir, 'npm.log'); @@ -90,6 +90,9 @@ function runPrePushHook(options = {}) { '', ].join('\n') ); + if (!linkcheckReadable) { + chmodSync(linkcheckBin, 0o000); + } /** @type {Record} */ const env = { @@ -155,6 +158,14 @@ describe('scripts/hooks/pre-push', () => { expect(result.lycheeCalls).toEqual(['--config .lychee.toml **/*.md']); }); + it('skips Gate 0 when the launcher target is not readable', () => { + const result = runPrePushHook({ quick: true, linkcheckReadable: false }); + + expect(result.status).toBe(0); + expect(result.output).toContain('[Gate 0] Link check skipped (lychee not installed)'); + expect(result.lycheeCalls).toEqual([]); + }); + it('runs Gate 8 in normal mode', () => { const result = runPrePushHook(); From 52b420e7918619ae106db37e82e10b901f6dcb6c Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 16:32:53 -0700 Subject: [PATCH 09/10] fix: normalize empty-read missing blob errors in ci --- src/infrastructure/adapters/GitGraphAdapter.js | 17 +++++++++++++++-- .../domain/services/GitGraphAdapter.test.js | 5 +++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/infrastructure/adapters/GitGraphAdapter.js b/src/infrastructure/adapters/GitGraphAdapter.js index 512138fc..0e0ae147 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.js +++ b/src/infrastructure/adapters/GitGraphAdapter.js @@ -185,6 +185,19 @@ function errorSearchText(err) { return `${message} ${stderr}`; } +/** + * Returns stderr/stdout diagnostic text from a Git error, ignoring wrapper + * messages like "Git command failed with code 1" that do not carry object + * lookup semantics on their own. + * @param {GitError} err + * @returns {string} + */ +function gitDiagnosticText(err) { + const stderr = String(err?.details?.stderr || ''); + const stdout = String(err?.details?.stdout || ''); + return `${stderr} ${stdout}`.trim().toLowerCase(); +} + /** * Checks if a Git error indicates a missing object (commit, blob, tree). * Covers exit code 128 with object-related stderr patterns. @@ -374,8 +387,8 @@ export default class GitGraphAdapter extends GraphPersistencePort { const gitErr = /** @type {GitError} */ (err); const wrapped = wrapGitError(gitErr, { oid }); const exitCode = getExitCode(gitErr); - const text = errorSearchText(gitErr); - const ambiguousMissingObject = exitCode === 1 && text.trim() === ''; + const diagnostics = gitDiagnosticText(gitErr); + const ambiguousMissingObject = exitCode === 1 && diagnostics === ''; if (wrapped === gitErr && ambiguousMissingObject) { throw new PersistenceError( `Missing Git object: ${oid}`, diff --git a/test/unit/domain/services/GitGraphAdapter.test.js b/test/unit/domain/services/GitGraphAdapter.test.js index 4a13b40d..10a2094c 100644 --- a/test/unit/domain/services/GitGraphAdapter.test.js +++ b/test/unit/domain/services/GitGraphAdapter.test.js @@ -40,8 +40,9 @@ describe('GitGraphAdapter', () => { mockPlumbing.executeStream.mockResolvedValue({ collect: vi.fn().mockResolvedValue(Buffer.alloc(0)), }); - const err = /** @type {any} */ (new Error('')); - err.details = { code: 1, stderr: '' }; + const err = /** @type {any} */ (new Error('Git command failed with code 1')); + err.name = 'GitPlumbingError'; + err.details = { code: 1, stderr: '', stdout: '' }; mockPlumbing.execute.mockRejectedValue(err); await expect(adapter.readBlob('deadbeef')) From c109c997bfa6fb5981eda197be613bbdddcb6ac5 Mon Sep 17 00:00:00 2001 From: James Ross Date: Fri, 13 Mar 2026 16:33:25 -0700 Subject: [PATCH 10/10] fix: declare stdout on git error details --- src/infrastructure/adapters/GitGraphAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/adapters/GitGraphAdapter.js b/src/infrastructure/adapters/GitGraphAdapter.js index 0e0ae147..41adc8f6 100644 --- a/src/infrastructure/adapters/GitGraphAdapter.js +++ b/src/infrastructure/adapters/GitGraphAdapter.js @@ -76,7 +76,7 @@ const TRANSIENT_ERROR_PATTERNS = [ ]; /** - * @typedef {Error & { details?: { stderr?: string, code?: number }, exitCode?: number, code?: number }} GitError + * @typedef {Error & { details?: { stderr?: string, stdout?: string, code?: number }, exitCode?: number, code?: number }} GitError */ /**