Skip to content

Commit 3c4439f

Browse files
authored
Merge pull request #276 from neuroglyph/feat/m13-vessel-content
Resolved 21 review threads across 5 rounds; v3.3.0 — M13 VESSEL Content-on-Node.
2 parents 33bba20 + 4f26436 commit 3c4439f

File tree

14 files changed

+1042
-17
lines changed

14 files changed

+1042
-17
lines changed

CHANGELOG.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [3.3.0] - 2026-02-22
11+
1012
### Added
1113

14+
- **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)
15+
- **`git mind content set <node> --from <file>`** — Attach content from a file. MIME auto-detected from extension, `--mime` override supported. `--json` output (#273)
16+
- **`git mind content show <node>`** — Display attached content. `--raw` for piping (body only, no metadata header). `--json` output (#273)
17+
- **`git mind content meta <node>`** — Show content metadata (SHA, MIME, size, encoding). `--json` output (#273)
18+
- **`git mind content delete <node>`** — Remove content attachment from a node. `--json` output (#273)
19+
- **Content store API**`writeContent()`, `readContent()`, `getContentMeta()`, `hasContent()`, `deleteContent()` exported from public API (#272)
20+
- **SHA integrity verification**`readContent()` re-hashes retrieved blob and compares to stored SHA on every read (#272)
21+
- **JSON Schema contracts for content CLI**`content-set.schema.json`, `content-show.schema.json`, `content-meta.schema.json` in `docs/contracts/cli/` (#274)
1222
- **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)
1323
- **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)
1424
- **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)
@@ -17,11 +27,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1727
- **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)
1828
- **Deferred items documented in ROADMAP**#261 (ephemeral registration) and #269 (`--extension` flag) documented with rationale and recommended H2 slot
1929

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

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

2653
## [3.2.0] - 2026-02-17
2754

ROADMAP.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2187,6 +2187,16 @@ Two issues were filed during the M12 extension polish pass and intentionally def
21872187

21882188
**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.
21892189

2190+
### Content system enhancements (from M13 VESSEL review)
2191+
2192+
- **`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.
2193+
- **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.
2194+
- **`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.
2195+
2196+
### Codebase hardening (from M13 VESSEL review)
2197+
2198+
- **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.
2199+
21902200
### Other backlog items
21912201

21922202
- `git mind onboarding` as a guided walkthrough (not just a view)

bin/git-mind.js

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* Usage: git mind <command> [options]
66
*/
77

8-
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';
8+
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';
99
import { parseDiffRefs, collectDiffPositionals } from '../src/diff.js';
1010
import { createContext } from '../src/context-envelope.js';
1111
import { registerBuiltinExtensions } from '../src/extension.js';
@@ -87,6 +87,17 @@ Commands:
8787
review Review pending suggestions
8888
--batch accept|reject Non-interactive batch mode
8989
--json Output as JSON
90+
content <subcommand> Manage node content
91+
set <node> --from <file> Attach content from a file
92+
--mime <type> Override MIME type detection
93+
--json Output as JSON
94+
show <node> Display attached content
95+
--raw Output body only (no metadata header)
96+
--json Output as JSON
97+
meta <node> Show content metadata
98+
--json Output as JSON
99+
delete <node> Remove attached content
100+
--json Output as JSON
90101
extension <subcommand> Manage extensions
91102
list List registered extensions
92103
--json Output as JSON
@@ -101,7 +112,7 @@ Edge types: implements, augments, relates-to, blocks, belongs-to,
101112
consumed-by, depends-on, documents`);
102113
}
103114

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

106117
/**
107118
* Extract a ContextEnvelope from parsed flags.
@@ -141,6 +152,24 @@ function parseFlags(args) {
141152
return flags;
142153
}
143154

155+
/**
156+
* Extract positional arguments from args, skipping --flag value pairs.
157+
* @param {string[]} args
158+
* @returns {string[]}
159+
*/
160+
function extractPositionals(args) {
161+
const positionals = [];
162+
for (let i = 0; i < args.length; i++) {
163+
if (args[i].startsWith('--')) {
164+
const flag = args[i].slice(2);
165+
if (!BOOLEAN_FLAGS.has(flag) && i + 1 < args.length) i++; // skip value
166+
} else {
167+
positionals.push(args[i]);
168+
}
169+
}
170+
return positionals;
171+
}
172+
144173
switch (command) {
145174
case 'init':
146175
await init(cwd);
@@ -166,16 +195,7 @@ switch (command) {
166195
case 'view': {
167196
const viewArgs = args.slice(1);
168197
const viewFlags = parseFlags(viewArgs);
169-
// Collect positionals: skip flags and their consumed values
170-
const viewPositionals = [];
171-
for (let i = 0; i < viewArgs.length; i++) {
172-
if (viewArgs[i].startsWith('--')) {
173-
const flag = viewArgs[i].slice(2);
174-
if (!BOOLEAN_FLAGS.has(flag) && i + 1 < viewArgs.length) i++; // skip value
175-
} else {
176-
viewPositionals.push(viewArgs[i]);
177-
}
178-
}
198+
const viewPositionals = extractPositionals(viewArgs);
179199
const viewCtx = contextFromFlags(viewFlags);
180200
await view(cwd, viewPositionals[0], {
181201
scope: viewFlags.scope,
@@ -373,6 +393,67 @@ switch (command) {
373393
break;
374394
}
375395

396+
case 'content': {
397+
const contentSubCmd = args[1];
398+
const contentArgs = args.slice(2);
399+
const contentFlags = parseFlags(contentArgs);
400+
const contentPositionals = extractPositionals(contentArgs);
401+
switch (contentSubCmd) {
402+
case 'set': {
403+
const setNode = contentPositionals[0];
404+
const fromFile = contentFlags.from;
405+
if (!setNode || !fromFile) {
406+
console.error('Usage: git mind content set <node> --from <file> [--mime <type>] [--json]');
407+
process.exitCode = 1;
408+
break;
409+
}
410+
await contentSet(cwd, setNode, fromFile, {
411+
mime: contentFlags.mime,
412+
json: contentFlags.json ?? false,
413+
});
414+
break;
415+
}
416+
case 'show': {
417+
const showNode = contentPositionals[0];
418+
if (!showNode) {
419+
console.error('Usage: git mind content show <node> [--raw] [--json]');
420+
process.exitCode = 1;
421+
break;
422+
}
423+
await contentShow(cwd, showNode, {
424+
raw: contentFlags.raw ?? false,
425+
json: contentFlags.json ?? false,
426+
});
427+
break;
428+
}
429+
case 'meta': {
430+
const metaNode = contentPositionals[0];
431+
if (!metaNode) {
432+
console.error('Usage: git mind content meta <node> [--json]');
433+
process.exitCode = 1;
434+
break;
435+
}
436+
await contentMeta(cwd, metaNode, { json: contentFlags.json ?? false });
437+
break;
438+
}
439+
case 'delete': {
440+
const deleteNode = contentPositionals[0];
441+
if (!deleteNode) {
442+
console.error('Usage: git mind content delete <node> [--json]');
443+
process.exitCode = 1;
444+
break;
445+
}
446+
await contentDelete(cwd, deleteNode, { json: contentFlags.json ?? false });
447+
break;
448+
}
449+
default:
450+
console.error(`Unknown content subcommand: ${contentSubCmd ?? '(none)'}`);
451+
console.error('Usage: git mind content <set|show|meta|delete>');
452+
process.exitCode = 1;
453+
}
454+
break;
455+
}
456+
376457
case 'extension': {
377458
await registerBuiltinExtensions();
378459
const subCmd = args[1];
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-delete.schema.json",
4+
"title": "git-mind content delete --json",
5+
"description": "Content deletion result from `git mind content delete --json`",
6+
"type": "object",
7+
"required": ["schemaVersion", "command", "nodeId", "removed", "previousSha"],
8+
"additionalProperties": false,
9+
"properties": {
10+
"schemaVersion": { "type": "integer", "const": 1 },
11+
"command": { "type": "string", "const": "content-delete" },
12+
"nodeId": { "type": "string", "minLength": 1 },
13+
"removed": { "type": "boolean" },
14+
"previousSha": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$" }
15+
}
16+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-meta.schema.json",
4+
"title": "git-mind content meta --json",
5+
"description": "Content metadata result from `git mind content meta --json`",
6+
"type": "object",
7+
"required": ["schemaVersion", "command", "nodeId", "hasContent"],
8+
"additionalProperties": false,
9+
"if": {
10+
"properties": { "hasContent": { "const": true } },
11+
"required": ["hasContent"]
12+
},
13+
"then": {
14+
"required": ["sha", "mime", "size"],
15+
"properties": {
16+
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
17+
"mime": { "type": "string", "minLength": 1 },
18+
"size": { "type": "integer", "minimum": 0 }
19+
}
20+
},
21+
"else": {
22+
"properties": {
23+
"sha": false,
24+
"mime": false,
25+
"size": false
26+
}
27+
},
28+
"properties": {
29+
"schemaVersion": { "type": "integer", "const": 1 },
30+
"command": { "type": "string", "const": "content-meta" },
31+
"nodeId": { "type": "string", "minLength": 1 },
32+
"hasContent": { "type": "boolean" },
33+
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
34+
"mime": { "type": "string", "minLength": 1 },
35+
"size": { "type": "integer", "minimum": 0 }
36+
}
37+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-set.schema.json",
4+
"title": "git-mind content set --json",
5+
"description": "Content attachment result from `git mind content set --json`",
6+
"type": "object",
7+
"required": ["schemaVersion", "command", "nodeId", "sha", "mime", "size"],
8+
"additionalProperties": false,
9+
"properties": {
10+
"schemaVersion": { "type": "integer", "const": 1 },
11+
"command": { "type": "string", "const": "content-set" },
12+
"nodeId": { "type": "string", "minLength": 1 },
13+
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
14+
"mime": { "type": "string", "minLength": 1 },
15+
"size": { "type": "integer", "minimum": 0 }
16+
}
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://github.com/neuroglyph/git-mind/docs/contracts/cli/content-show.schema.json",
4+
"title": "git-mind content show --json",
5+
"description": "Content display result from `git mind content show --json`",
6+
"type": "object",
7+
"required": ["schemaVersion", "command", "nodeId", "content", "sha", "mime", "size"],
8+
"additionalProperties": false,
9+
"properties": {
10+
"schemaVersion": { "type": "integer", "const": 1 },
11+
"command": { "type": "string", "const": "content-show" },
12+
"nodeId": { "type": "string", "minLength": 1 },
13+
"content": { "type": "string" },
14+
"sha": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
15+
"mime": { "type": "string", "minLength": 1 },
16+
"size": { "type": "integer", "minimum": 0 }
17+
}
18+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@neuroglyph/git-mind",
3-
"version": "3.2.0",
3+
"version": "3.3.0",
44
"description": "A project knowledge graph tool built on git-warp",
55
"type": "module",
66
"license": "Apache-2.0",

0 commit comments

Comments
 (0)