Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
29 changes: 28 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [3.3.0] - 2026-02-22

### Added

- **Content-on-node (M13 VESSEL)** — Attach rich content to graph nodes using git's native CAS. Content stored as git blobs via `hash-object`, SHA and metadata recorded as WARP node properties under the `_content.*` prefix (#271)
- **`git mind content set <node> --from <file>`** — Attach content from a file. MIME auto-detected from extension, `--mime` override supported. `--json` output (#273)
- **`git mind content show <node>`** — Display attached content. `--raw` for piping (body only, no metadata header). `--json` output (#273)
- **`git mind content meta <node>`** — Show content metadata (SHA, MIME, size, encoding). `--json` output (#273)
- **`git mind content delete <node>`** — Remove content attachment from a node. `--json` output (#273)
- **Content store API** — `writeContent()`, `readContent()`, `getContentMeta()`, `hasContent()`, `deleteContent()` exported from public API (#272)
- **SHA integrity verification** — `readContent()` re-hashes retrieved blob and compares to stored SHA on every read (#272)
- **JSON Schema contracts for content CLI** — `content-set.schema.json`, `content-show.schema.json`, `content-meta.schema.json` in `docs/contracts/cli/` (#274)
- **ADR-0004: Content Attachments Belong in git-warp** — Decision record establishing that CAS-backed content-on-node is a git-warp substrate responsibility, not a git-mind domain concern. Aligns with Paper I's `Atom(p)` attachment formalism (#252)
- **Chalk formatting for `extension list`** — `formatExtensionList()` renders extension names in cyan bold, versions dimmed, `[builtin]` in yellow / `[custom]` in magenta, consistent with all other CLI commands (#265)
- **Prefix collision detection** — `registerExtension()` now checks incoming domain prefixes against all registered extensions and throws a descriptive error on overlap. Idempotent re-registration of the same extension name is still allowed (#264)
Expand All @@ -17,11 +27,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **JSON Schema contracts for extension CLI output** — 4 new schemas in `docs/contracts/cli/`: `extension-list`, `extension-validate`, `extension-add`, `extension-remove`. Valid samples added to the contract test harness (#262)
- **Deferred items documented in ROADMAP** — #261 (ephemeral registration) and #269 (`--extension` flag) documented with rationale and recommended H2 slot

### Fixed

- **CRITICAL: Command injection in `readContent()`** — Replaced all `execSync` shell interpolation with `execFileSync` arg arrays + SHA validation regex. Zero shell invocations in content module (#276)
- **Dead `encoding` parameter removed** — Removed unused `encoding` field from content store, CLI format, JSON Schema contracts, and tests. Content is always UTF-8 (#276)
- **Static imports in content CLI** — Replaced dynamic `await import('node:fs/promises')` and `await import('node:path')` with static imports (#276)
- **`nodeId` in `content show` metadata** — Non-raw `content show` now passes `nodeId` to `formatContentMeta` for consistent display (#276)
- **Schema `if/then/else` conditional** — `content-meta.schema.json` enforces `sha`, `mime`, and `size` required when `hasContent` is `true`; forbids them when `false` (#276)
- **Redundant null check** — Removed dead `sha !== undefined` in `hasContent()` — `?? null` guarantees non-undefined (#276)
- **Misleading integrity test** — Split into blob-not-found test + genuine integrity mismatch test using non-UTF-8 blob (#276)
- **Test SHA assertions accept both SHA-1 (40 chars) and SHA-256 (64 chars)** (#276)
- **Schema test compile-once** — Content schema validators compiled once in `beforeAll` instead of per-test; removed `$id` stripping workaround (#276)
- **Error-path CLI tests** — 4 new tests: nonexistent file, node without content, non-existent node for show/delete (#276)
- **MIME map extended** — Added `.css` → `text/css` and `.svg` → `image/svg+xml` (#276)
- **YAML MIME type** — Changed `.yaml`/`.yml` mapping from `text/yaml` to `application/yaml` (IANA standard) (#276)
- **Missing `content-delete.schema.json` contract** — Added JSON Schema for `content delete --json` output (#276)
- **Content subcommand positional parsing** — `extractPositionals()` helper properly skips `--flag value` pairs instead of naive `!startsWith('--')` check (#276)

### Changed

- **Upgraded `@git-stunts/git-warp`** from v11.3.3 to v11.5.0
- **`registerBuiltinExtensions()` memoized** — Module-level `builtInsLoaded` flag prevents redundant YAML file reads on repeated invocations within the same process (#266)
- **Test count** — 537 tests across 28 files (was 527)
- **Test count** — 571 tests across 29 files (was 537)

## [3.2.0] - 2026-02-17

Expand Down
10 changes: 10 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2187,6 +2187,16 @@ Two issues were filed during the M12 extension polish pass and intentionally def

**Recommended slot:** H2 (CONTENT + MATERIALIZATION) planning. Both issues naturally fall into the extension lifecycle story — persistence is a prerequisite for the extension marketplace vision (H4). Design the persistence mechanism during H2 kickoff, implement as the first H2 deliverable so that all subsequent extension work (content system extensions, materializer extensions) benefits from proper registration.

### Content system enhancements (from M13 VESSEL review)

- **`git mind content list`** — Query all nodes that have `_content.sha` properties. Currently there's no way to discover which nodes carry content without inspecting each one individually.
- **Binary content support** — Add base64 encoding for non-text MIME types. Currently the content system is text-only (UTF-8); non-UTF-8 blobs fail the integrity check by design. Requires reintroducing encoding metadata and updating `readContent()` to handle buffer round-trips.
- **`content meta --verify` flag** — Run the SHA integrity check without dumping the full content body. Useful for bulk health checks across all content-bearing nodes.

### Codebase hardening (from M13 VESSEL review)

- **Standardize all git subprocess calls to `execFileSync`** — `src/content.js` now uses `execFileSync` exclusively, but other modules (e.g. `processCommitCmd` in `commands.js`) still use `execSync` with string interpolation. Audit and migrate for consistency and defense-in-depth.

### Other backlog items

- `git mind onboarding` as a guided walkthrough (not just a view)
Expand Down
105 changes: 93 additions & 12 deletions bin/git-mind.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Usage: git mind <command> [options]
*/

import { init, link, view, list, remove, nodes, status, at, importCmd, importMarkdownCmd, exportCmd, mergeCmd, installHooks, processCommitCmd, doctor, suggest, review, diff, set, unsetCmd, extensionList, extensionValidate, extensionAdd, extensionRemove } from '../src/cli/commands.js';
import { init, link, view, list, remove, nodes, status, at, importCmd, importMarkdownCmd, exportCmd, mergeCmd, installHooks, processCommitCmd, doctor, suggest, review, diff, set, unsetCmd, contentSet, contentShow, contentMeta, contentDelete, extensionList, extensionValidate, extensionAdd, extensionRemove } from '../src/cli/commands.js';
import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js';
import { createContext } from '../src/context-envelope.js';
import { registerBuiltinExtensions } from '../src/extension.js';
Expand Down Expand Up @@ -87,6 +87,17 @@ Commands:
review Review pending suggestions
--batch accept|reject Non-interactive batch mode
--json Output as JSON
content <subcommand> Manage node content
set <node> --from <file> Attach content from a file
--mime <type> Override MIME type detection
--json Output as JSON
show <node> Display attached content
--raw Output body only (no metadata header)
--json Output as JSON
meta <node> Show content metadata
--json Output as JSON
delete <node> Remove attached content
--json Output as JSON
extension <subcommand> Manage extensions
list List registered extensions
--json Output as JSON
Expand All @@ -101,7 +112,7 @@ Edge types: implements, augments, relates-to, blocks, belongs-to,
consumed-by, depends-on, documents`);
}

const BOOLEAN_FLAGS = new Set(['json', 'fix', 'dry-run', 'validate']);
const BOOLEAN_FLAGS = new Set(['json', 'fix', 'dry-run', 'validate', 'raw']);

/**
* Extract a ContextEnvelope from parsed flags.
Expand Down Expand Up @@ -141,6 +152,24 @@ function parseFlags(args) {
return flags;
}

/**
* Extract positional arguments from args, skipping --flag value pairs.
* @param {string[]} args
* @returns {string[]}
*/
function extractPositionals(args) {
const positionals = [];
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const flag = args[i].slice(2);
if (!BOOLEAN_FLAGS.has(flag) && i + 1 < args.length) i++; // skip value
} else {
positionals.push(args[i]);
}
}
return positionals;
}

switch (command) {
case 'init':
await init(cwd);
Expand All @@ -166,16 +195,7 @@ switch (command) {
case 'view': {
const viewArgs = args.slice(1);
const viewFlags = parseFlags(viewArgs);
// Collect positionals: skip flags and their consumed values
const viewPositionals = [];
for (let i = 0; i < viewArgs.length; i++) {
if (viewArgs[i].startsWith('--')) {
const flag = viewArgs[i].slice(2);
if (!BOOLEAN_FLAGS.has(flag) && i + 1 < viewArgs.length) i++; // skip value
} else {
viewPositionals.push(viewArgs[i]);
}
}
const viewPositionals = extractPositionals(viewArgs);
const viewCtx = contextFromFlags(viewFlags);
await view(cwd, viewPositionals[0], {
scope: viewFlags.scope,
Expand Down Expand Up @@ -373,6 +393,67 @@ switch (command) {
break;
}

case 'content': {
const contentSubCmd = args[1];
const contentArgs = args.slice(2);
const contentFlags = parseFlags(contentArgs);
const contentPositionals = extractPositionals(contentArgs);
switch (contentSubCmd) {
case 'set': {
const setNode = contentPositionals[0];
const fromFile = contentFlags.from;
if (!setNode || !fromFile) {
console.error('Usage: git mind content set <node> --from <file> [--mime <type>] [--json]');
process.exitCode = 1;
break;
}
await contentSet(cwd, setNode, fromFile, {
mime: contentFlags.mime,
json: contentFlags.json ?? false,
});
break;
}
case 'show': {
const showNode = contentPositionals[0];
if (!showNode) {
console.error('Usage: git mind content show <node> [--raw] [--json]');
process.exitCode = 1;
break;
}
await contentShow(cwd, showNode, {
raw: contentFlags.raw ?? false,
json: contentFlags.json ?? false,
});
break;
}
case 'meta': {
const metaNode = contentPositionals[0];
if (!metaNode) {
console.error('Usage: git mind content meta <node> [--json]');
process.exitCode = 1;
break;
}
await contentMeta(cwd, metaNode, { json: contentFlags.json ?? false });
break;
}
case 'delete': {
const deleteNode = contentPositionals[0];
if (!deleteNode) {
console.error('Usage: git mind content delete <node> [--json]');
process.exitCode = 1;
break;
}
await contentDelete(cwd, deleteNode, { json: contentFlags.json ?? false });
break;
}
default:
console.error(`Unknown content subcommand: ${contentSubCmd ?? '(none)'}`);
console.error('Usage: git mind content <set|show|meta|delete>');
process.exitCode = 1;
}
break;
}

case 'extension': {
await registerBuiltinExtensions();
const subCmd = args[1];
Expand Down
16 changes: 16 additions & 0 deletions docs/contracts/cli/content-delete.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-delete.schema.json",
"title": "git-mind content delete --json",
"description": "Content deletion result from `git mind content delete --json`",
"type": "object",
"required": ["schemaVersion", "command", "nodeId", "removed", "previousSha"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"command": { "type": "string", "const": "content-delete" },
"nodeId": { "type": "string", "minLength": 1 },
"removed": { "type": "boolean" },
"previousSha": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$" }
}
}
37 changes: 37 additions & 0 deletions docs/contracts/cli/content-meta.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-meta.schema.json",
"title": "git-mind content meta --json",
"description": "Content metadata result from `git mind content meta --json`",
"type": "object",
"required": ["schemaVersion", "command", "nodeId", "hasContent"],
"additionalProperties": false,
"if": {
"properties": { "hasContent": { "const": true } },
"required": ["hasContent"]
},
"then": {
"required": ["sha", "mime", "size"],
"properties": {
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
"mime": { "type": "string", "minLength": 1 },
"size": { "type": "integer", "minimum": 0 }
}
},
"else": {
"properties": {
"sha": false,
"mime": false,
"size": false
}
},
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"command": { "type": "string", "const": "content-meta" },
"nodeId": { "type": "string", "minLength": 1 },
"hasContent": { "type": "boolean" },
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
"mime": { "type": "string", "minLength": 1 },
"size": { "type": "integer", "minimum": 0 }
}
}
17 changes: 17 additions & 0 deletions docs/contracts/cli/content-set.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-set.schema.json",
"title": "git-mind content set --json",
"description": "Content attachment result from `git mind content set --json`",
"type": "object",
"required": ["schemaVersion", "command", "nodeId", "sha", "mime", "size"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"command": { "type": "string", "const": "content-set" },
"nodeId": { "type": "string", "minLength": 1 },
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
"mime": { "type": "string", "minLength": 1 },
"size": { "type": "integer", "minimum": 0 }
}
}
18 changes: 18 additions & 0 deletions docs/contracts/cli/content-show.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-show.schema.json",
"title": "git-mind content show --json",
"description": "Content display result from `git mind content show --json`",
"type": "object",
"required": ["schemaVersion", "command", "nodeId", "content", "sha", "mime", "size"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "type": "integer", "const": 1 },
"command": { "type": "string", "const": "content-show" },
"nodeId": { "type": "string", "minLength": 1 },
"content": { "type": "string" },
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
"mime": { "type": "string", "minLength": 1 },
"size": { "type": "integer", "minimum": 0 }
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@neuroglyph/git-mind",
"version": "3.2.0",
"version": "3.3.0",
"description": "A project knowledge graph tool built on git-warp",
"type": "module",
"license": "Apache-2.0",
Expand Down
Loading
Loading