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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/dist/cli/commands/config.d.ts +14 -0
  4. package/dist/cli/commands/config.js +123 -0
  5. package/dist/cli/commands/down.d.ts +9 -0
  6. package/dist/cli/commands/down.js +46 -0
  7. package/dist/cli/commands/init.d.ts +17 -0
  8. package/dist/cli/commands/init.js +144 -0
  9. package/dist/cli/commands/issues.d.ts +10 -0
  10. package/dist/cli/commands/issues.js +376 -0
  11. package/dist/cli/commands/mcp.d.ts +2 -0
  12. package/dist/cli/commands/mcp.js +9 -0
  13. package/dist/cli/commands/restart.d.ts +11 -0
  14. package/dist/cli/commands/restart.js +43 -0
  15. package/dist/cli/commands/serve.d.ts +14 -0
  16. package/dist/cli/commands/serve.js +132 -0
  17. package/dist/cli/commands/server-utils.d.ts +6 -0
  18. package/dist/cli/commands/server-utils.js +94 -0
  19. package/dist/cli/commands/status.d.ts +11 -0
  20. package/dist/cli/commands/status.js +97 -0
  21. package/dist/cli/commands/up.d.ts +13 -0
  22. package/dist/cli/commands/up.js +62 -0
  23. package/dist/cli/commands/validate.d.ts +14 -0
  24. package/dist/cli/commands/validate.js +88 -0
  25. package/dist/cli/commands/viz.d.ts +7 -0
  26. package/dist/cli/commands/viz.js +97 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.js +33 -0
  29. package/dist/entity/id.d.ts +8 -0
  30. package/dist/entity/id.js +48 -0
  31. package/dist/entity/issues.d.ts +12 -0
  32. package/dist/entity/issues.js +68 -0
  33. package/dist/entity/parser.d.ts +6 -0
  34. package/dist/entity/parser.js +166 -0
  35. package/dist/entity/types.d.ts +54 -0
  36. package/dist/entity/types.js +21 -0
  37. package/dist/entity/validate.d.ts +12 -0
  38. package/dist/entity/validate.js +136 -0
  39. package/dist/entity/writer.d.ts +16 -0
  40. package/dist/entity/writer.js +142 -0
  41. package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
  42. package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
  43. package/dist/frontend/index.html +14 -0
  44. package/dist/graph/cascade.d.ts +10 -0
  45. package/dist/graph/cascade.js +49 -0
  46. package/dist/graph/decisions.d.ts +11 -0
  47. package/dist/graph/decisions.js +27 -0
  48. package/dist/graph/gaps.d.ts +10 -0
  49. package/dist/graph/gaps.js +58 -0
  50. package/dist/graph/nodes.d.ts +20 -0
  51. package/dist/graph/nodes.js +33 -0
  52. package/dist/graph/projection.d.ts +6 -0
  53. package/dist/graph/projection.js +44 -0
  54. package/dist/graph/query.d.ts +15 -0
  55. package/dist/graph/query.js +82 -0
  56. package/dist/graph/search.d.ts +2 -0
  57. package/dist/graph/search.js +23 -0
  58. package/dist/graph/supersede.d.ts +7 -0
  59. package/dist/graph/supersede.js +48 -0
  60. package/dist/graph/temporal.d.ts +13 -0
  61. package/dist/graph/temporal.js +28 -0
  62. package/dist/mcp/index.d.ts +2 -0
  63. package/dist/mcp/index.js +10 -0
  64. package/dist/mcp/server.d.ts +3 -0
  65. package/dist/mcp/server.js +340 -0
  66. package/dist/onboarding/interview.d.ts +22 -0
  67. package/dist/onboarding/interview.js +92 -0
  68. package/dist/onboarding/scan.d.ts +17 -0
  69. package/dist/onboarding/scan.js +106 -0
  70. package/dist/platform/rules.d.ts +8 -0
  71. package/dist/platform/rules.js +229 -0
  72. package/dist/server/core.d.ts +26 -0
  73. package/dist/server/core.js +111 -0
  74. package/dist/server/derived.d.ts +8 -0
  75. package/dist/server/derived.js +13 -0
  76. package/dist/server/etag.d.ts +9 -0
  77. package/dist/server/etag.js +25 -0
  78. package/dist/server/http.d.ts +13 -0
  79. package/dist/server/http.js +131 -0
  80. package/dist/server/pubsub.d.ts +12 -0
  81. package/dist/server/pubsub.js +19 -0
  82. package/dist/server/schema.d.ts +2 -0
  83. package/dist/server/schema.js +362 -0
  84. package/dist/server/stale-refs.d.ts +7 -0
  85. package/dist/server/stale-refs.js +23 -0
  86. package/dist/server/watcher.d.ts +21 -0
  87. package/dist/server/watcher.js +98 -0
  88. package/dist/server/worktree-watcher.d.ts +20 -0
  89. package/dist/server/worktree-watcher.js +79 -0
  90. package/dist/server/worktree.d.ts +22 -0
  91. package/dist/server/worktree.js +84 -0
  92. package/package.json +73 -0
