@polderlabs/bizar 3.7.2 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/bin.mjs CHANGED
@@ -28,6 +28,7 @@ import { runInit } from './init.mjs';
28
28
  import { runExport } from './export.mjs';
29
29
  import runPlan from './plan.mjs';
30
30
  import { runUpdate } from './update.mjs';
31
+ import { runGraph } from './graph.mjs';
31
32
  import { ensureSetup, checkSetupStatus } from './bootstrap.mjs';
32
33
 
33
34
  const args = process.argv.slice(2);
@@ -86,6 +87,7 @@ function showHelp() {
86
87
  bizar init Initialize .bizar/ in current project
87
88
  bizar export [target] Export agents/rules to another harness
88
89
  bizar plan <subcommand> Manage visual plans
90
+ bizar graph Per-project knowledge graph (powered by graphify)
89
91
  bizar test-gate Detect & run the project's test suite
90
92
  bizar update Update opencode, bizar, and/or bizar-plugin
91
93
  bizar service Manage the background service daemon
@@ -126,7 +128,29 @@ function showInitHelp() {
126
128
 
127
129
  Description:
128
130
  Detects the project stack, creates .bizar/PROJECT.md and
129
- .bizar/AGENTS_SELF_IMPROVEMENT.md, and installs relevant skills.
131
+ .bizar/AGENTS_SELF_IMPROVEMENT.md, installs relevant skills,
132
+ and builds the per-project knowledge graph in .bizar/graph/
133
+ (requires graphify — pip install graphifyy — otherwise the
134
+ graph step is skipped gracefully).
135
+ `);
136
+ }
137
+
138
+ function showGraphHelp() {
139
+ console.log(`
140
+ bizar graph — Per-project knowledge graph (powered by graphify)
141
+
142
+ Usage:
143
+ bizar graph build # full build of the project graph
144
+ bizar graph update # incremental rebuild
145
+ bizar graph query "<text>" # query the graph
146
+ bizar graph path "<A>" "<B>" # shortest path between concepts
147
+ bizar graph explain "<X>" # all nodes related to a concept
148
+ bizar graph watch # watch for changes and rebuild
149
+ bizar graph status # show graph path, size, node/edge/community counts
150
+ bizar graph install # install graphify + drop OpenCode skill/plugin
151
+
152
+ Requires Python 3.10+ and \`graphify\` (pip install graphifyy).
153
+ Graph data lives in .bizar/graph/ inside this project.
130
154
  `);
131
155
  }
132
156
 
