whygraph 0.1.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/LICENSE +21 -0
- package/README.md +205 -0
- package/dist/cli/commands/config.d.ts +14 -0
- package/dist/cli/commands/config.js +123 -0
- package/dist/cli/commands/down.d.ts +9 -0
- package/dist/cli/commands/down.js +46 -0
- package/dist/cli/commands/init.d.ts +17 -0
- package/dist/cli/commands/init.js +144 -0
- package/dist/cli/commands/issues.d.ts +10 -0
- package/dist/cli/commands/issues.js +376 -0
- package/dist/cli/commands/mcp.d.ts +2 -0
- package/dist/cli/commands/mcp.js +9 -0
- package/dist/cli/commands/restart.d.ts +11 -0
- package/dist/cli/commands/restart.js +43 -0
- package/dist/cli/commands/serve.d.ts +14 -0
- package/dist/cli/commands/serve.js +132 -0
- package/dist/cli/commands/server-utils.d.ts +6 -0
- package/dist/cli/commands/server-utils.js +94 -0
- package/dist/cli/commands/status.d.ts +11 -0
- package/dist/cli/commands/status.js +97 -0
- package/dist/cli/commands/up.d.ts +13 -0
- package/dist/cli/commands/up.js +62 -0
- package/dist/cli/commands/validate.d.ts +14 -0
- package/dist/cli/commands/validate.js +88 -0
- package/dist/cli/commands/viz.d.ts +7 -0
- package/dist/cli/commands/viz.js +97 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +33 -0
- package/dist/entity/id.d.ts +8 -0
- package/dist/entity/id.js +48 -0
- package/dist/entity/issues.d.ts +12 -0
- package/dist/entity/issues.js +68 -0
- package/dist/entity/parser.d.ts +6 -0
- package/dist/entity/parser.js +166 -0
- package/dist/entity/types.d.ts +54 -0
- package/dist/entity/types.js +21 -0
- package/dist/entity/validate.d.ts +12 -0
- package/dist/entity/validate.js +136 -0
- package/dist/entity/writer.d.ts +16 -0
- package/dist/entity/writer.js +142 -0
- package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
- package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
- package/dist/frontend/index.html +14 -0
- package/dist/graph/cascade.d.ts +10 -0
- package/dist/graph/cascade.js +49 -0
- package/dist/graph/decisions.d.ts +11 -0
- package/dist/graph/decisions.js +27 -0
- package/dist/graph/gaps.d.ts +10 -0
- package/dist/graph/gaps.js +58 -0
- package/dist/graph/nodes.d.ts +20 -0
- package/dist/graph/nodes.js +33 -0
- package/dist/graph/projection.d.ts +6 -0
- package/dist/graph/projection.js +44 -0
- package/dist/graph/query.d.ts +15 -0
- package/dist/graph/query.js +82 -0
- package/dist/graph/search.d.ts +2 -0
- package/dist/graph/search.js +23 -0
- package/dist/graph/supersede.d.ts +7 -0
- package/dist/graph/supersede.js +48 -0
- package/dist/graph/temporal.d.ts +13 -0
- package/dist/graph/temporal.js +28 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +10 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +340 -0
- package/dist/onboarding/interview.d.ts +22 -0
- package/dist/onboarding/interview.js +92 -0
- package/dist/onboarding/scan.d.ts +17 -0
- package/dist/onboarding/scan.js +106 -0
- package/dist/platform/rules.d.ts +8 -0
- package/dist/platform/rules.js +229 -0
- package/dist/server/core.d.ts +26 -0
- package/dist/server/core.js +111 -0
- package/dist/server/derived.d.ts +8 -0
- package/dist/server/derived.js +13 -0
- package/dist/server/etag.d.ts +9 -0
- package/dist/server/etag.js +25 -0
- package/dist/server/http.d.ts +13 -0
- package/dist/server/http.js +131 -0
- package/dist/server/pubsub.d.ts +12 -0
- package/dist/server/pubsub.js +19 -0
- package/dist/server/schema.d.ts +2 -0
- package/dist/server/schema.js +362 -0
- package/dist/server/stale-refs.d.ts +7 -0
- package/dist/server/stale-refs.js +23 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +98 -0
- package/dist/server/worktree-watcher.d.ts +20 -0
- package/dist/server/worktree-watcher.js +79 -0
- package/dist/server/worktree.d.ts +22 -0
- package/dist/server/worktree.js +84 -0
- package/package.json +73 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import * as yaml from "js-yaml";
|
|
5
|
+
export function findWhygraphDir(startDir) {
|
|
6
|
+
let current = resolve(startDir);
|
|
7
|
+
const root = resolve("/");
|
|
8
|
+
while (current !== root) {
|
|
9
|
+
const candidate = join(current, ".whygraph");
|
|
10
|
+
if (existsSync(candidate)) {
|
|
11
|
+
return current;
|
|
12
|
+
}
|
|
13
|
+
const parent = resolve(current, "..");
|
|
14
|
+
/* v8 ignore next 2 */
|
|
15
|
+
if (parent === current)
|
|
16
|
+
break;
|
|
17
|
+
current = parent;
|
|
18
|
+
}
|
|
19
|
+
/* v8 ignore next 3 */
|
|
20
|
+
if (existsSync(join(root, ".whygraph"))) {
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function getConfiguredPort(projectDir) {
|
|
26
|
+
const configPath = join(projectDir, ".whygraph", "config.yaml");
|
|
27
|
+
try {
|
|
28
|
+
const content = readFileSync(configPath, "utf-8");
|
|
29
|
+
const config = yaml.load(content);
|
|
30
|
+
return config.serverPort ?? 4777;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 4777;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function killProcessOnPort(port) {
|
|
37
|
+
try {
|
|
38
|
+
const result = execSync(`lsof -ti:${port}`, { encoding: "utf-8" }).trim();
|
|
39
|
+
if (result) {
|
|
40
|
+
const pids = result.split("\n").map((p) => p.trim()).filter(Boolean);
|
|
41
|
+
for (const pid of pids) {
|
|
42
|
+
try {
|
|
43
|
+
execSync(`kill ${pid}`);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Process may have already exited
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No process on that port
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
export async function waitForPortFree(port, timeoutMs = 3000) {
|
|
58
|
+
const start = Date.now();
|
|
59
|
+
while (Date.now() - start < timeoutMs) {
|
|
60
|
+
try {
|
|
61
|
+
await fetch(`http://localhost:${port}/api/health`);
|
|
62
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Port ${port} still in use after ${timeoutMs}ms`);
|
|
69
|
+
}
|
|
70
|
+
export async function waitForServerReady(port, timeoutMs = 5000) {
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
while (Date.now() - start < timeoutMs) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`http://localhost:${port}/api/health`);
|
|
75
|
+
/* v8 ignore next 1 */
|
|
76
|
+
if (res.ok)
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not ready yet
|
|
81
|
+
}
|
|
82
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`Server not ready on port ${port} after ${timeoutMs}ms`);
|
|
85
|
+
}
|
|
86
|
+
export function isServerRunning(port) {
|
|
87
|
+
try {
|
|
88
|
+
execSync(`lsof -ti:${port}`, { encoding: "utf-8" });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
export interface StatusResult {
|
|
3
|
+
serverRunning: boolean;
|
|
4
|
+
serverPort: number;
|
|
5
|
+
entityCount?: number;
|
|
6
|
+
nodeCount?: number;
|
|
7
|
+
decisionCount?: number;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function runStatus(targetDir: string): Promise<StatusResult>;
|
|
11
|
+
export declare function registerStatusCommand(program: Command): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
import { findWhygraphDir } from "./serve.js";
|
|
5
|
+
// ============================================================
|
|
6
|
+
// Core Logic
|
|
7
|
+
// ============================================================
|
|
8
|
+
export async function runStatus(targetDir) {
|
|
9
|
+
const projectDir = findWhygraphDir(targetDir);
|
|
10
|
+
if (!projectDir) {
|
|
11
|
+
throw new Error(`.whygraph/ not found. Run "whygraph init" first.`);
|
|
12
|
+
}
|
|
13
|
+
const configPath = join(projectDir, ".whygraph", "config.yaml");
|
|
14
|
+
if (!existsSync(configPath)) {
|
|
15
|
+
throw new Error(`.whygraph/config.yaml not found. Run "whygraph init" first.`);
|
|
16
|
+
}
|
|
17
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
18
|
+
const config = yaml.load(raw);
|
|
19
|
+
const port = config.serverPort;
|
|
20
|
+
// Try to connect to the server
|
|
21
|
+
try {
|
|
22
|
+
const healthRes = await fetch(`http://localhost:${port}/api/health`);
|
|
23
|
+
if (!healthRes.ok) {
|
|
24
|
+
return { serverRunning: false, serverPort: port, error: "Health check failed" };
|
|
25
|
+
}
|
|
26
|
+
// Query GraphQL for status
|
|
27
|
+
const gqlRes = await fetch(`http://localhost:${port}/api/graphql`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "Content-Type": "application/json" },
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
query: `{ status { running entityCount nodeCount decisionCount } }`,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
if (!gqlRes.ok) {
|
|
35
|
+
return { serverRunning: true, serverPort: port, error: "GraphQL query failed" };
|
|
36
|
+
}
|
|
37
|
+
const gqlData = (await gqlRes.json());
|
|
38
|
+
const status = gqlData.data?.status;
|
|
39
|
+
if (!status) {
|
|
40
|
+
return { serverRunning: true, serverPort: port, error: "Unexpected GraphQL response" };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
serverRunning: true,
|
|
44
|
+
serverPort: port,
|
|
45
|
+
entityCount: status.entityCount,
|
|
46
|
+
nodeCount: status.nodeCount,
|
|
47
|
+
decisionCount: status.decisionCount,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { serverRunning: false, serverPort: port };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ============================================================
|
|
55
|
+
// CLI Wiring
|
|
56
|
+
// ============================================================
|
|
57
|
+
export function registerStatusCommand(program) {
|
|
58
|
+
program
|
|
59
|
+
.command("status")
|
|
60
|
+
.description("Check the whygraph server status and entity counts")
|
|
61
|
+
.option("--json", "Output results as JSON")
|
|
62
|
+
.action(async (opts) => {
|
|
63
|
+
try {
|
|
64
|
+
const result = await runStatus(process.cwd());
|
|
65
|
+
if (opts.json) {
|
|
66
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
if (result.serverRunning) {
|
|
70
|
+
/* v8 ignore start */
|
|
71
|
+
const ec = result.entityCount ?? "unknown";
|
|
72
|
+
const nc = result.nodeCount ?? "unknown";
|
|
73
|
+
const dc = result.decisionCount ?? "unknown";
|
|
74
|
+
/* v8 ignore stop */
|
|
75
|
+
process.stdout.write(`Server: running (port ${result.serverPort})\n` +
|
|
76
|
+
` Entities: ${ec}\n` +
|
|
77
|
+
` Nodes: ${nc}\n` +
|
|
78
|
+
` Decisions: ${dc}\n`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
process.stdout.write(`Server: not running (port ${result.serverPort})\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
/* v8 ignore next 1 */
|
|
87
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
+
if (opts.json) {
|
|
89
|
+
process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
93
|
+
}
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
export declare function runUp(targetDir: string, options?: {
|
|
4
|
+
port?: number;
|
|
5
|
+
json?: boolean;
|
|
6
|
+
spawner?: typeof nodeSpawn;
|
|
7
|
+
waitForReady?: (port: number) => Promise<void>;
|
|
8
|
+
}): Promise<{
|
|
9
|
+
url: string;
|
|
10
|
+
port: number;
|
|
11
|
+
pid: number;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function registerUpCommand(program: Command): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { findWhygraphDir, getConfiguredPort, isServerRunning, waitForServerReady, } from "./server-utils.js";
|
|
5
|
+
export async function runUp(targetDir, options = {}) {
|
|
6
|
+
const projectDir = findWhygraphDir(targetDir);
|
|
7
|
+
if (!projectDir) {
|
|
8
|
+
throw new Error(`.whygraph/ not found. Run "whygraph init" first.`);
|
|
9
|
+
}
|
|
10
|
+
const port = options.port ?? getConfiguredPort(projectDir);
|
|
11
|
+
if (isServerRunning(port)) {
|
|
12
|
+
throw new Error(`Server already running on port ${port}. Use "whygraph restart" or "whygraph down" first.`);
|
|
13
|
+
}
|
|
14
|
+
// Spawn the serve command as a detached background process
|
|
15
|
+
const spawn = options.spawner ?? nodeSpawn;
|
|
16
|
+
const serveScript = join(dirname(fileURLToPath(import.meta.url)), "..", "index.js");
|
|
17
|
+
const child = spawn(process.execPath, [serveScript, "serve", "--port", String(port)], {
|
|
18
|
+
detached: true,
|
|
19
|
+
stdio: "ignore",
|
|
20
|
+
cwd: projectDir,
|
|
21
|
+
});
|
|
22
|
+
child.unref();
|
|
23
|
+
const pid = child.pid;
|
|
24
|
+
// Wait for the server to be ready
|
|
25
|
+
const waitFn = options.waitForReady ?? waitForServerReady;
|
|
26
|
+
await waitFn(port);
|
|
27
|
+
return {
|
|
28
|
+
url: `http://localhost:${port}`,
|
|
29
|
+
port,
|
|
30
|
+
pid,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function registerUpCommand(program) {
|
|
34
|
+
program
|
|
35
|
+
.command("up")
|
|
36
|
+
.description("Start the whygraph server in the background")
|
|
37
|
+
.option("--port <number>", "Port number")
|
|
38
|
+
.option("--json", "Output results as JSON")
|
|
39
|
+
.action(async (opts) => {
|
|
40
|
+
try {
|
|
41
|
+
const port = opts.port ? parseInt(opts.port, 10) : undefined;
|
|
42
|
+
const result = await runUp(process.cwd(), { port, json: opts.json });
|
|
43
|
+
if (opts.json) {
|
|
44
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
process.stdout.write(`whygraph server running at ${result.url} (pid ${result.pid})\n`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
/* v8 ignore next 1 */
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
if (opts.json) {
|
|
54
|
+
process.stdout.write(JSON.stringify({ error: message }) + "\n");
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
58
|
+
}
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
export interface FileError {
|
|
3
|
+
file: string;
|
|
4
|
+
field?: string;
|
|
5
|
+
message: string;
|
|
6
|
+
severity: "error" | "warning";
|
|
7
|
+
}
|
|
8
|
+
export interface ValidateResult {
|
|
9
|
+
filesChecked: number;
|
|
10
|
+
entitiesParsed: number;
|
|
11
|
+
errors: FileError[];
|
|
12
|
+
}
|
|
13
|
+
export declare function runValidate(whygraphDir: string): Promise<ValidateResult>;
|
|
14
|
+
export declare function registerValidateCommand(program: Command): void;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseEntity } from "../../entity/parser.js";
|
|
4
|
+
import { validateEntity, validateEntityRefs } from "../../entity/validate.js";
|
|
5
|
+
// ============================================================
|
|
6
|
+
// Core Logic
|
|
7
|
+
// ============================================================
|
|
8
|
+
export async function runValidate(whygraphDir) {
|
|
9
|
+
const graphDir = join(whygraphDir, ".whygraph", "graph");
|
|
10
|
+
if (!existsSync(graphDir)) {
|
|
11
|
+
return { filesChecked: 0, entitiesParsed: 0, errors: [] };
|
|
12
|
+
}
|
|
13
|
+
const files = readdirSync(graphDir).filter((f) => f.endsWith(".md"));
|
|
14
|
+
const errors = [];
|
|
15
|
+
const entityMap = new Map();
|
|
16
|
+
const filePathMap = new Map(); // entity id → file path
|
|
17
|
+
// First pass: parse all entities into a map
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
const filePath = join(graphDir, file);
|
|
20
|
+
const content = readFileSync(filePath, "utf-8");
|
|
21
|
+
const entity = parseEntity(content);
|
|
22
|
+
if (entity === null) {
|
|
23
|
+
errors.push({
|
|
24
|
+
file: filePath,
|
|
25
|
+
message: "Failed to parse entity from file",
|
|
26
|
+
severity: "error",
|
|
27
|
+
});
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
entityMap.set(entity.id, entity);
|
|
31
|
+
filePathMap.set(entity.id, filePath);
|
|
32
|
+
}
|
|
33
|
+
// Second pass: schema + cross-reference validation
|
|
34
|
+
for (const [id, entity] of entityMap) {
|
|
35
|
+
const filePath = filePathMap.get(id);
|
|
36
|
+
const schemaResult = validateEntity(entity);
|
|
37
|
+
for (const err of schemaResult.errors) {
|
|
38
|
+
errors.push({ file: filePath, field: err.field, message: err.message, severity: err.severity });
|
|
39
|
+
}
|
|
40
|
+
const refErrors = validateEntityRefs(entity, entityMap);
|
|
41
|
+
for (const err of refErrors) {
|
|
42
|
+
errors.push({ file: filePath, field: err.field, message: err.message, severity: err.severity });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
filesChecked: files.length,
|
|
47
|
+
entitiesParsed: entityMap.size,
|
|
48
|
+
errors,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ============================================================
|
|
52
|
+
// CLI Wiring
|
|
53
|
+
// ============================================================
|
|
54
|
+
function formatTable(result) {
|
|
55
|
+
const lines = [];
|
|
56
|
+
if (result.errors.length === 0) {
|
|
57
|
+
lines.push(`Checked ${result.filesChecked} file(s), ${result.entitiesParsed} entity(ies) parsed. No errors found.`);
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
lines.push("FILE FIELD SEVERITY MESSAGE");
|
|
61
|
+
lines.push("─".repeat(90));
|
|
62
|
+
for (const err of result.errors) {
|
|
63
|
+
/* v8 ignore next 1 */
|
|
64
|
+
const fileName = err.file.split("/").pop() ?? err.file;
|
|
65
|
+
const field = err.field ?? "-";
|
|
66
|
+
lines.push(`${fileName.padEnd(30)} ${field.padEnd(12)} ${err.severity.padEnd(9)} ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push("");
|
|
69
|
+
lines.push(`Checked ${result.filesChecked} file(s), ${result.entitiesParsed} entity(ies) parsed. ${result.errors.length} error(s) found.`);
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
export function registerValidateCommand(program) {
|
|
73
|
+
program
|
|
74
|
+
.command("validate")
|
|
75
|
+
.description("Validate all entity files in .whygraph/graph/")
|
|
76
|
+
.option("--json", "Output results as JSON")
|
|
77
|
+
.action(async (opts) => {
|
|
78
|
+
const result = await runValidate(process.cwd());
|
|
79
|
+
if (opts.json) {
|
|
80
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
process.stdout.write(formatTable(result) + "\n");
|
|
84
|
+
}
|
|
85
|
+
const hasErrors = result.errors.some((e) => e.severity === "error");
|
|
86
|
+
process.exitCode = hasErrors ? 1 : 0;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
// ============================================================
|
|
5
|
+
// Find .whygraph directory (reuse serve's logic inline)
|
|
6
|
+
// ============================================================
|
|
7
|
+
function findWhygraphDir(startDir) {
|
|
8
|
+
let current = resolve(startDir);
|
|
9
|
+
const root = resolve("/");
|
|
10
|
+
while (current !== root) {
|
|
11
|
+
const candidate = join(current, ".whygraph");
|
|
12
|
+
if (existsSync(candidate)) {
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
const parent = resolve(current, "..");
|
|
16
|
+
/* v8 ignore next 2 */
|
|
17
|
+
if (parent === current)
|
|
18
|
+
break;
|
|
19
|
+
current = parent;
|
|
20
|
+
}
|
|
21
|
+
/* v8 ignore next 3 */
|
|
22
|
+
if (existsSync(join(root, ".whygraph"))) {
|
|
23
|
+
return root;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
// ============================================================
|
|
28
|
+
// Core Logic
|
|
29
|
+
// ============================================================
|
|
30
|
+
export async function runViz(targetDir, options = {}) {
|
|
31
|
+
const projectDir = findWhygraphDir(targetDir);
|
|
32
|
+
if (!projectDir) {
|
|
33
|
+
throw new Error(`.whygraph/ not found. Run "whygraph init" first.`);
|
|
34
|
+
}
|
|
35
|
+
const port = options.port ?? 4777;
|
|
36
|
+
const url = `http://localhost:${port}`;
|
|
37
|
+
// Check if server is running via health check
|
|
38
|
+
let serverRunning = false;
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`${url}/api/health`);
|
|
41
|
+
if (response.ok) {
|
|
42
|
+
serverRunning = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Server not running
|
|
47
|
+
}
|
|
48
|
+
// If not running, start it in background
|
|
49
|
+
if (!serverRunning) {
|
|
50
|
+
const child = spawn("whygraph", ["serve", "--port", String(port)], {
|
|
51
|
+
cwd: projectDir,
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: "ignore",
|
|
54
|
+
});
|
|
55
|
+
child.unref();
|
|
56
|
+
// Brief wait for server to start
|
|
57
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
58
|
+
}
|
|
59
|
+
// Open browser
|
|
60
|
+
const open = await import("open");
|
|
61
|
+
await open.default(url);
|
|
62
|
+
return { url };
|
|
63
|
+
}
|
|
64
|
+
// ============================================================
|
|
65
|
+
// CLI Wiring
|
|
66
|
+
// ============================================================
|
|
67
|
+
export function registerVizCommand(program) {
|
|
68
|
+
program
|
|
69
|
+
.command("viz")
|
|
70
|
+
.description("Open the whygraph visualization in a browser")
|
|
71
|
+
.option("--port <number>", "Port number", "4777")
|
|
72
|
+
.option("--json", "Output results as JSON")
|
|
73
|
+
.action(async (opts) => {
|
|
74
|
+
try {
|
|
75
|
+
/* v8 ignore next 1 */
|
|
76
|
+
const port = opts.port ? parseInt(opts.port, 10) : undefined;
|
|
77
|
+
const result = await runViz(process.cwd(), { port });
|
|
78
|
+
if (opts.json) {
|
|
79
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
process.stdout.write(`Opening whygraph at ${result.url}\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
/* v8 ignore next 1 */
|
|
87
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
88
|
+
if (opts.json) {
|
|
89
|
+
process.stdout.write(JSON.stringify({ error: message }, null, 2) + "\n");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
93
|
+
}
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { Command } from "commander";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const { version } = require("../../package.json");
|
|
6
|
+
import { registerConfigCommand } from "./commands/config.js";
|
|
7
|
+
import { registerDownCommand } from "./commands/down.js";
|
|
8
|
+
import { registerInitCommand } from "./commands/init.js";
|
|
9
|
+
import { registerIssuesCommand } from "./commands/issues.js";
|
|
10
|
+
import { registerMcpCommand } from "./commands/mcp.js";
|
|
11
|
+
import { registerRestartCommand } from "./commands/restart.js";
|
|
12
|
+
import { registerServeCommand } from "./commands/serve.js";
|
|
13
|
+
import { registerStatusCommand } from "./commands/status.js";
|
|
14
|
+
import { registerUpCommand } from "./commands/up.js";
|
|
15
|
+
import { registerValidateCommand } from "./commands/validate.js";
|
|
16
|
+
import { registerVizCommand } from "./commands/viz.js";
|
|
17
|
+
const program = new Command();
|
|
18
|
+
program
|
|
19
|
+
.name("whygraph")
|
|
20
|
+
.description("The graph of why — so your agent knows before it touches anything.")
|
|
21
|
+
.version(version);
|
|
22
|
+
registerConfigCommand(program);
|
|
23
|
+
registerDownCommand(program);
|
|
24
|
+
registerInitCommand(program);
|
|
25
|
+
registerIssuesCommand(program);
|
|
26
|
+
registerMcpCommand(program);
|
|
27
|
+
registerRestartCommand(program);
|
|
28
|
+
registerServeCommand(program);
|
|
29
|
+
registerStatusCommand(program);
|
|
30
|
+
registerUpCommand(program);
|
|
31
|
+
registerValidateCommand(program);
|
|
32
|
+
registerVizCommand(program);
|
|
33
|
+
program.parse();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface IdOptions {
|
|
2
|
+
prefix?: string;
|
|
3
|
+
length?: number;
|
|
4
|
+
}
|
|
5
|
+
export declare function generateId(options?: IdOptions): string;
|
|
6
|
+
export declare function slugify(title: string): string;
|
|
7
|
+
export declare function toFilename(id: string, title: string): string;
|
|
8
|
+
export declare function parseFilename(filename: string): string | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { customAlphabet } from "nanoid";
|
|
2
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
3
|
+
const DEFAULT_PREFIX = "wg-";
|
|
4
|
+
const DEFAULT_LENGTH = 4;
|
|
5
|
+
const MAX_SLUG_LENGTH = 50;
|
|
6
|
+
export function generateId(options) {
|
|
7
|
+
const prefix = options?.prefix ?? DEFAULT_PREFIX;
|
|
8
|
+
const length = options?.length ?? DEFAULT_LENGTH;
|
|
9
|
+
const nanoid = customAlphabet(ALPHABET, length);
|
|
10
|
+
return `${prefix}${nanoid()}`;
|
|
11
|
+
}
|
|
12
|
+
export function slugify(title) {
|
|
13
|
+
let slug = title
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
16
|
+
.replace(/-+/g, "-")
|
|
17
|
+
.replace(/^-|-$/g, "");
|
|
18
|
+
if (slug.length > MAX_SLUG_LENGTH) {
|
|
19
|
+
slug = slug.slice(0, MAX_SLUG_LENGTH).replace(/-$/, "");
|
|
20
|
+
}
|
|
21
|
+
return slug;
|
|
22
|
+
}
|
|
23
|
+
export function toFilename(id, title) {
|
|
24
|
+
const slug = slugify(title);
|
|
25
|
+
if (!slug) {
|
|
26
|
+
return `${id}.md`;
|
|
27
|
+
}
|
|
28
|
+
return `${id}--${slug}.md`;
|
|
29
|
+
}
|
|
30
|
+
export function parseFilename(filename) {
|
|
31
|
+
// Strip directory path
|
|
32
|
+
const base = filename.includes("/")
|
|
33
|
+
? filename.slice(filename.lastIndexOf("/") + 1)
|
|
34
|
+
: filename;
|
|
35
|
+
// Must end with .md
|
|
36
|
+
if (!base.endsWith(".md")) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const withoutExt = base.slice(0, -3);
|
|
40
|
+
// Try double-dash split first
|
|
41
|
+
const ddIndex = withoutExt.indexOf("--");
|
|
42
|
+
const id = ddIndex >= 0 ? withoutExt.slice(0, ddIndex) : withoutExt;
|
|
43
|
+
// Validate ID looks like prefix + nanoid
|
|
44
|
+
if (!/^[a-z]+-[0-9a-z]+$/.test(id)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ValidationError } from "./validate.js";
|
|
2
|
+
export interface EntityIssue {
|
|
3
|
+
entityId: string;
|
|
4
|
+
errors: ValidationError[];
|
|
5
|
+
createdAt: string;
|
|
6
|
+
updatedAt: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function writeIssue(whygraphDir: string, entityId: string, errors: ValidationError[]): EntityIssue;
|
|
9
|
+
export declare function readIssue(whygraphDir: string, entityId: string): EntityIssue | null;
|
|
10
|
+
export declare function deleteIssue(whygraphDir: string, entityId: string): boolean;
|
|
11
|
+
export declare function listIssues(whygraphDir: string): EntityIssue[];
|
|
12
|
+
export declare function reconcileIssue(whygraphDir: string, entityId: string, errors: ValidationError[]): EntityIssue | null;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { writeFileSync, readFileSync, readdirSync, unlinkSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
function issuesDir(whygraphDir) {
|
|
4
|
+
return join(whygraphDir, "issues");
|
|
5
|
+
}
|
|
6
|
+
function issueFilePath(whygraphDir, entityId) {
|
|
7
|
+
return join(issuesDir(whygraphDir), `${entityId}.json`);
|
|
8
|
+
}
|
|
9
|
+
export function writeIssue(whygraphDir, entityId, errors) {
|
|
10
|
+
const dir = issuesDir(whygraphDir);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
const now = new Date().toISOString();
|
|
13
|
+
const existing = readIssue(whygraphDir, entityId);
|
|
14
|
+
const issue = {
|
|
15
|
+
entityId,
|
|
16
|
+
errors,
|
|
17
|
+
createdAt: existing?.createdAt ?? now,
|
|
18
|
+
updatedAt: now,
|
|
19
|
+
};
|
|
20
|
+
writeFileSync(issueFilePath(whygraphDir, entityId), JSON.stringify(issue, null, 2) + "\n", "utf-8");
|
|
21
|
+
return issue;
|
|
22
|
+
}
|
|
23
|
+
export function readIssue(whygraphDir, entityId) {
|
|
24
|
+
const filePath = issueFilePath(whygraphDir, entityId);
|
|
25
|
+
if (!existsSync(filePath))
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
29
|
+
return JSON.parse(raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* v8 ignore next 1 */
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function deleteIssue(whygraphDir, entityId) {
|
|
37
|
+
const filePath = issueFilePath(whygraphDir, entityId);
|
|
38
|
+
if (!existsSync(filePath))
|
|
39
|
+
return false;
|
|
40
|
+
unlinkSync(filePath);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
export function listIssues(whygraphDir) {
|
|
44
|
+
const dir = issuesDir(whygraphDir);
|
|
45
|
+
if (!existsSync(dir))
|
|
46
|
+
return [];
|
|
47
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
48
|
+
const issues = [];
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(join(dir, file), "utf-8");
|
|
52
|
+
issues.push(JSON.parse(raw));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Skip corrupted issue files
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return issues;
|
|
59
|
+
}
|
|
60
|
+
export function reconcileIssue(whygraphDir, entityId, errors) {
|
|
61
|
+
const hasErrors = errors.some((e) => e.severity === "error");
|
|
62
|
+
const hasWarnings = errors.length > 0;
|
|
63
|
+
if (hasErrors || hasWarnings) {
|
|
64
|
+
return writeIssue(whygraphDir, entityId, errors);
|
|
65
|
+
}
|
|
66
|
+
deleteIssue(whygraphDir, entityId);
|
|
67
|
+
return null;
|
|
68
|
+
}
|