@@ -0,0 +1,340 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import * as z from "zod/v4";
4
+ import { join } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import * as yaml from "js-yaml";
7
+ import { generateId } from "../entity/id.js";
8
+ import { writeEntity } from "../entity/writer.js";
9
+ function findWhygraphDir(startDir) {
10
+ let dir = startDir;
11
+ while (true) {
12
+ if (existsSync(join(dir, ".whygraph", "config.yaml")))
13
+ return dir;
14
+ const parent = join(dir, "..");
15
+ /* v8 ignore next 2 */
16
+ if (parent === dir)
17
+ return null;
18
+ dir = parent;
19
+ }
20
+ }
21
+ function getMcpMode(projectDir) {
22
+ // Env var takes precedence (useful for testing)
23
+ if (process.env["WHYGRAPH_MCP_MODE"])
24
+ return process.env["WHYGRAPH_MCP_MODE"];
25
+ /* v8 ignore next 9 */
26
+ if (!projectDir)
27
+ return "default";
28
+ try {
29
+ const raw = readFileSync(join(projectDir, ".whygraph", "config.yaml"), "utf-8");
30
+ const config = yaml.load(raw);
31
+ /* v8 ignore next 1 */
32
+ return config.mcpMode ?? "default";
33
+ }
34
+ catch {
35
+ /* v8 ignore next 1 */
36
+ return "default";
37
+ }
38
+ }
39
+ const DEFAULT_PORT = 4777;
40
+ function getPort() {
41
+ const env = process.env["WHYGRAPH_PORT"];
42
+ /* v8 ignore next 4 */
43
+ if (env) {
44
+ const parsed = parseInt(env, 10);
45
+ if (!isNaN(parsed) && parsed > 0)
46
+ return parsed;
47
+ }
48
+ return DEFAULT_PORT;
49
+ }
50
+ function getEndpoint() {
51
+ return `http://localhost:${getPort()}/api/graphql`;
52
+ }
53
+ async function queryGraphQL(query, variables) {
54
+ const endpoint = getEndpoint();
55
+ let response;
56
+ try {
57
+ response = await fetch(endpoint, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({ query, variables }),
61
+ });
62
+ }
63
+ catch {
64
+ throw new Error(`Cannot connect to whygraph server at ${endpoint}. Is the server running? Start it with: whygraph serve`);
65
+ }
66
+ const json = (await response.json());
67
+ if (json.errors && json.errors.length > 0) {
68
+ throw new Error(`GraphQL error: ${json.errors.map((e) => e.message).join(", ")}`);
69
+ }
70
+ /* v8 ignore next 3 */
71
+ if (!json.data) {
72
+ throw new Error("No data returned from GraphQL query");
73
+ }
74
+ return json.data;
75
+ }
76
+ function textResult(data) {
77
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
78
+ }
79
+ function errorResult(message) {
80
+ return { content: [{ type: "text", text: message }], isError: true };
81
+ }
82
+ export function createMcpServer() {
83
+ const projectDir = findWhygraphDir(process.cwd());
84
+ const mcpMode = getMcpMode(projectDir);
85
+ const server = new McpServer({
86
+ name: "whygraph",
87
+ version: "0.2.0",
88
+ });
89
+ // whygraph_context
90
+ server.registerTool("whygraph_context", {
91
+ description: "Get architectural context for a file/symbol. Returns structural nodes and decisions that affect the given code location.",
92
+ inputSchema: {
93
+ file: z.string().describe("File path to get context for"),
94
+ symbol: z.string().optional().describe("Optional symbol name within the file"),
95
+ },
96
+ }, async ({ file, symbol }) => {
97
+ try {
98
+ const variables = { file };
99
+ if (symbol !== undefined)
100
+ variables["symbol"] = symbol;
101
+ const query = `
102
+ query Context($file: String!, $symbol: String) {
103
+ context(file: $file, symbol: $symbol) {
104
+ nodes { id label name parentChain }
105
+ decisions { id title status date affects tags context decision tradeoffs alternatives }
106
+ }
107
+ }
108
+ `;
109
+ const data = await queryGraphQL(query, variables);
110
+ return textResult(data.context);
111
+ }
112
+ catch (err) {
113
+ return errorResult(err.message);
114
+ }
115
+ });
116
+ // whygraph_get_decisions
117
+ server.registerTool("whygraph_get_decisions", {
118
+ description: "Query decisions with optional filters for status, tags, and date range.",
119
+ inputSchema: {
120
+ status: z.string().optional().describe("Filter by decision status (active or superseded)"),
121
+ tags: z.array(z.string()).optional().describe("Filter by tags"),
122
+ dateFrom: z.string().optional().describe("Filter decisions from this date (YYYY-MM-DD)"),
123
+ dateTo: z.string().optional().describe("Filter decisions up to this date (YYYY-MM-DD)"),
124
+ },
125
+ }, async ({ status, tags, dateFrom, dateTo }) => {
126
+ try {
127
+ const variables = {};
128
+ if (status !== undefined)
129
+ variables["status"] = status;
130
+ if (tags !== undefined)
131
+ variables["tags"] = tags;
132
+ if (dateFrom !== undefined)
133
+ variables["dateFrom"] = dateFrom;
134
+ if (dateTo !== undefined)
135
+ variables["dateTo"] = dateTo;
136
+ const query = `
137
+ query Decisions($status: String, $tags: [String!], $dateFrom: String, $dateTo: String) {
138
+ decisions(status: $status, tags: $tags, dateFrom: $dateFrom, dateTo: $dateTo) {
139
+ id title status date affects tags context decision tradeoffs alternatives
140
+ }
141
+ }
142
+ `;
143
+ const data = await queryGraphQL(query, variables);
144
+ return textResult(data.decisions);
145
+ }
146
+ catch (err) {
147
+ return errorResult(err.message);
148
+ }
149
+ });
150
+ // whygraph_get_gaps
151
+ server.registerTool("whygraph_get_gaps", {
152
+ description: "Find structural nodes that have no associated decisions (coverage gaps).",
153
+ inputSchema: {
154
+ limit: z.number().optional().describe("Maximum number of gaps to return"),
155
+ },
156
+ }, async ({ limit }) => {
157
+ try {
158
+ const variables = {};
159
+ if (limit !== undefined)
160
+ variables["limit"] = limit;
161
+ const query = `
162
+ query Gaps($limit: Int) {
163
+ gaps(limit: $limit) {
164
+ id label name status parent
165
+ }
166
+ }
167
+ `;
168
+ const data = await queryGraphQL(query, variables);
169
+ return textResult(data.gaps);
170
+ }
171
+ catch (err) {
172
+ return errorResult(err.message);
173
+ }
174
+ });
175
+ // whygraph_list_nodes
176
+ server.registerTool("whygraph_list_nodes", {
177
+ description: "List structural nodes with optional filters for label, parent, and search text.",
178
+ inputSchema: {
179
+ label: z.string().optional().describe("Filter by node label (App, Feature, Component)"),
180
+ parent: z.string().optional().describe("Filter by parent node ID"),
181
+ search: z.string().optional().describe("Search nodes by name"),
182
+ },
183
+ }, async ({ label, parent, search }) => {
184
+ try {
185
+ const variables = {};
186
+ if (label !== undefined)
187
+ variables["label"] = label;
188
+ if (parent !== undefined)
189
+ variables["parent"] = parent;
190
+ if (search !== undefined)
191
+ variables["search"] = search;
192
+ const query = `
193
+ query Nodes($label: String, $parent: String, $search: String) {
194
+ nodes(label: $label, parent: $parent, search: $search) {
195
+ id label name parent
196
+ }
197
+ }
198
+ `;
199
+ const data = await queryGraphQL(query, variables);
200
+ return textResult(data.nodes);
201
+ }
202
+ catch (err) {
203
+ return errorResult(err.message);
204
+ }
205
+ });
206
+ // Write tools — only registered in strict mode
207
+ if (mcpMode === "strict") {
208
+ registerWriteTools(server);
209
+ }
210
+ return server;
211
+ }
212
+ function registerWriteTools(server) {
213
+ // whygraph_create_decision
214
+ server.registerTool("whygraph_create_decision", {
215
+ description: "Create a new decision node in the whygraph.",
216
+ inputSchema: {
217
+ title: z.string().min(1).describe("Decision title"),
218
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("Decision date (YYYY-MM-DD)"),
219
+ affects: z.array(z.string().min(1)).min(1).describe("IDs of structural nodes this decision affects"),
220
+ tags: z.array(z.string()).min(1).describe("Decision tags (arch, data, security, performance, integration, infra, ux)"),
221
+ context: z.string().min(1).describe("Context explaining why this decision was needed"),
222
+ decision: z.string().min(1).describe("The decision that was made"),
223
+ tradeoffs: z.string().min(1).describe("Tradeoffs of the decision"),
224
+ alternatives: z.string().min(1).describe("Alternatives that were considered"),
225
+ supersedes: z.string().optional().describe("ID of the decision this supersedes"),
226
+ },
227
+ }, async ({ title, date, affects, tags, context, decision, tradeoffs, alternatives, supersedes }) => {
228
+ try {
229
+ const variables = {
230
+ title, date, affects, tags, context, decision, tradeoffs, alternatives,
231
+ };
232
+ if (supersedes !== undefined)
233
+ variables["supersedes"] = supersedes;
234
+ const query = `
235
+ mutation CreateDecision(
236
+ $title: String!, $date: String!, $affects: [String!]!, $tags: [String!]!,
237
+ $context: String!, $decision: String!, $tradeoffs: String!, $alternatives: String!,
238
+ $supersedes: String
239
+ ) {
240
+ createDecision(
241
+ title: $title, date: $date, affects: $affects, tags: $tags,
242
+ context: $context, decision: $decision, tradeoffs: $tradeoffs,
243
+ alternatives: $alternatives, supersedes: $supersedes
244
+ ) {
245
+ id title status date affects tags context decision tradeoffs alternatives supersedes
246
+ created_at updated_at
247
+ }
248
+ }
249
+ `;
250
+ const data = await queryGraphQL(query, variables);
251
+ return textResult(data.createDecision);
252
+ }
253
+ catch {
254
+ // Fallback: write directly to disk when server is unreachable
255
+ const projectDir = findWhygraphDir(process.cwd());
256
+ /* v8 ignore next 1 */
257
+ if (!projectDir)
258
+ return errorResult("Cannot find .whygraph directory and server is unreachable");
259
+ const now = new Date().toISOString();
260
+ const id = generateId({ prefix: "wg-", length: 4 });
261
+ const entity = {
262
+ id, label: "Decision", title, status: "active", date,
263
+ affects, tags: tags,
264
+ context, decision, tradeoffs, alternatives,
265
+ supersedes, created_at: now, updated_at: now,
266
+ };
267
+ const { filePath, validation } = writeEntity(join(projectDir, ".whygraph", "graph"), entity);
268
+ /* v8 ignore next 2 */
269
+ const warnings = validation.errors.length > 0
270
+ ? ` (${validation.errors.length} validation issue(s) — written to disk, server will reconcile)`
271
+ : "";
272
+ return textResult({ ...entity, _fallback: true, _filePath: filePath, _note: `Server unreachable, wrote to disk${warnings}` });
273
+ }
274
+ });
275
+ // whygraph_create_node
276
+ server.registerTool("whygraph_create_node", {
277
+ description: "Create a new structural node in the whygraph.",
278
+ inputSchema: {
279
+ label: z.string().min(1).describe("Node label (App, Feature, Component)"),
280
+ name: z.string().min(1).describe("Node name"),
281
+ parent: z.string().optional().describe("Parent node ID"),
282
+ refs: z.array(z.object({
283
+ file: z.string(),
284
+ symbol: z.string().optional(),
285
+ })).optional().describe("Code references"),
286
+ description: z.string().optional().describe("Node description"),
287
+ },
288
+ }, async ({ label, name, parent, refs, description }) => {
289
+ try {
290
+ const variables = { label, name };
291
+ if (parent !== undefined)
292
+ variables["parent"] = parent;
293
+ if (refs !== undefined)
294
+ variables["refs"] = refs;
295
+ if (description !== undefined)
296
+ variables["description"] = description;
297
+ const query = `
298
+ mutation CreateNode(
299
+ $label: String!, $name: String!, $parent: String,
300
+ $refs: [SymbolRefInput!], $description: String
301
+ ) {
302
+ createNode(
303
+ label: $label, name: $name, parent: $parent,
304
+ refs: $refs, description: $description
305
+ ) {
306
+ id label name status parent refs { file symbol } description
307
+ created_at updated_at
308
+ }
309
+ }
310
+ `;
311
+ const data = await queryGraphQL(query, variables);
312
+ return textResult(data.createNode);
313
+ }
314
+ catch {
315
+ const projectDir = findWhygraphDir(process.cwd());
316
+ /* v8 ignore next 1 */
317
+ if (!projectDir)
318
+ return errorResult("Cannot find .whygraph directory and server is unreachable");
319
+ const now = new Date().toISOString();
320
+ const id = generateId({ prefix: "wg-", length: 4 });
321
+ const entity = {
322
+ id, label: label, name, status: "active",
323
+ parent, refs: refs, description,
324
+ created_at: now, updated_at: now,
325
+ };
326
+ const { filePath, validation } = writeEntity(join(projectDir, ".whygraph", "graph"), entity);
327
+ /* v8 ignore next 2 */
328
+ const warnings = validation.errors.length > 0
329
+ ? ` (${validation.errors.length} validation issue(s) — written to disk, server will reconcile)`
330
+ : "";
331
+ return textResult({ ...entity, _fallback: true, _filePath: filePath, _note: `Server unreachable, wrote to disk${warnings}` });
332
+ }
333
+ });
334
+ }
335
+ /* v8 ignore next 5 */
336
+ export async function startMcpServer() {
337
+ const server = createMcpServer();
338
+ const transport = new StdioServerTransport();
339
+ await server.connect(transport);
340
+ }
@@ -0,0 +1,22 @@
1
+ export interface InterviewFeatureInput {
2
+ name: string;
3
+ files?: string[];
4
+ }
5
+ export interface InterviewComponentInput {
6
+ name: string;
7
+ parent: string;
8
+ files?: string[];
9
+ }
10
+ export interface InterviewOptions {
11
+ features?: InterviewFeatureInput[];
12
+ components?: InterviewComponentInput[];
13
+ }
14
+ export interface InterviewResult {
15
+ created: Array<{
16
+ id: string;
17
+ label: string;
18
+ name: string;
19
+ }>;
20
+ gaps: number;
21
+ }
22
+ export declare function runInterview(whygraphDir: string, options?: InterviewOptions): Promise<InterviewResult>;
@@ -0,0 +1,92 @@
1
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { generateId } from "../entity/id.js";
4
+ import { writeEntity } from "../entity/writer.js";
5
+ import { parseEntity } from "../entity/parser.js";
6
+ import { buildGraph } from "../graph/projection.js";
7
+ import { getGaps } from "../graph/gaps.js";
8
+ // ============================================================
9
+ // Helpers
10
+ // ============================================================
11
+ function loadEntities(graphDir) {
12
+ if (!existsSync(graphDir))
13
+ return [];
14
+ const files = readdirSync(graphDir).filter((f) => f.endsWith(".md"));
15
+ const entities = [];
16
+ for (const file of files) {
17
+ const content = readFileSync(join(graphDir, file), "utf-8");
18
+ const entity = parseEntity(content);
19
+ if (entity !== null) {
20
+ entities.push(entity);
21
+ }
22
+ }
23
+ return entities;
24
+ }
25
+ function filesToRefs(files) {
26
+ if (!files || files.length === 0)
27
+ return undefined;
28
+ return files.map((file) => ({ file }));
29
+ }
30
+ // ============================================================
31
+ // Core Logic
32
+ // ============================================================
33
+ export async function runInterview(whygraphDir, options) {
34
+ const graphDir = join(whygraphDir, "graph");
35
+ // Load existing entities to understand current state
36
+ const existingEntities = loadEntities(graphDir);
37
+ const graph = buildGraph(existingEntities);
38
+ const gaps = getGaps(graph);
39
+ const now = new Date().toISOString();
40
+ const created = [];
41
+ // Find an App node to use as parent for features
42
+ const appNode = existingEntities.find((e) => e.label === "App");
43
+ const appId = appNode?.id;
44
+ // Create Feature nodes
45
+ const featureIdMap = new Map();
46
+ if (options?.features) {
47
+ for (const feature of options.features) {
48
+ const id = generateId();
49
+ featureIdMap.set(feature.name, id);
50
+ const refs = filesToRefs(feature.files);
51
+ const node = {
52
+ id,
53
+ label: "Feature",
54
+ name: feature.name,
55
+ status: "active",
56
+ created_at: now,
57
+ updated_at: now,
58
+ ...(appId !== undefined ? { parent: appId } : {}),
59
+ ...(refs !== undefined ? { refs } : {}),
60
+ };
61
+ writeEntity(graphDir, node);
62
+ created.push({ id, label: "Feature", name: feature.name });
63
+ }
64
+ }
65
+ // Create Component nodes
66
+ if (options?.components) {
67
+ for (const component of options.components) {
68
+ const id = generateId();
69
+ const parentId = featureIdMap.get(component.parent);
70
+ if (parentId === undefined) {
71
+ continue;
72
+ }
73
+ const refs = filesToRefs(component.files);
74
+ const node = {
75
+ id,
76
+ label: "Component",
77
+ name: component.name,
78
+ status: "active",
79
+ parent: parentId,
80
+ created_at: now,
81
+ updated_at: now,
82
+ ...(refs !== undefined ? { refs } : {}),
83
+ };
84
+ writeEntity(graphDir, node);
85
+ created.push({ id, label: "Component", name: component.name });
86
+ }
87
+ }
88
+ return {
89
+ created,
90
+ gaps: gaps.length,
91
+ };
92
+ }
@@ -0,0 +1,17 @@
1
+ import type { SymbolRef } from "../entity/types.js";
2
+ export interface ScanProposal {
3
+ features: Array<{
4
+ name: string;
5
+ refs: SymbolRef[];
6
+ }>;
7
+ components: Array<{
8
+ name: string;
9
+ parentFeature: string;
10
+ refs: SymbolRef[];
11
+ }>;
12
+ }
13
+ export interface ScanResult {
14
+ proposal: ScanProposal;
15
+ scannedDir: string;
16
+ }
17
+ export declare function runScan(projectDir: string, _whygraphDir: string): Promise<ScanResult>;
@@ -0,0 +1,106 @@
1
+ import { readdirSync, statSync, existsSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ // ============================================================
4
+ // Constants
5
+ // ============================================================
6
+ const MAIN_FILE_NAMES = new Set([
7
+ "index.ts",
8
+ "index.js",
9
+ "index.tsx",
10
+ "index.jsx",
11
+ "mod.ts",
12
+ "mod.js",
13
+ "main.ts",
14
+ "main.js",
15
+ ]);
16
+ const IGNORED_DIRS = new Set([
17
+ "node_modules",
18
+ ".git",
19
+ ".whygraph",
20
+ "dist",
21
+ "build",
22
+ "coverage",
23
+ "__pycache__",
24
+ ".next",
25
+ ".nuxt",
26
+ ]);
27
+ // ============================================================
28
+ // Helpers
29
+ // ============================================================
30
+ function findSourceRoot(projectDir) {
31
+ const candidates = ["src", "lib", "app", "packages"];
32
+ for (const candidate of candidates) {
33
+ const candidatePath = join(projectDir, candidate);
34
+ if (existsSync(candidatePath) && statSync(candidatePath).isDirectory()) {
35
+ return candidatePath;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ function findMainFiles(dirPath, projectDir) {
41
+ const refs = [];
42
+ /* v8 ignore next 1 */
43
+ if (!existsSync(dirPath))
44
+ return refs;
45
+ const entries = readdirSync(dirPath);
46
+ for (const entry of entries) {
47
+ if (MAIN_FILE_NAMES.has(entry)) {
48
+ const fullPath = join(dirPath, entry);
49
+ if (statSync(fullPath).isFile()) {
50
+ refs.push({ file: relative(projectDir, fullPath) });
51
+ }
52
+ }
53
+ }
54
+ return refs;
55
+ }
56
+ function getSubdirectories(dirPath) {
57
+ /* v8 ignore next 1 */
58
+ if (!existsSync(dirPath))
59
+ return [];
60
+ return readdirSync(dirPath).filter((entry) => {
61
+ if (IGNORED_DIRS.has(entry))
62
+ return false;
63
+ if (entry.startsWith("."))
64
+ return false;
65
+ const fullPath = join(dirPath, entry);
66
+ return statSync(fullPath).isDirectory();
67
+ });
68
+ }
69
+ // ============================================================
70
+ // Core Logic
71
+ // ============================================================
72
+ export async function runScan(projectDir, _whygraphDir) {
73
+ const sourceRoot = findSourceRoot(projectDir);
74
+ if (sourceRoot === null) {
75
+ return {
76
+ proposal: { features: [], components: [] },
77
+ scannedDir: projectDir,
78
+ };
79
+ }
80
+ const features = [];
81
+ const components = [];
82
+ const topLevelDirs = getSubdirectories(sourceRoot);
83
+ for (const dirName of topLevelDirs) {
84
+ const dirPath = join(sourceRoot, dirName);
85
+ const mainRefs = findMainFiles(dirPath, projectDir);
86
+ features.push({
87
+ name: dirName,
88
+ refs: mainRefs,
89
+ });
90
+ // Look for subdirectories as components
91
+ const subDirs = getSubdirectories(dirPath);
92
+ for (const subDirName of subDirs) {
93
+ const subDirPath = join(dirPath, subDirName);
94
+ const subRefs = findMainFiles(subDirPath, projectDir);
95
+ components.push({
96
+ name: subDirName,
97
+ parentFeature: dirName,
98
+ refs: subRefs,
99
+ });
100
+ }
101
+ }
102
+ return {
103
+ proposal: { features, components },
104
+ scannedDir: sourceRoot,
105
+ };
106
+ }
@@ -0,0 +1,8 @@
1
+ import type { Environment, WhygraphConfig } from "../entity/types.js";
2
+ export interface PlatformRulesResult {
3
+ environment: Environment;
4
+ filePath: string;
5
+ mcpRegistered: boolean;
6
+ mcpSetupPath?: string;
7
+ }
8
+ export declare function writePlatformRules(projectDir: string, environment: Environment, _primeOutput: string, config?: WhygraphConfig): PlatformRulesResult;