@vobs/cli 0.1.0 → 0.3.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.
@@ -0,0 +1,11 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/cli
4
+ */
5
+ export interface VobsAiServerStreams {
6
+ readonly input: AsyncIterable<string>;
7
+ readonly stdout: Pick<NodeJS.WriteStream, 'write'>;
8
+ readonly stderr?: Pick<NodeJS.WriteStream, 'write'>;
9
+ }
10
+ /** Serve the read-only vobs AI tools over line-delimited JSON-RPC on stdio. */
11
+ export declare function runVobsAiServer(streams: VobsAiServerStreams): Promise<void>;
@@ -0,0 +1,212 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/cli
4
+ */
5
+ import { existsSync, realpathSync } from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import { VOBS_AI_TOOL_CATALOG, } from '@vobs/ai-contracts';
8
+ import { createVobsProjectContext } from './context.js';
9
+ import { createVobsDiagnosticReport } from './diagnose.js';
10
+ import { inspectVobsProject } from './inspect.js';
11
+ const MCP_PROTOCOL_VERSION = '2025-06-18';
12
+ const SERVER_VERSION = '0.1.0';
13
+ /** Serve the read-only vobs AI tools over line-delimited JSON-RPC on stdio. */
14
+ export async function runVobsAiServer(streams) {
15
+ for await (const line of streams.input) {
16
+ if (line.trim() === '')
17
+ continue;
18
+ const response = await handleJsonRpcLine(line);
19
+ if (response === undefined)
20
+ continue;
21
+ streams.stdout.write(`${JSON.stringify(response)}\n`);
22
+ }
23
+ }
24
+ async function handleJsonRpcLine(line) {
25
+ let request;
26
+ try {
27
+ request = JSON.parse(line);
28
+ }
29
+ catch {
30
+ return errorResponse(null, -32700, 'Parse error');
31
+ }
32
+ if (request.jsonrpc !== '2.0' || typeof request.method !== 'string') {
33
+ return errorResponse(request.id ?? null, -32600, 'Invalid Request');
34
+ }
35
+ if (request.id === undefined) {
36
+ await Promise.resolve()
37
+ .then(() => dispatchRequest(request))
38
+ .catch(() => undefined);
39
+ return undefined;
40
+ }
41
+ try {
42
+ return {
43
+ jsonrpc: '2.0',
44
+ id: request.id,
45
+ result: await dispatchRequest(request),
46
+ };
47
+ }
48
+ catch (error) {
49
+ if (error instanceof AiToolRequestError) {
50
+ return errorResponse(request.id, error.code, error.message);
51
+ }
52
+ return errorResponse(request.id, -32603, 'Internal error');
53
+ }
54
+ }
55
+ function dispatchRequest(request) {
56
+ switch (request.method) {
57
+ case 'initialize':
58
+ return initializeResult();
59
+ case 'notifications/initialized':
60
+ return undefined;
61
+ case 'ping':
62
+ return {};
63
+ case 'tools/list':
64
+ return { tools: VOBS_AI_TOOL_CATALOG.tools };
65
+ case 'tools/call':
66
+ return callTool(request.params);
67
+ default:
68
+ throw new AiToolRequestError(-32601, `Method not found: ${String(request.method)}`);
69
+ }
70
+ }
71
+ function initializeResult() {
72
+ return {
73
+ protocolVersion: MCP_PROTOCOL_VERSION,
74
+ capabilities: { tools: { listChanged: false } },
75
+ serverInfo: { name: 'vobs-ai', version: SERVER_VERSION },
76
+ };
77
+ }
78
+ function callTool(params) {
79
+ if (!isRecord(params) || typeof params.name !== 'string') {
80
+ throw new AiToolRequestError(-32602, 'tools/call requires a tool name');
81
+ }
82
+ const input = params.arguments === undefined ? {} : params.arguments;
83
+ if (!isRecord(input)) {
84
+ throw new AiToolRequestError(-32602, 'tools/call arguments must be an object');
85
+ }
86
+ let output;
87
+ try {
88
+ output = invokeTool(params.name, input);
89
+ }
90
+ catch (error) {
91
+ if (error instanceof AiToolRequestError)
92
+ throw error;
93
+ return {
94
+ content: [
95
+ {
96
+ type: 'text',
97
+ text: error instanceof Error ? error.message : 'The vobs tool failed.',
98
+ },
99
+ ],
100
+ structuredContent: null,
101
+ isError: true,
102
+ };
103
+ }
104
+ return {
105
+ content: [{ type: 'text', text: JSON.stringify(output) }],
106
+ structuredContent: output,
107
+ isError: false,
108
+ };
109
+ }
110
+ function invokeTool(name, input) {
111
+ if (!VOBS_AI_TOOL_CATALOG.tools.some((tool) => tool.name === name)) {
112
+ throw new AiToolRequestError(-32602, `Unknown tool: ${name}`);
113
+ }
114
+ assertKnownToolInput(name, input);
115
+ const tsconfigPath = readOptionalString(input, 'tsconfig');
116
+ const route = readOptionalString(input, 'route');
117
+ const file = readOptionalString(input, 'file');
118
+ const depth = readOptionalInteger(input, 'depth');
119
+ const resolvedTsconfig = resolveToolTsconfig(tsconfigPath);
120
+ switch (name) {
121
+ case 'project.inspect':
122
+ if (route !== undefined || file !== undefined || depth !== undefined) {
123
+ throw new AiToolRequestError(-32602, 'project.inspect only accepts tsconfig');
124
+ }
125
+ return inspectVobsProject({ tsconfigPath: resolvedTsconfig });
126
+ case 'project.context':
127
+ if (route === undefined && file === undefined) {
128
+ throw new AiToolRequestError(-32602, 'project.context requires route or file');
129
+ }
130
+ if (route !== undefined && file !== undefined) {
131
+ throw new AiToolRequestError(-32602, 'project.context accepts only one selector');
132
+ }
133
+ return createVobsProjectContext({
134
+ tsconfigPath: resolvedTsconfig,
135
+ ...(route === undefined ? {} : { route }),
136
+ ...(file === undefined ? {} : { file }),
137
+ ...(depth === undefined ? {} : { depth }),
138
+ });
139
+ case 'project.diagnostics':
140
+ if (route === undefined && file === undefined && depth !== undefined) {
141
+ throw new AiToolRequestError(-32602, 'project.diagnostics depth requires route or file');
142
+ }
143
+ if (route !== undefined && file !== undefined) {
144
+ throw new AiToolRequestError(-32602, 'project.diagnostics accepts only one selector');
145
+ }
146
+ return createVobsDiagnosticReport({
147
+ tsconfigPath: resolvedTsconfig,
148
+ ...(route === undefined ? {} : { route }),
149
+ ...(file === undefined ? {} : { file }),
150
+ ...(depth === undefined ? {} : { depth }),
151
+ });
152
+ default:
153
+ throw new AiToolRequestError(-32602, `Unknown tool: ${name}`);
154
+ }
155
+ }
156
+ function assertKnownToolInput(name, input) {
157
+ const definition = VOBS_AI_TOOL_CATALOG.tools.find((tool) => tool.name === name);
158
+ if (definition === undefined)
159
+ return;
160
+ const allowed = new Set(Object.keys(definition.inputSchema.properties));
161
+ const unexpected = Object.keys(input).find((key) => !allowed.has(key));
162
+ if (unexpected !== undefined) {
163
+ throw new AiToolRequestError(-32602, `Unknown argument for ${name}: ${unexpected}`);
164
+ }
165
+ }
166
+ function resolveToolTsconfig(tsconfigPath) {
167
+ const serverRoot = realpathSync.native(process.cwd());
168
+ const requested = path.resolve(serverRoot, tsconfigPath ?? 'tsconfig.json');
169
+ if (!isPathInside(serverRoot, requested)) {
170
+ throw new AiToolRequestError(-32602, 'tsconfig must stay inside the AI server project');
171
+ }
172
+ if (existsSync(requested) && !isPathInside(serverRoot, realpathSync.native(requested))) {
173
+ throw new AiToolRequestError(-32602, 'tsconfig must stay inside the AI server project');
174
+ }
175
+ return requested;
176
+ }
177
+ function isPathInside(root, candidate) {
178
+ const relative = path.relative(root, candidate);
179
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
180
+ }
181
+ function readOptionalString(input, key) {
182
+ const value = input[key];
183
+ if (value === undefined)
184
+ return undefined;
185
+ if (typeof value !== 'string' || value.length === 0) {
186
+ throw new AiToolRequestError(-32602, `${key} must be a non-empty string`);
187
+ }
188
+ return value;
189
+ }
190
+ function readOptionalInteger(input, key) {
191
+ const value = input[key];
192
+ if (value === undefined)
193
+ return undefined;
194
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 4) {
195
+ throw new AiToolRequestError(-32602, `${key} must be an integer from 0 to 4`);
196
+ }
197
+ return value;
198
+ }
199
+ function isRecord(value) {
200
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
201
+ }
202
+ function errorResponse(id, code, message) {
203
+ return { jsonrpc: '2.0', id, error: { code, message } };
204
+ }
205
+ class AiToolRequestError extends Error {
206
+ code;
207
+ name = 'AiToolRequestError';
208
+ constructor(code, message) {
209
+ super(message);
210
+ this.code = code;
211
+ }
212
+ }
@@ -0,0 +1,12 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/cli
4
+ */
5
+ import type { VobsProjectContext } from '@vobs/ai-contracts';
6
+ export interface ContextOptions {
7
+ readonly tsconfigPath: string;
8
+ readonly route?: string;
9
+ readonly file?: string;
10
+ readonly depth?: number;
11
+ }
12
+ export declare function createVobsProjectContext(options: ContextOptions): VobsProjectContext;
@@ -0,0 +1,301 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/cli
4
+ */
5
+ import { existsSync, readFileSync, statSync } from 'node:fs';
6
+ import * as path from 'node:path';
7
+ import * as ts from 'typescript';
8
+ import { inspectVobsProject } from './inspect.js';
9
+ const MAX_CONTEXT_FILES = 100;
10
+ const ignoredPathSegments = new Set(['.git', '.cache', '.tmp', 'coverage', 'dist', 'node_modules']);
11
+ export function createVobsProjectContext(options) {
12
+ if ((options.route === undefined) === (options.file === undefined)) {
13
+ throw new Error('vobs context requires exactly one of --route or --file');
14
+ }
15
+ const depth = options.depth ?? 2;
16
+ if (!Number.isInteger(depth) || depth < 0 || depth > 4) {
17
+ throw new Error('vobs context --depth must be an integer from 0 to 4');
18
+ }
19
+ const tsconfigPath = path.resolve(options.tsconfigPath);
20
+ const projectRoot = path.dirname(tsconfigPath);
21
+ const workspaceRoot = findWorkspaceRoot(projectRoot);
22
+ const manifest = inspectVobsProject({ tsconfigPath });
23
+ const files = new Map();
24
+ let truncated = false;
25
+ const addFile = (file, kind, reason) => {
26
+ const current = files.get(file);
27
+ if (current !== undefined) {
28
+ current.kinds.add(kind);
29
+ current.reasons.add(reason);
30
+ return false;
31
+ }
32
+ if (files.size >= MAX_CONTEXT_FILES) {
33
+ truncated = true;
34
+ return false;
35
+ }
36
+ files.set(file, { kinds: new Set([kind]), reasons: new Set([reason]) });
37
+ return true;
38
+ };
39
+ let selectedFile;
40
+ let selectedRoutes;
41
+ if (options.route !== undefined) {
42
+ const route = findRoute(manifest.files.routes, options.route);
43
+ if (route === undefined)
44
+ throw new Error(`No vobs route matches: ${options.route}`);
45
+ selectedRoutes = [route];
46
+ }
47
+ else {
48
+ const absoluteFile = path.resolve(projectRoot, options.file);
49
+ assertProjectSourceFile(projectRoot, absoluteFile);
50
+ selectedFile = manifestPath(absoluteFile, workspaceRoot);
51
+ addFile(selectedFile, classifyFile(selectedFile, manifest), 'selected file');
52
+ const ownerFiles = new Set([selectedFile]);
53
+ for (const component of manifest.files.components) {
54
+ if (component.file === selectedFile || component.view === selectedFile) {
55
+ ownerFiles.add(component.file);
56
+ addFile(component.file, component.kind, `owner related to ${selectedFile}`);
57
+ if (component.view !== undefined)
58
+ addFile(component.view, 'view', `view owned by ${component.file}`);
59
+ }
60
+ }
61
+ selectedRoutes = manifest.files.routes.filter((route) => ownerFiles.has(route.file) ||
62
+ route.layouts.includes(selectedFile) ||
63
+ route.error === selectedFile);
64
+ }
65
+ for (const route of selectedRoutes)
66
+ addRouteFiles(route, addFile);
67
+ const componentByFile = new Map(manifest.files.components.map((component) => [component.file, component]));
68
+ const viewByFile = new Map((manifest.files.viewContracts ?? []).map((view) => [view.file, view]));
69
+ const knownViews = new Set(manifest.files.views);
70
+ const queue = [];
71
+ const queued = new Set();
72
+ for (const file of files.keys())
73
+ enqueue(file, 0, queue, queued);
74
+ for (let index = 0; index < queue.length; index += 1) {
75
+ const current = queue[index];
76
+ if (current === undefined)
77
+ continue;
78
+ const component = componentByFile.get(current.file);
79
+ if (component?.view !== undefined) {
80
+ const added = addFile(component.view, 'view', `view owned by ${component.file}`);
81
+ if (added)
82
+ enqueue(component.view, current.depth, queue, queued);
83
+ }
84
+ const view = viewByFile.get(current.file);
85
+ if (view !== undefined) {
86
+ for (const include of view.includes) {
87
+ if (!knownViews.has(include))
88
+ continue;
89
+ const added = addFile(include, 'view', `included by ${view.file}`);
90
+ if (added)
91
+ enqueue(include, current.depth, queue, queued);
92
+ }
93
+ }
94
+ if (current.depth >= depth || !isTypeScriptPath(current.file))
95
+ continue;
96
+ const absoluteFile = path.resolve(workspaceRoot, current.file);
97
+ for (const dependency of readLocalDependencies(absoluteFile, projectRoot, workspaceRoot)) {
98
+ const added = addFile(dependency, classifyFile(dependency, manifest), `imported by ${current.file}`);
99
+ if (added)
100
+ enqueue(dependency, current.depth + 1, queue, queued);
101
+ }
102
+ }
103
+ for (const component of manifest.files.components) {
104
+ if (component.view !== undefined && files.has(component.view)) {
105
+ addFile(component.file, component.kind, `owner of ${component.view}`);
106
+ }
107
+ }
108
+ for (const [file, entry] of files) {
109
+ addKnownKinds(file, entry.kinds, manifest);
110
+ }
111
+ const contextFiles = [...files]
112
+ .map(([file, entry]) => ({
113
+ path: file,
114
+ kinds: [...entry.kinds].sort(),
115
+ reasons: [...entry.reasons].sort(),
116
+ }))
117
+ .sort((left, right) => left.path.localeCompare(right.path));
118
+ const includedPaths = new Set(contextFiles.map((file) => file.path));
119
+ const diagnostics = manifest.diagnostics.filter((diagnostic) => diagnostic.source !== undefined && includedPaths.has(diagnostic.source));
120
+ return {
121
+ schemaVersion: manifest.schemaVersion,
122
+ generatedBy: 'vobs context',
123
+ selector: options.route === undefined
124
+ ? { file: selectedFile }
125
+ : { route: normalizeRouteInput(options.route) },
126
+ project: manifest.project,
127
+ routes: selectedRoutes,
128
+ files: contextFiles,
129
+ components: manifest.files.components.filter((component) => includedPaths.has(component.file) ||
130
+ (component.view !== undefined && includedPaths.has(component.view))),
131
+ globalComponents: manifest.files.globalComponents ?? [],
132
+ views: (manifest.files.viewContracts ?? []).filter((view) => includedPaths.has(view.file)),
133
+ authorization: manifest.files.authorization.filter((file) => includedPaths.has(file.file)),
134
+ diagnostics,
135
+ summary: {
136
+ fileCount: contextFiles.length,
137
+ diagnosticCount: diagnostics.length,
138
+ truncated,
139
+ },
140
+ };
141
+ }
142
+ function addRouteFiles(route, addFile) {
143
+ addFile(route.file, route.kind === 'page' ? 'route' : route.kind, `route ${route.path}`);
144
+ for (const layout of route.layouts)
145
+ addFile(layout, 'layout', `layout for route ${route.path}`);
146
+ if (route.error !== undefined)
147
+ addFile(route.error, 'error', `error boundary for route ${route.path}`);
148
+ }
149
+ function findRoute(routes, routeInput) {
150
+ const normalized = normalizeRouteInput(routeInput);
151
+ const exact = routes.find((route) => route.path === normalized);
152
+ if (exact !== undefined)
153
+ return exact;
154
+ return routes
155
+ .filter((route) => route.kind === 'page' && routeMatches(route.path, normalized))
156
+ .sort((left, right) => routeSpecificity(right.path) - routeSpecificity(left.path))[0];
157
+ }
158
+ function normalizeRouteInput(route) {
159
+ const pathOnly = route.split(/[?#]/u)[0] ?? '';
160
+ if (pathOnly === '')
161
+ return '/';
162
+ const prefixed = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`;
163
+ return prefixed.length > 1 ? prefixed.replace(/\/+$/u, '') : prefixed;
164
+ }
165
+ function routeMatches(pattern, route) {
166
+ const patternSegments = routeSegments(pattern);
167
+ const routeParts = routeSegments(route);
168
+ for (let index = 0; index < patternSegments.length; index += 1) {
169
+ const patternSegment = patternSegments[index];
170
+ const routeSegment = routeParts[index];
171
+ if (patternSegment?.startsWith('*'))
172
+ return routeSegment !== undefined;
173
+ if (routeSegment === undefined)
174
+ return false;
175
+ if (!patternSegment?.startsWith(':') && patternSegment !== routeSegment)
176
+ return false;
177
+ }
178
+ return patternSegments.length === routeParts.length;
179
+ }
180
+ function routeSegments(route) {
181
+ return route === '/' ? [] : route.split('/').filter(Boolean);
182
+ }
183
+ function routeSpecificity(route) {
184
+ return routeSegments(route).reduce((score, segment) => score + (segment.startsWith('*') ? 0 : segment.startsWith(':') ? 1 : 2), 0);
185
+ }
186
+ function readLocalDependencies(fileName, projectRoot, workspaceRoot) {
187
+ if (!existsSync(fileName))
188
+ return [];
189
+ const source = readFileSync(fileName, 'utf8');
190
+ const sourceFile = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true);
191
+ const dependencies = new Set();
192
+ const visit = (node) => {
193
+ if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
194
+ node.moduleSpecifier !== undefined &&
195
+ ts.isStringLiteral(node.moduleSpecifier)) {
196
+ addResolvedDependency(node.moduleSpecifier.text);
197
+ }
198
+ else if (ts.isCallExpression(node) &&
199
+ node.expression.kind === ts.SyntaxKind.ImportKeyword &&
200
+ node.arguments[0] !== undefined &&
201
+ ts.isStringLiteral(node.arguments[0])) {
202
+ addResolvedDependency(node.arguments[0].text);
203
+ }
204
+ ts.forEachChild(node, visit);
205
+ };
206
+ const addResolvedDependency = (request) => {
207
+ if (!request.startsWith('.'))
208
+ return;
209
+ const dependency = resolveSourceRequest(path.dirname(fileName), request);
210
+ if (dependency === undefined || !isPathInside(projectRoot, dependency))
211
+ return;
212
+ dependencies.add(manifestPath(dependency, workspaceRoot));
213
+ };
214
+ visit(sourceFile);
215
+ return [...dependencies].sort();
216
+ }
217
+ function resolveSourceRequest(directory, request) {
218
+ const requested = path.resolve(directory, request);
219
+ const extension = path.extname(requested);
220
+ const candidates = [
221
+ requested,
222
+ ...(extension === '.js' || extension === '.mjs'
223
+ ? [
224
+ `${requested.slice(0, -extension.length)}.ts`,
225
+ `${requested.slice(0, -extension.length)}.tsx`,
226
+ ]
227
+ : []),
228
+ ...(extension === ''
229
+ ? [
230
+ `${requested}.ts`,
231
+ `${requested}.tsx`,
232
+ `${requested}.html`,
233
+ path.join(requested, 'index.ts'),
234
+ ]
235
+ : []),
236
+ ];
237
+ return candidates.find((candidate) => isContextSourcePath(candidate) && existsSync(candidate) && statSync(candidate).isFile());
238
+ }
239
+ function assertProjectSourceFile(projectRoot, fileName) {
240
+ if (!isPathInside(projectRoot, fileName)) {
241
+ throw new Error(`vobs context file must stay inside the project: ${fileName}`);
242
+ }
243
+ if (!isContextSourcePath(fileName) || !existsSync(fileName) || !statSync(fileName).isFile()) {
244
+ throw new Error(`vobs context file is not a supported project source: ${fileName}`);
245
+ }
246
+ }
247
+ function isContextSourcePath(fileName) {
248
+ const segments = path.resolve(fileName).split(path.sep);
249
+ return (!segments.some((segment) => ignoredPathSegments.has(segment)) &&
250
+ !segments.some((segment) => /^(?:\.env|.*(?:secret|token|credential|password).*)$/iu.test(segment)) &&
251
+ (fileName.endsWith('.ts') || fileName.endsWith('.tsx') || fileName.endsWith('.html')));
252
+ }
253
+ function isTypeScriptPath(fileName) {
254
+ return fileName.endsWith('.ts') || fileName.endsWith('.tsx');
255
+ }
256
+ function isPathInside(root, fileName) {
257
+ const relative = path.relative(root, fileName);
258
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
259
+ }
260
+ function enqueue(file, depth, queue, queued) {
261
+ if (queued.has(file))
262
+ return;
263
+ queued.add(file);
264
+ queue.push({ file, depth });
265
+ }
266
+ function classifyFile(file, manifest) {
267
+ const component = manifest.files.components.find((candidate) => candidate.file === file || candidate.view === file);
268
+ if (component?.view === file)
269
+ return 'view';
270
+ if (component !== undefined)
271
+ return component.kind;
272
+ if (manifest.files.authorization.some((candidate) => candidate.file === file)) {
273
+ return 'authorization';
274
+ }
275
+ return 'dependency';
276
+ }
277
+ function addKnownKinds(file, kinds, manifest) {
278
+ const component = manifest.files.components.find((candidate) => candidate.file === file);
279
+ if (component !== undefined)
280
+ kinds.add(component.kind);
281
+ if (manifest.files.components.some((candidate) => candidate.view === file))
282
+ kinds.add('view');
283
+ if (manifest.files.authorization.some((candidate) => candidate.file === file)) {
284
+ kinds.add('authorization');
285
+ }
286
+ }
287
+ function findWorkspaceRoot(start) {
288
+ let current = path.resolve(start);
289
+ while (true) {
290
+ if (existsSync(path.join(current, 'pnpm-workspace.yaml')))
291
+ return current;
292
+ const parent = path.dirname(current);
293
+ if (parent === current)
294
+ return path.resolve(start);
295
+ current = parent;
296
+ }
297
+ }
298
+ function manifestPath(fileName, workspaceRoot) {
299
+ const relative = path.relative(workspaceRoot, fileName).replaceAll(path.sep, '/');
300
+ return relative === '' ? '.' : relative;
301
+ }
@@ -0,0 +1,12 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/cli
4
+ */
5
+ import { type VobsProjectDiagnosticReport } from '@vobs/ai-contracts';
6
+ export interface DiagnoseOptions {
7
+ readonly tsconfigPath: string;
8
+ readonly route?: string;
9
+ readonly file?: string;
10
+ readonly depth?: number;
11
+ }
12
+ export declare function createVobsDiagnosticReport(options: DiagnoseOptions): VobsProjectDiagnosticReport;
@@ -0,0 +1,122 @@
1
+ /** @license MIT
2
+ * Copyright (c) 2026 vobsjs
3
+ * @vobs/cli
4
+ */
5
+ import { VOBS_AI_SCHEMA_VERSION, VOBS_COMPILER_DIAGNOSTIC_GUIDANCE, } from '@vobs/ai-contracts';
6
+ import { createVobsProjectContext } from './context.js';
7
+ import { inspectVobsProject } from './inspect.js';
8
+ const unknownDiagnosticGuidance = {
9
+ area: 'compiler',
10
+ summary: 'The compiler reported a diagnostic without registered AI guidance.',
11
+ actions: [
12
+ 'Inspect the diagnostic message and reported source location.',
13
+ 'Look up the diagnostic code in the vobs error reference.',
14
+ 'Run vobs check after making a focused change.',
15
+ ],
16
+ documentation: '/reference/errors/',
17
+ };
18
+ export function createVobsDiagnosticReport(options) {
19
+ if (options.route !== undefined && options.file !== undefined) {
20
+ throw new Error('vobs diagnose accepts at most one of --route or --file');
21
+ }
22
+ if (options.depth !== undefined && options.route === undefined && options.file === undefined) {
23
+ throw new Error('vobs diagnose --depth requires --route or --file');
24
+ }
25
+ let selector;
26
+ let project;
27
+ let diagnostics;
28
+ let relationships;
29
+ let truncated = false;
30
+ if (options.route !== undefined || options.file !== undefined) {
31
+ const context = createVobsProjectContext(options);
32
+ selector = context.selector;
33
+ project = context.project;
34
+ diagnostics = context.diagnostics;
35
+ truncated = context.summary.truncated;
36
+ relationships = {
37
+ components: context.components,
38
+ views: context.views ?? [],
39
+ routes: context.routes,
40
+ };
41
+ }
42
+ else {
43
+ const manifest = inspectVobsProject({ tsconfigPath: options.tsconfigPath });
44
+ project = manifest.project;
45
+ diagnostics = manifest.diagnostics;
46
+ relationships = {
47
+ components: manifest.files.components,
48
+ views: manifest.files.viewContracts ?? [],
49
+ routes: manifest.files.routes,
50
+ };
51
+ }
52
+ const guidedDiagnostics = diagnostics.map((diagnostic) => guideDiagnostic(diagnostic, relationships));
53
+ return {
54
+ schemaVersion: VOBS_AI_SCHEMA_VERSION,
55
+ generatedBy: 'vobs diagnose',
56
+ ...(selector === undefined ? {} : { selector }),
57
+ project,
58
+ diagnostics: guidedDiagnostics,
59
+ summary: {
60
+ total: guidedDiagnostics.length,
61
+ errors: countSeverity(guidedDiagnostics, 'error'),
62
+ warnings: countSeverity(guidedDiagnostics, 'warning'),
63
+ infos: countSeverity(guidedDiagnostics, 'info'),
64
+ truncated,
65
+ },
66
+ };
67
+ }
68
+ function guideDiagnostic(diagnostic, relationships) {
69
+ return {
70
+ ...diagnostic,
71
+ guidance: VOBS_COMPILER_DIAGNOSTIC_GUIDANCE[diagnostic.code] ?? unknownDiagnosticGuidance,
72
+ relatedFiles: collectRelatedFiles(diagnostic.source, relationships),
73
+ };
74
+ }
75
+ function collectRelatedFiles(source, relationships) {
76
+ if (source === undefined)
77
+ return [];
78
+ const related = new Set([source]);
79
+ addRelatedComponents(related, relationships.components);
80
+ let changed = true;
81
+ while (changed) {
82
+ changed = false;
83
+ for (const view of relationships.views) {
84
+ if (related.has(view.file)) {
85
+ for (const include of view.includes) {
86
+ if (!related.has(include)) {
87
+ related.add(include);
88
+ changed = true;
89
+ }
90
+ }
91
+ }
92
+ if (view.includes.some((include) => related.has(include)) && !related.has(view.file)) {
93
+ related.add(view.file);
94
+ changed = true;
95
+ }
96
+ }
97
+ }
98
+ addRelatedComponents(related, relationships.components);
99
+ for (const route of relationships.routes) {
100
+ if (route.file !== source && route.error !== source && !route.layouts.includes(source)) {
101
+ continue;
102
+ }
103
+ related.add(route.file);
104
+ for (const layout of route.layouts)
105
+ related.add(layout);
106
+ if (route.error !== undefined)
107
+ related.add(route.error);
108
+ }
109
+ return [...related].sort();
110
+ }
111
+ function addRelatedComponents(related, components) {
112
+ for (const component of components) {
113
+ if (!related.has(component.file) && !related.has(component.view ?? ''))
114
+ continue;
115
+ related.add(component.file);
116
+ if (component.view !== undefined)
117
+ related.add(component.view);
118
+ }
119
+ }
120
+ function countSeverity(diagnostics, severity) {
121
+ return diagnostics.filter((diagnostic) => diagnostic.severity === severity).length;
122
+ }