@polderlabs/bizar 3.7.3 → 3.9.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/README.md CHANGED
@@ -65,18 +65,18 @@
65
65
  ### Agents
66
66
 
67
67
  | Agent | Rune | Model | Cost | Role |
68
- |---|---|---|---|---|---|
69
- | **Odin** | ᛟ | MiniMax-M3 | $0.30/M · $1.20/M out | Primary router — never executes, only delegates |
68
+ |---|---|---|---|---|
69
+ | **Odin** | ᛟ | OpenRouter minimax-m3 | $0.30/M · $1.20/M out | Primary router — never executes, only delegates |
70
70
  | **Frigg** | ᚠ | DeepSeek V4 Flash | **Free** | Read-only Q&A — ask questions, get answers, never modifies |
71
71
  | **Vör** | ᛢ | DeepSeek V4 Flash | **Free** | Clarifies ambiguous requests — asks questions until task is well-defined |
72
72
  | **Mimir** | ᛗ | DeepSeek V4 Flash | **Free** | Research & codebase exploration (Semble-first) |
73
73
  | **Heimdall** | ᚹ | DeepSeek V4 Flash | **Free** | Simple tasks, quick edits, file operations |
74
- | **Hermod** | ᚱ | MiniMax-M2.7 | $0.30/M · $1.20/M out | Git & GitHub operations (commit, PR, merge, rebase) |
75
- | **Thor** | ᚦ | MiniMax-M2.7 | $0.30/M · $1.20/M out | Moderate implementation, debugging, refactoring |
76
- | **Baldr** | ᛒ | MiniMax-M2.7 | $0.30/M · $1.20/M out | UI/UX design system (DESIGN.md plans, visual audits) |
77
- | **Tyr** | ᛏ | MiniMax-M3 | $0.30/M · $1.20/M out | Complex implementation, architecture, deep debugging |
74
+ | **Hermod** | ᚱ | OpenRouter minimax-m2.7 | $0.30/M · $1.20/M out | Git & GitHub operations (commit, PR, merge, rebase) |
75
+ | **Thor** | ᚦ | OpenRouter minimax-m2.7 | $0.30/M · $1.20/M out | Moderate implementation, debugging, refactoring |
76
+ | **Baldr** | ᛒ | OpenRouter minimax-m2.7 | $0.30/M · $1.20/M out | UI/UX design system (DESIGN.md plans, visual audits) |
77
+ | **Tyr** | ᛏ | OpenRouter minimax-m3 | $0.30/M · $1.20/M out | Complex implementation, architecture, deep debugging |
78
78
  | **Vidarr** | ᛉ | GPT-5.5 | Subscription | Last resort — GPT-5.5 |
79
- | **Forseti** | ᚨ | MiniMax-M3 | $0.30/M · $1.20/M out | Adversarial plan reviewer (edit: deny, audit-only) |
79
+ | **Forseti** | ᚨ | OpenRouter minimax-m3 | $0.30/M · $1.20/M out | Adversarial plan reviewer (edit: deny, audit-only) |
80
80
 
81
81
  ---
82
82
 
@@ -257,13 +257,11 @@ After installation, run `/connect` in opencode to add API keys:
257
257
  | Provider | Models | Auth |
258
258
  |---|---|---|
259
259
  | **OpenCode Zen** | `opencode/deepseek-v4-flash-free` | Free API key from [opencode.ai](https://opencode.ai) — create account, get key, no charges |
260
- | **minimax.io** | `minimax/MiniMax-M2.7`, `minimax/MiniMax-M3` | API key from [minimax.io](https://minimax.io) — **no `baseURL` override** |
260
+ | **OpenRouter** | `openrouter/minimax-m2.7`, `openrouter/minimax-m3` | API key from [openrouter.ai](https://openrouter.ai) |
261
261
  | **OpenAI** | `openai/gpt-5.5` | ChatGPT subscription (OAuth) |
262
262
 
263
263
  Then run `/models` to verify connectivity.
264
264
 
265
- > **Note:** Do not set `baseURL` on the minimax provider. The ai-sdk minimax provider uses its own default URL internally. Setting one causes 404 errors.
266
-
267
265
  ---
268
266
 
269
267
  ## 🧭 Routing
package/cli/audit.mjs CHANGED
@@ -78,8 +78,8 @@ export async function runAudit() {
78
78
  const model = modelMatch[1].trim();
79
79
  const validModels = [
80
80
  'opencode/deepseek-v4-flash-free',
81
- 'minimax/MiniMax-M2.7',
82
- 'minimax/MiniMax-M3',
81
+ 'openrouter/minimax-m2.7',
82
+ 'openrouter/minimax-m3',
83
83
  'openai/gpt-5.5',
84
84
  ];
85
85
  if (!validModels.includes(model)) {
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
 
@@ -376,6 +400,9 @@ async function main() {
376
400
  } else if (args[0] === 'init') {
377
401
  if (isHelpRequest) showInitHelp();
378
402
  else await runInit(process.cwd());
403
+ } else if (args[0] === 'graph') {
404
+ if (isHelpRequest) showGraphHelp();
405
+ else await runGraph(args.slice(1));
379
406
  } else if (args[0] === 'export') {
380
407
  if (isHelpRequest) showExportHelp();
381
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');