@@ -157,18 +181,40 @@ function showInstallHelp() {
157
181
 
158
182
  function showUpdateHelp() {
159
183
  console.log(`
160
- bizar update — Update opencode, bizar, and/or bizar-plugin
184
+ bizar update — Update opencode, bizar, bizar-dash, and/or bizar-plugin
161
185
 
162
186
  Usage:
163
- bizar update
164
- bizar update --all
165
- bizar update opencode
166
- bizar update bizar
167
- bizar update plugin
168
-
169
- Description:
170
- Prompts interactively by default. Use --all for non-interactive
171
- updates, or pass explicit component names.
187
+ bizar update Interactive prompt for components
188
+ bizar update --all Update every component
189
+ bizar update opencode bizar dash Update specific components
190
+ bizar update --all --yes Update everything + auto-kill running instances
191
+ bizar update --no-restart Don't auto-restart the dashboard after update
192
+ bizar update --help Show this help
193
+
194
+ Components:
195
+ opencode the opencode CLI itself
196
+ bizar @polderlabs/bizar (this CLI + installer + agents + rules)
197
+ dash @polderlabs/bizar-dash (web dashboard + TUI)
198
+ plugin @polderlabs/bizar-plugin (opencode plugin)
199
+
200
+ Behavior:
201
+ • Detects running Bizar instances (background service daemon, web
202
+ dashboard) by reading ~/.config/bizar/{service,dashboard}.pid and
203
+ cleaning up any stale or empty PID files.
204
+ • Warns the user explicitly before killing each instance. Pass
205
+ --yes / -y / --force to skip the confirmation (required for
206
+ non-interactive shells).
207
+ • Sends SIGTERM, waits up to 5s, escalates to SIGKILL if needed.
208
+ • Re-runs the install script so the deployed plugin source matches
209
+ the just-upgraded npm version (avoids the version-skew trap).
210
+ • If the dashboard was running and bizar / bizar-dash were updated,
211
+ spawns a fresh detached dashboard process with the new code
212
+ (skipped with --no-restart).
213
+
214
+ Examples:
215
+ bizar update --all --yes Headless full update + restart
216
+ bizar update dash Update only the dashboard
217
+ bizar update plugin --no-restart Plugin-only, leave dashboard alone
172
218
  `);
173
219
  }
174
220
 
@@ -354,6 +400,9 @@ async function main() {
354
400
  } else if (args[0] === 'init') {
355
401
  if (isHelpRequest) showInitHelp();
356
402
  else await runInit(process.cwd());
403
+ } else if (args[0] === 'graph') {
404
+ if (isHelpRequest) showGraphHelp();
405
+ else await runGraph(args.slice(1));
357
406
  } else if (args[0] === 'export') {
358
407
  if (isHelpRequest) showExportHelp();
359
408
  else await runExport(parseFlag('--target'));
package/cli/graph.mjs ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * cli/graph.mjs
3
+ *
4
+ * `bizar graph` — Per-project knowledge graph powered by graphify.
5
+ * Graph data lives in .bizar/graph/ inside the project.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ // ── Constants ─────────────────────────────────────────────────────────────────
14
+
15
+ export const GRAPH_DIR = '.bizar/graph';
16
+ export const GRAPHIFY_OUT_ENV = 'GRAPHIFY_OUT';
17
+
18
+ // ── Python detection ───────────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Find a Python 3 interpreter on PATH.
22
+ * Returns the absolute path string, or null if none found.
23
+ */
24
+ export function findPython() {
25
+ // Prefer python3, fall back to python
26
+ for (const name of ['python3', 'python']) {
27
+ try {
28
+ const result = spawnSync(name, ['--version'], { encoding: 'utf8', timeout: 5000 });
29
+ if (result.status === 0 && result.stdout.includes('Python 3')) {
30
+ return name;
31
+ }
32
+ } catch {
33
+ // try next
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Check whether graphify is available via `python3 -m graphify --version`.
41
+ * Returns true if available, false otherwise.
42
+ */
43
+ function checkGraphify(python) {
44
+ try {
45
+ const result = spawnSync(python, ['-m', 'graphify', '--version'], {
46
+ encoding: 'utf8',
47
+ timeout: 10000,
48
+ env: { ...process.env },
49
+ });
50
+ return result.status === 0;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ // ── Graph stats parsing ────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Parse .bizar/graph/graph.json and return stats.
60
+ * Returns null if the file does not exist or cannot be parsed.
61
+ *
62
+ * Expected NetworkX node-link JSON shape:
63
+ * {
64
+ * "directed": false,
65
+ * "multigraph": false,
66
+ * "graph": {},
67
+ * "nodes": [{ "id": "...", "community": 0, ... }],
68
+ * "links": [{ "source": "...", "target": "...", ... }]
69
+ * }
70
+ */
71
+ export function parseGraphStats(jsonPath) {
72
+ if (!existsSync(jsonPath)) return null;
73
+ try {
74
+ const content = readFileSync(jsonPath, 'utf8');
75
+ const graph = JSON.parse(content);
76
+
77
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
78
+ const links = Array.isArray(graph.links) ? graph.links : [];
79
+
80
+ // Count unique community values among nodes
81
+ const communitySet = new Set();
82
+ for (const node of nodes) {
83
+ if (node.community !== undefined && node.community !== null) {
84
+ communitySet.add(node.community);
85
+ }
86
+ }
87
+
88
+ const stats = statSync(jsonPath);
89
+
90
+ return {
91
+ nodes: nodes.length,
92
+ edges: links.length,
93
+ communities: communitySet.size,
94
+ lastModified: stats.mtime.toISOString(),
95
+ sizeBytes: stats.size,
96
+ };
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ // ── Gitignore guard ────────────────────────────────────────────────────────────
103
+
104
+ const GRAPH_GITIGNORE = `cache/
105
+ .graphify_python
106
+ cost.json
107
+ `;
108
+
109
+ /**
110
+ * Ensure .bizar/graph/.gitignore exists so heavy cache artefacts are not committed.
111
+ * Called after a successful first build.
112
+ */
113
+ function ensureGitignore(graphDir) {
114
+ const gitignorePath = join(graphDir, '.gitignore');
115
+ if (!existsSync(gitignorePath)) {
116
+ writeFileSync(gitignorePath, GRAPH_GITIGNORE, 'utf8');
117
+ }
118
+ }
119
+
120
+ // ── Run helpers ───────────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Spawn a graphify command with GRAPHIFY_OUT set to GRAPH_DIR.
124
+ * Streams stdout/stderr to the terminal. Returns the exit code.
125
+ */
126
+ function runGraphify(python, subArgs, extraEnv = {}) {
127
+ const env = { ...process.env, [GRAPHIFY_OUT_ENV]: GRAPH_DIR };
128
+ const result = spawnSync(python, ['-m', 'graphify', ...subArgs], {
129
+ stdio: 'inherit',
130
+ env,
131
+ timeout: 0, // no timeout — user may run long builds
132
+ });
133
+ return result.status ?? 1;
134
+ }
135
+
136
+ // ── Subcommand handlers ────────────────────────────────────────────────────────
137
+
138
+ async function cmdBuild(python) {
139
+ console.log(chalk.dim(' Running full graphify pipeline...'));
140
+ const code = runGraphify(python, ['.']);
141
+ if (code === 0) {
142
+ ensureGitignore(GRAPH_DIR);
143
+ console.log(chalk.green(`\n ✓ Graph built → ${GRAPH_DIR}/\n`));
144
+ }
145
+ return code;
146
+ }
147
+
148
+ async function cmdUpdate(python) {
149
+ console.log(chalk.dim(' Running incremental graph update...'));
150
+ return runGraphify(python, ['.', '--update']);
151
+ }
152
+
153
+ async function cmdQuery(python, query) {
154
+ if (!query) {
155
+ console.log(chalk.red(' Error: query text required.'));
156
+ console.log(chalk.dim(' Usage: bizar graph query "<search term>"\n'));
157
+ return 1;
158
+ }
159
+ return runGraphify(python, ['query', query]);
160
+ }
161
+
162
+ async function cmdPath(python, a, b) {
163
+ if (!a || !b) {
164
+ console.log(chalk.red(' Error: two concept names required.'));
165
+ console.log(chalk.dim(' Usage: bizar graph path "<A>" "<B>"\n'));
166
+ return 1;
167
+ }
168
+ return runGraphify(python, ['path', a, b]);
169
+ }
170
+
171
+ async function cmdExplain(python, concept) {
172
+ if (!concept) {
173
+ console.log(chalk.red(' Error: concept name required.'));
174
+ console.log(chalk.dim(' Usage: bizar graph explain "<X>"\n'));
175
+ return 1;
176
+ }
177
+ return runGraphify(python, ['explain', concept]);
178
+ }
179
+
180
+ async function cmdWatch(python) {
181
+ console.log(chalk.dim(' Starting graphify watch mode (Ctrl-C to stop)...'));
182
+ return runGraphify(python, ['.', '--watch']);
183
+ }
184
+
185
+ async function cmdStatus() {
186
+ const graphJsonPath = join(GRAPH_DIR, 'graph.json');
187
+ const stats = parseGraphStats(graphJsonPath);
188
+
189
+ console.log(chalk.bold.hex('#10b981')('\n ᛗ BIZAR GRAPH STATUS ᛗ\n'));
190
+ console.log(` Path: ${GRAPH_DIR}/`);
191
+
192
+ if (!existsSync(GRAPH_DIR)) {
193
+ console.log(` Exists: ${chalk.yellow('no')} (run \`bizar graph build\` first)`);
194
+ console.log();
195
+ return 0;
196
+ }
197
+
198
+ if (!stats) {
199
+ console.log(` Exists: ${chalk.yellow('no graph.json found')}`);
200
+ console.log();
201
+ return 0;
202
+ }
203
+
204
+ console.log(` Exists: ${chalk.green('yes')}`);
205
+ console.log(` Size: ${(stats.sizeBytes / 1024).toFixed(1)} KB`);
206
+ console.log(` Last built: ${stats.lastModified}`);
207
+ console.log(` Nodes: ${stats.nodes}`);
208
+ console.log(` Edges: ${stats.edges}`);
209
+ console.log(` Communities:${stats.communities}`);
210
+ console.log();
211
+ return 0;
212
+ }
213
+
214
+ async function cmdInstall() {
215
+ const python = findPython();
216
+ if (!python) {
217
+ console.error(chalk.red(' Error: graphify requires Python 3.10+.'));
218
+ console.error(chalk.dim(' Install Python from https://python.org or via your package manager, then re-run.\n'));
219
+ return 1;
220
+ }
221
+
222
+ console.log(chalk.dim(' Installing graphify via pip...\n'));
223
+
224
+ // pip install graphifyy
225
+ console.log(chalk.dim(' $ pip install graphifyy'));
226
+ let result = spawnSync('pip', ['install', 'graphifyy'], { stdio: 'inherit' });
227
+ if (result.status !== 0) {
228
+ console.log(chalk.red(' ✗ pip install graphifyy failed.\n'));
229
+ return 1;
230
+ }
231
+
232
+ // graphify install --platform opencode --project
233
+ console.log(chalk.dim('\n $ graphify install --platform opencode --project'));
234
+ result = spawnSync('graphify', ['install', '--platform', 'opencode', '--project'], {
235
+ stdio: 'inherit',
236
+ });
237
+ if (result.status !== 0) {
238
+ console.log(chalk.red(' ✗ graphify install failed.\n'));
239
+ return 1;
240
+ }
241
+
242
+ console.log(chalk.green('\n ✓ graphify installed and OpenCode plugin configured.\n'));
243
+ return 0;
244
+ }
245
+
246
+ // ── Help ──────────────────────────────────────────────────────────────────────
247
+
248
+ export function showGraphHelp() {
249
+ console.log(`
250
+ ${chalk.bold.hex('#10b981')('bizar graph')} — Per-project knowledge graph (powered by graphify)
251
+
252
+ ${chalk.dim('Usage:')}
253
+ ${chalk.cyan('bizar graph build')} # full build of the project graph
254
+ ${chalk.cyan('bizar graph update')} # incremental rebuild
255
+ ${chalk.cyan('bizar graph query "<text>"')} # query the graph
256
+ ${chalk.cyan('bizar graph path "<A>" "<B>"')} # shortest path between concepts
257
+ ${chalk.cyan('bizar graph explain "<X>"')} # all nodes related to a concept
258
+ ${chalk.cyan('bizar graph watch')} # watch for changes and rebuild
259
+ ${chalk.cyan('bizar graph status')} # show graph path, size, node/edge/community counts
260
+ ${chalk.cyan('bizar graph install')} # install graphify + drop OpenCode skill/plugin
261
+
262
+ ${chalk.dim('Requires:')} Python 3.10+ and \`graphify\` (${chalk.cyan('pip install graphifyy')})
263
+
264
+ ${chalk.dim('Graph data lives in')} ${chalk.cyan('.bizar/graph/')} ${chalk.dim('inside this project.')}
265
+ `);
266
+ }
267
+
268
+ // ── Main entry point ───────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * `bizar graph` entry point.
272
+ *
273
+ * @param {string[]} args — subcommand + args after 'graph'
274
+ */
275
+ export async function runGraph(args) {
276
+ // Show help when called with no args or --help / -h
277
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
278
+ showGraphHelp();
279
+ return 0;
280
+ }
281
+
282
+ const sub = args[0];
283
+
284
+ // status and install are the only subcommands that don't need graphify
285
+ if (sub !== 'status' && sub !== 'install') {
286
+ // Detect Python
287
+ const python = findPython();
288
+ if (!python) {
289
+ console.error(chalk.red(' Error: graphify requires Python 3.10+.'));
290
+ console.error(chalk.dim(' Install Python from https://python.org or via your package manager, then re-run.\n'));
291
+ return 1;
292
+ }
293
+
294
+ // Detect graphify
295
+ if (!checkGraphify(python)) {
296
+ console.error(chalk.red(' Error: graphify is not installed.'));
297
+ console.error(chalk.dim(' Install it with:'));
298
+ console.error(chalk.cyan(' pip install graphifyy\n'));
299
+ console.error(chalk.dim(' Or run:'));
300
+ console.error(chalk.cyan(' bizar graph install\n'));
301
+ return 1;
302
+ }
303
+
304
+ // Ensure .bizar/ directory exists (graphify will create graph/ inside it)
305
+ mkdirSync(GRAPH_DIR, { recursive: true });
306
+
307
+ switch (sub) {
308
+ case 'build':
309
+ return await cmdBuild(python);
310
+ case 'update':
311
+ return await cmdUpdate(python);
312
+ case 'query':
313
+ return await cmdQuery(python, args.slice(1).join(' '));
314
+ case 'path':
315
+ return await cmdPath(python, args[1], args[2]);
316
+ case 'explain':
317
+ return await cmdExplain(python, args[1]);
318
+ case 'watch':
319
+ return await cmdWatch(python);
320
+ default:
321
+ console.error(chalk.red(` Error: unknown subcommand '${sub}'.\n`));
322
+ showGraphHelp();
323
+ return 1;
324
+ }
325
+ }
326
+
327
+ // subcommands that don't need graphify
328
+ switch (sub) {
329
+ case 'status':
330
+ return await cmdStatus();
331
+ case 'install':
332
+ return await cmdInstall();
333
+ default:
334
+ // unreachable — already caught unknown subcommands above
335
+ return 1;
336
+ }
337
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * cli/graph.test.mjs
3
+ *
4
+ * Tests for the `bizar graph` subcommand.
5
+ * Uses Node's built-in node:test (no external test framework).
6
+ *
7
+ * Covered:
8
+ * - findPython() returns a string when Python is on PATH (or null)
9
+ * - parseGraphStats() returns null for nonexistent files
10
+ * - parseGraphStats() returns correct {nodes, edges, communities} from a fixture
11
+ * - GRAPH_DIR constant is '.bizar/graph'
12
+ * - help output contains all subcommand names
13
+ */
14
+
15
+ import { test, describe } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ // Resolve graph.mjs from this test file's location
22
+ const __dirname = dirname(fileURLToPath(import.meta.url));
23
+ const PROJECT_ROOT = join(__dirname, '..');
24
+
25
+ const {
26
+ findPython,
27
+ parseGraphStats,
28
+ showGraphHelp,
29
+ GRAPH_DIR,
30
+ } = await import('./graph.mjs');
31
+
32
+ // ── Constants ─────────────────────────────────────────────────────────────────
33
+
34
+ test('GRAPH_DIR equals ".bizar/graph"', () => {
35
+ assert.equal(GRAPH_DIR, '.bizar/graph');
36
+ });
37
+
38
+ // ── findPython ────────────────────────────────────────────────────────────────
39
+
40
+ describe('findPython()', () => {
41
+ test('returns a string when python3 or python is on PATH', () => {
42
+ const result = findPython();
43
+ // On most dev machines Python is installed; the test documents the expected shape
44
+ if (result !== null) {
45
+ assert.equal(typeof result, 'string');
46
+ assert.ok(result.length > 0);
47
+ }
48
+ });
49
+
50
+ test('returns null when neither python3 nor python is found', () => {
51
+ // We can't easily stub PATH in this test runner without副作用,
52
+ // so we verify the function is deterministic and returns the same value
53
+ // across two calls (proving it doesn't throw or return garbage).
54
+ const first = findPython();
55
+ const second = findPython();
56
+ assert.equal(first, second, 'findPython should be deterministic');
57
+ });
58
+ });
59
+
60
+ // ── parseGraphStats ────────────────────────────────────────────────────────────
61
+
62
+ describe('parseGraphStats()', () => {
63
+ test('returns null for a nonexistent path', () => {
64
+ const result = parseGraphStats('/nonexistent/path/graph.json');
65
+ assert.equal(result, null);
66
+ });
67
+
68
+ test('returns null for a file that is not valid JSON', () => {
69
+ const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test');
70
+ mkdirSync(tmpDir, { recursive: true });
71
+ try {
72
+ const badPath = join(tmpDir, 'graph.json');
73
+ writeFileSync(badPath, '{ not json', 'utf8');
74
+ const result = parseGraphStats(badPath);
75
+ assert.equal(result, null);
76
+ } finally {
77
+ rmSync(tmpDir, { recursive: true });
78
+ }
79
+ });
80
+
81
+ test('returns zeroed stats when graph.json has null nodes/links arrays', () => {
82
+ const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test2');
83
+ mkdirSync(tmpDir, { recursive: true });
84
+ try {
85
+ const graphPath = join(tmpDir, 'graph.json');
86
+ writeFileSync(graphPath, JSON.stringify({ graph: {}, nodes: null, links: null }), 'utf8');
87
+ const result = parseGraphStats(graphPath);
88
+ // null arrays are treated as 0-length — parseGraphStats guards with Array.isArray
89
+ assert.notEqual(result, null);
90
+ assert.equal(result.nodes, 0);
91
+ assert.equal(result.edges, 0);
92
+ assert.equal(result.communities, 0);
93
+ } finally {
94
+ rmSync(tmpDir, { recursive: true });
95
+ }
96
+ });
97
+
98
+ test('returns correct stats from a minimal NetworkX node-link fixture', () => {
99
+ const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test3');
100
+ mkdirSync(tmpDir, { recursive: true });
101
+ try {
102
+ const graphPath = join(tmpDir, 'graph.json');
103
+
104
+ // NetworkX node-link JSON with 4 nodes, 3 links, 2 communities
105
+ const fixture = {
106
+ directed: false,
107
+ multigraph: false,
108
+ graph: {},
109
+ nodes: [
110
+ { id: 'alpha', community: 0 },
111
+ { id: 'beta', community: 0 },
112
+ { id: 'gamma', community: 1 },
113
+ { id: 'delta', community: 1 },
114
+ ],
115
+ links: [
116
+ { source: 'alpha', target: 'beta' },
117
+ { source: 'beta', target: 'gamma' },
118
+ { source: 'gamma', target: 'delta' },
119
+ ],
120
+ };
121
+
122
+ writeFileSync(graphPath, JSON.stringify(fixture), 'utf8');
123
+
124
+ const stats = parseGraphStats(graphPath);
125
+
126
+ assert.notEqual(stats, null);
127
+ assert.equal(stats.nodes, 4, 'should count 4 nodes');
128
+ assert.equal(stats.edges, 3, 'should count 3 links');
129
+ assert.equal(stats.communities, 2, 'should detect 2 unique communities (0 and 1)');
130
+ assert.ok(typeof stats.lastModified === 'string', 'lastModified should be ISO string');
131
+ assert.ok(typeof stats.sizeBytes === 'number', 'sizeBytes should be a number');
132
+ assert.ok(stats.sizeBytes > 0, 'sizeBytes should be > 0 for a real file');
133
+ } finally {
134
+ rmSync(tmpDir, { recursive: true });
135
+ }
136
+ });
137
+
138
+ test('handles nodes without a community field gracefully', () => {
139
+ const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test4');
140
+ mkdirSync(tmpDir, { recursive: true });
141
+ try {
142
+ const graphPath = join(tmpDir, 'graph.json');
143
+ const fixture = {
144
+ nodes: [
145
+ { id: 'orphan' }, // no community field
146
+ { id: 'solo', community: 0 },
147
+ { id: 'alone', community: 1 },
148
+ ],
149
+ links: [],
150
+ };
151
+ writeFileSync(graphPath, JSON.stringify(fixture), 'utf8');
152
+ const stats = parseGraphStats(graphPath);
153
+ assert.notEqual(stats, null);
154
+ assert.equal(stats.communities, 2, 'should count communities 0 and 1 only');
155
+ } finally {
156
+ rmSync(tmpDir, { recursive: true });
157
+ }
158
+ });
159
+ });
160
+
161
+ // ── Help output ───────────────────────────────────────────────────────────────
162
+
163
+ describe('showGraphHelp()', () => {
164
+ test('outputs all subcommand names', () => {
165
+ // Capture stdout
166
+ let output = '';
167
+ const originalLog = console.log;
168
+ console.log = (msg) => { output += msg + '\n'; };
169
+
170
+ try {
171
+ showGraphHelp();
172
+ } finally {
173
+ console.log = originalLog;
174
+ }
175
+
176
+ // Assert each expected subcommand keyword appears in the help text
177
+ const subcommands = ['build', 'update', 'query', 'path', 'explain', 'watch', 'status', 'install'];
178
+ for (const cmd of subcommands) {
179
+ assert.ok(
180
+ output.includes(cmd),
181
+ `help output should contain subcommand "${cmd}"`
182
+ );
183
+ }
184
+ });
185
+
186
+ test('mentions the GRAPH_DIR path', () => {
187
+ let output = '';
188
+ const originalLog = console.log;
189
+ console.log = (msg) => { output += msg + '\n'; };
190
+
191
+ try {
192
+ showGraphHelp();
193
+ } finally {
194
+ console.log = originalLog;
195
+ }
196
+
197
+ assert.ok(output.includes('.bizar/graph'), 'help should mention the graph directory');
198
+ });
199
+
200
+ test('mentions graphify pip install command', () => {
201
+ let output = '';
202
+ const originalLog = console.log;
203
+ console.log = (msg) => { output += msg + '\n'; };
204
+
205
+ try {
206
+ showGraphHelp();
207
+ } finally {
208
+ console.log = originalLog;
209
+ }
210
+
211
+ assert.ok(
212
+ output.includes('pip install graphifyy'),
213
+ 'help should mention pip install graphifyy'
214
+ );
215
+ });
216
+ });
217
+
218
+ console.log(' graph.mjs tests loaded — run with: node --test cli/graph.test.mjs');