Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
678bf84
feat: audit receipt chain verification with CLI command (M4.T1.VERIFY…
Feb 12, 2026
be1e6a7
refactor: decompose CLI monolith into per-command modules (M5.T1.COMM…
Feb 13, 2026
9bc49c4
fix: harden verify-audit arg parsing — empty-string values, unknown f…
Feb 13, 2026
2bf5d51
docs: fix CHANGELOG inaccuracy — eslint relaxed block was extended, n…
Feb 13, 2026
91c134b
docs: fix section numbering in AUDIT_RECEIPT.md (14 → 13)
Feb 13, 2026
c13c345
chore: remove unused WRONG_PARENT variable in BATS tamper test
Feb 13, 2026
35ee853
fix: add bin/cli to npm files array
Feb 13, 2026
19e062a
refactor: replace Node-only adapters with cross-runtime equivalents
Feb 13, 2026
d5c3749
refactor: migrate base arg parser to node:util.parseArgs
Feb 13, 2026
93ba05d
refactor: migrate per-command parsers to node:util.parseArgs + Zod
Feb 13, 2026
8047b61
docs: update CHANGELOG and bump version to 10.12.0
Feb 13, 2026
7b3b244
chore: add TODO(ts-cleanup) tags to wildcard casts in infrastructure.js
Feb 13, 2026
50b0e73
fix: address 6 CodeRabbit review issues from PR #27
Feb 13, 2026
406b297
fix(schemas): reject empty-string cursor names in --save/--load/--drop
Feb 13, 2026
1b31840
fix: writerFilter truthy check and view.js transitive dep detection
Feb 13, 2026
5973093
fix: KNOWN_COMMANDS sync test reads actual COMMANDS map from source
Feb 13, 2026
727b036
refactor: extract COMMANDS map to registry module
Feb 13, 2026
6b2bc9c
docs: update CHANGELOG with COMMANDS registry extraction
Feb 13, 2026
6ced071
fix(bats): materialize before patches in audit seed so receipts are c…
Feb 13, 2026
08f292b
fix(schemas): seek --diff-limit validation and error messages
Feb 13, 2026
b1f9411
feat: export InMemoryGraphAdapter from package entry point
Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [10.12.0] — 2026-02-13 — Multi-Runtime CLI + parseArgs Migration

Makes the CLI (`bin/`) portable across Node 22+, Bun, and Deno by removing Node-only dependencies, and replaces hand-rolled arg parsing with `node:util.parseArgs` + Zod schemas.

### Fixed

- **verify-audit**: Reject empty-string `--since`/`--writer` values at schema level; use strict `!== undefined` check for `writerFilter`
- **install-hooks**: `readHookContent` now only swallows ENOENT; permission errors propagate
- **view**: Module-not-found catch narrowed to `git-warp-tui` specifier/package name only (ignores transitive dep failures)
- **schemas**: `--max-depth` rejects negative values; `--diff` alone (without --tick/--latest/--load) now rejected; `--save`/`--load`/`--drop` reject empty-string cursor names; `--diff-limit` validates positive integer with user-friendly message; `--diff-limit` without `--diff` now rejected
- **npm packaging**: Added `bin/cli` to the `files` array — the commands-split refactor broke the published package for CLI use.
- **BATS audit seed**: Added `materialize()` call before first patch so `_cachedState` is initialized and audit receipts are created (all 5 verify-audit BATS tests were failing in CI).

### Changed

- **COMMANDS registry**: Extracted `COMMANDS` Map from `warp-graph.js` into `bin/cli/commands/registry.js` (side-effect-free); `KNOWN_COMMANDS` exported from `infrastructure.js`. Sync test asserts they match via direct import.
- **Cross-runtime adapters**: `NodeCryptoAdapter` → `WebCryptoAdapter` (uses `globalThis.crypto.subtle`), `ClockAdapter.node()` → `ClockAdapter.global()` (uses `globalThis.performance`), removed `import crypto from 'node:crypto'` in seek.js (converted `computeFrontierHash` to async Web Crypto).
- **Base arg parser** (`bin/cli/infrastructure.js`): Replaced 170 LOC hand-rolled parser with `node:util.parseArgs`. Two-pass approach: `extractBaseArgs` splits base flags from command args, `preprocessView` handles `--view`'s optional-value semantics. Returns `{options, command, commandArgs}` instead of `{options, positionals}`.
- **Per-command parsers**: All 10 commands now use `parseCommandArgs()` (wraps `nodeParseArgs` + Zod `safeParse`) instead of hand-rolled loops. Query uses a hybrid approach: `extractTraversalSteps` for `--outgoing`/`--incoming` optional values, then standard parsing for the rest.
- **Removed** `readOptionValue` and helper functions from infrastructure.js (no longer needed).

### Added

- **`bin/cli/schemas.js`**: Zod schemas for all commands — type coercion, enum validation, mutual-exclusion checks (seek's 10-flag parser).
- **`parseCommandArgs()`** in infrastructure.js: Shared helper wrapping `nodeParseArgs` + Zod validation for command-level parsing.
- **67 new CLI tests**: `parseArgs.test.js` (25 tests for base parsing), `schemas.test.js` (32 tests for Zod schema validation).
- **Public export**: `InMemoryGraphAdapter` now exported from the package entry point (`index.js` + `index.d.ts`) so downstream modules can use it for tests without reaching into internal paths.

## [10.11.0] — 2026-02-12 — COMMANDS SPLIT: CLI Decomposition

Decomposes the 2491-line `bin/warp-graph.js` monolith into per-command modules (M5.T1). Pure refactor — no behavior changes.

### Changed

- **`bin/warp-graph.js`**: Reduced from 2491 LOC to 112 LOC. Now contains only imports, the COMMANDS map, VIEW_SUPPORTED_COMMANDS, `main()`, and the error handler.
- **`bin/cli/infrastructure.js`**: EXIT_CODES, HELP_TEXT, CliError, parseArgs, and arg-parsing helpers.
- **`bin/cli/shared.js`**: 12 helpers used by 2+ commands (createPersistence, openGraph, applyCursorCeiling, etc.).
- **`bin/cli/types.js`**: JSDoc typedefs (Persistence, WarpGraphInstance, CliOptions, etc.).
- **`bin/cli/commands/`**: 10 per-command modules (info, query, path, history, check, materialize, seek, verify-audit, view, install-hooks).
- **ESLint config**: Added `bin/cli/commands/seek.js`, `bin/cli/commands/query.js`, and other `bin/cli/` modules to the relaxed-complexity block alongside `bin/warp-graph.js`.

## [10.10.0] — 2026-02-12 — VERIFY-AUDIT: Chain Verification

Implements cryptographic verification of audit receipt chains (M4.T1). Walks chains backward from tip to genesis, validating receipt schema, chain linking, Git parent consistency, tick monotonicity, trailer-CBOR consistency, OID format, and tree structure.

### Added

- **`AuditVerifierService`** (`src/domain/services/AuditVerifierService.js`): Domain service with `verifyChain()` and `verifyAll()` methods. Supports `--since` partial verification and ref-race detection.
- **`getCommitTree(sha)`** on `CommitPort` / `GraphPersistencePort`: Returns the tree OID for a given commit. Implemented in `GitGraphAdapter` (via `git rev-parse`) and `InMemoryGraphAdapter`.
- **`buildAuditPrefix()`** in `RefLayout`: Lists all audit writer refs under a graph.
- **`verify-audit` CLI command**: `git warp verify-audit [--writer <id>] [--since <commit>]`. Supports `--json` and `--ndjson` output. Exit code 3 on invalid chains.
- **Text presenter** for verify-audit: colored status, per-chain detail, trust warnings.
- **31 unit tests** in `AuditVerifierService.test.js` — valid chains, partial verification, broken chain detection, data mismatch, OID format validation, schema validation, warnings, multi-writer aggregation.
- **6 BATS CLI tests** in `cli-verify-audit.bats` — JSON/human output, writer filter, partial verify, tamper detection, no-audit-refs success.
- **Benchmark** in `AuditVerifierService.bench.js` — 1000-receipt chain verification (<5s target).

## [10.9.0] — 2026-02-12 — SHADOW-LEDGER: Audit Receipts

Implements tamper-evident, chained audit receipts per the spec in `docs/specs/AUDIT_RECEIPT.md`. When `audit: true` is passed to `WarpGraph.open()`, each data commit produces a corresponding audit commit recording per-operation outcomes. Audit commits form an independent chain per (graphName, writerId) pair, linked via `prevAuditCommit` and Git commit parents.
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ Create `docs/specs/AUDIT_RECEIPT.md` with:

### M4.T1.VERIFY-AUDIT (S-Tier)

- **Status:** `OPEN`
- **Status:** `DONE`

**User Story:** As an operator, I need a definitive verification command for audit integrity.

Expand Down Expand Up @@ -362,7 +362,7 @@ Create `docs/specs/AUDIT_RECEIPT.md` with:

### M5.T1.COMMANDS SPLIT

- **Status:** `OPEN`
- **Status:** `DONE`

**Requirements:**

Expand Down
168 changes: 168 additions & 0 deletions bin/cli/commands/check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import HealthCheckService from '../../../src/domain/services/HealthCheckService.js';
import ClockAdapter from '../../../src/infrastructure/adapters/ClockAdapter.js';
import { buildCheckpointRef, buildCoverageRef } from '../../../src/domain/utils/RefLayout.js';
import { EXIT_CODES } from '../infrastructure.js';
import { openGraph, applyCursorCeiling, emitCursorWarning, readCheckpointDate, createHookInstaller } from '../shared.js';

/** @typedef {import('../types.js').CliOptions} CliOptions */
/** @typedef {import('../types.js').Persistence} Persistence */
/** @typedef {import('../types.js').WarpGraphInstance} WarpGraphInstance */

/** @param {Persistence} persistence */
async function getHealth(persistence) {
const clock = ClockAdapter.global();
const healthService = new HealthCheckService({ persistence: /** @type {*} */ (persistence), clock }); // TODO(ts-cleanup): narrow port type
return await healthService.getHealth();
}

/** @param {WarpGraphInstance} graph */
async function getGcMetrics(graph) {
await graph.materialize();
return graph.getGCMetrics();
}

/** @param {WarpGraphInstance} graph */
async function collectWriterHeads(graph) {
const frontier = await graph.getFrontier();
return [...frontier.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([writerId, sha]) => ({ writerId, sha }));
}

/**
* @param {Persistence} persistence
* @param {string} graphName
*/
async function loadCheckpointInfo(persistence, graphName) {
const checkpointRef = buildCheckpointRef(graphName);
const checkpointSha = await persistence.readRef(checkpointRef);
const checkpointDate = await readCheckpointDate(persistence, checkpointSha);
const checkpointAgeSeconds = computeAgeSeconds(checkpointDate);

return {
ref: checkpointRef,
sha: checkpointSha || null,
date: checkpointDate,
ageSeconds: checkpointAgeSeconds,
};
}

/** @param {string|null} checkpointDate */
function computeAgeSeconds(checkpointDate) {
if (!checkpointDate) {
return null;
}
const parsed = Date.parse(checkpointDate);
if (Number.isNaN(parsed)) {
return null;
}
return Math.max(0, Math.floor((Date.now() - parsed) / 1000));
}

/**
* @param {Persistence} persistence
* @param {string} graphName
* @param {Array<{writerId: string, sha: string}>} writerHeads
*/
async function loadCoverageInfo(persistence, graphName, writerHeads) {
const coverageRef = buildCoverageRef(graphName);
const coverageSha = await persistence.readRef(coverageRef);
const missingWriters = coverageSha
? await findMissingWriters(persistence, writerHeads, coverageSha)
: [];

return {
ref: coverageRef,
sha: coverageSha || null,
missingWriters: missingWriters.sort(),
};
}

/**
* @param {Persistence} persistence
* @param {Array<{writerId: string, sha: string}>} writerHeads
* @param {string} coverageSha
*/
async function findMissingWriters(persistence, writerHeads, coverageSha) {
const missing = [];
for (const head of writerHeads) {
const reachable = await persistence.isAncestor(head.sha, coverageSha);
if (!reachable) {
missing.push(head.writerId);
}
}
return missing;
}

/**
* @param {{repo: string, graphName: string, health: *, checkpoint: *, writerHeads: Array<{writerId: string, sha: string}>, coverage: *, gcMetrics: *, hook: *|null, status: *|null}} params
*/
function buildCheckPayload({
repo,
graphName,
health,
checkpoint,
writerHeads,
coverage,
gcMetrics,
hook,
status,
}) {
return {
repo,
graph: graphName,
health,
checkpoint,
writers: {
count: writerHeads.length,
heads: writerHeads,
},
coverage,
gc: gcMetrics,
hook: hook || null,
status: status || null,
};
}

/** @param {string} repoPath */
function getHookStatusForCheck(repoPath) {
try {
const installer = createHookInstaller();
return installer.getHookStatus(repoPath);
} catch {
return null;
}
}

/**
* Handles the `check` command: reports graph health, GC, and hook status.
* @param {{options: CliOptions}} params
* @returns {Promise<{payload: *, exitCode: number}>}
*/
export default async function handleCheck({ options }) {
const { graph, graphName, persistence } = await openGraph(options);
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
emitCursorWarning(cursorInfo, null);
const health = await getHealth(persistence);
const gcMetrics = await getGcMetrics(graph);
const status = await graph.status();
const writerHeads = await collectWriterHeads(graph);
const checkpoint = await loadCheckpointInfo(persistence, graphName);
const coverage = await loadCoverageInfo(persistence, graphName, writerHeads);
const hook = getHookStatusForCheck(options.repo);

return {
payload: buildCheckPayload({
repo: options.repo,
graphName,
health,
checkpoint,
writerHeads,
coverage,
gcMetrics,
hook,
status,
}),
exitCode: EXIT_CODES.OK,
};
}
73 changes: 73 additions & 0 deletions bin/cli/commands/history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { summarizeOps } from '../../../src/visualization/renderers/ascii/history.js';
import { EXIT_CODES, notFoundError, parseCommandArgs } from '../infrastructure.js';
import { historySchema } from '../schemas.js';
import { openGraph, applyCursorCeiling, emitCursorWarning } from '../shared.js';

/** @typedef {import('../types.js').CliOptions} CliOptions */

const HISTORY_OPTIONS = {
node: { type: 'string' },
};

/** @param {string[]} args */
function parseHistoryArgs(args) {
const { values } = parseCommandArgs(args, HISTORY_OPTIONS, historySchema);
return { node: values.node ?? null };
}

/**
* @param {*} patch
* @param {string} nodeId
*/
function patchTouchesNode(patch, nodeId) {
const ops = Array.isArray(patch?.ops) ? patch.ops : [];
for (const op of ops) {
if (op.node === nodeId) {
return true;
}
if (op.from === nodeId || op.to === nodeId) {
return true;
}
}
return false;
}

/**
* Handles the `history` command: shows patch history for a writer.
* @param {{options: CliOptions, args: string[]}} params
* @returns {Promise<{payload: *, exitCode: number}>}
*/
export default async function handleHistory({ options, args }) {
const historyOptions = parseHistoryArgs(args);
const { graph, graphName, persistence } = await openGraph(options);
const cursorInfo = await applyCursorCeiling(graph, persistence, graphName);
emitCursorWarning(cursorInfo, null);

const writerId = options.writer;
let patches = await graph.getWriterPatches(writerId);
if (cursorInfo.active) {
patches = patches.filter((/** @type {*} */ { patch }) => patch.lamport <= /** @type {number} */ (cursorInfo.tick)); // TODO(ts-cleanup): type CLI payload
}
if (patches.length === 0) {
throw notFoundError(`No patches found for writer: ${writerId}`);
}

const entries = patches
.filter((/** @type {*} */ { patch }) => !historyOptions.node || patchTouchesNode(patch, historyOptions.node)) // TODO(ts-cleanup): type CLI payload
.map((/** @type {*} */ { patch, sha }) => ({ // TODO(ts-cleanup): type CLI payload
sha,
schema: patch.schema,
lamport: patch.lamport,
opCount: Array.isArray(patch.ops) ? patch.ops.length : 0,
opSummary: Array.isArray(patch.ops) ? summarizeOps(patch.ops) : undefined,
}));

const payload = {
graph: graphName,
writer: writerId,
nodeFilter: historyOptions.node,
entries,
};

return { payload, exitCode: EXIT_CODES.OK };
}
Loading