@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 +60 -11
- package/cli/graph.mjs +337 -0
- package/cli/graph.test.mjs +218 -0
- package/cli/init.mjs +35 -0
- package/cli/install.mjs +58 -0
- package/cli/update.mjs +371 -74
- package/config/AGENTS.md +53 -0
- package/config/agents/baldr.md +24 -0
- package/config/agents/forseti.md +9 -0
- package/config/agents/heimdall.md +24 -0
- package/config/agents/hermod.md +20 -0
- package/config/agents/mimir.md +24 -0
- package/config/agents/odin.md +45 -0
- package/config/agents/thor.md +24 -0
- package/config/agents/tyr.md +24 -0
- package/config/agents/vidarr.md +24 -0
- package/config/commands/init.md +22 -1
- package/package.json +1 -1
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,
|
|
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
|
|
167
|
-
bizar update
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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');
|