@zauso-ai/capstan-core 0.1.2

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/dist/verify.js ADDED
@@ -0,0 +1,837 @@
1
+ import { access, readdir, readFile, stat } from "node:fs/promises";
2
+ import { execFile } from "node:child_process";
3
+ import { dirname, join, relative, resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ // ---------------------------------------------------------------------------
8
+ // Internal helpers
9
+ // ---------------------------------------------------------------------------
10
+ async function pathExists(p) {
11
+ try {
12
+ await access(p);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ async function isDirectory(p) {
20
+ try {
21
+ const s = await stat(p);
22
+ return s.isDirectory();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ async function measureStep(name, fn) {
29
+ const start = performance.now();
30
+ try {
31
+ const diagnostics = await fn();
32
+ const hasErrors = diagnostics.some((d) => d.severity === "error");
33
+ return {
34
+ name,
35
+ status: hasErrors ? "failed" : "passed",
36
+ durationMs: Math.round(performance.now() - start),
37
+ diagnostics,
38
+ };
39
+ }
40
+ catch (err) {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ return {
43
+ name,
44
+ status: "failed",
45
+ durationMs: Math.round(performance.now() - start),
46
+ diagnostics: [
47
+ {
48
+ code: "internal_error",
49
+ severity: "error",
50
+ message: `Step "${name}" threw: ${message}`,
51
+ hint: "This is likely a bug in the verifier. Check the stack trace above.",
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ }
57
+ function skippedStep(name, reason) {
58
+ return {
59
+ name,
60
+ status: "skipped",
61
+ durationMs: 0,
62
+ diagnostics: [
63
+ {
64
+ code: "step_skipped",
65
+ severity: "info",
66
+ message: reason,
67
+ },
68
+ ],
69
+ };
70
+ }
71
+ function buildRepairChecklist(steps) {
72
+ const items = [];
73
+ let index = 1;
74
+ for (const step of steps) {
75
+ for (const d of step.diagnostics) {
76
+ if (d.severity === "info")
77
+ continue;
78
+ const item = {
79
+ index,
80
+ step: step.name,
81
+ message: d.message,
82
+ };
83
+ if (d.file !== undefined)
84
+ item.file = d.file;
85
+ if (d.line !== undefined)
86
+ item.line = d.line;
87
+ if (d.hint !== undefined)
88
+ item.hint = d.hint;
89
+ if (d.fixCategory !== undefined)
90
+ item.fixCategory = d.fixCategory;
91
+ if (d.autoFixable !== undefined)
92
+ item.autoFixable = d.autoFixable;
93
+ items.push(item);
94
+ index++;
95
+ }
96
+ }
97
+ return items;
98
+ }
99
+ function buildReport(appRoot, steps) {
100
+ const hasFailure = steps.some((s) => s.status === "failed");
101
+ let errorCount = 0;
102
+ let warningCount = 0;
103
+ for (const step of steps) {
104
+ for (const d of step.diagnostics) {
105
+ if (d.severity === "error")
106
+ errorCount++;
107
+ if (d.severity === "warning")
108
+ warningCount++;
109
+ }
110
+ }
111
+ return {
112
+ status: hasFailure ? "failed" : "passed",
113
+ appRoot,
114
+ timestamp: new Date().toISOString(),
115
+ steps,
116
+ repairChecklist: buildRepairChecklist(steps),
117
+ summary: {
118
+ totalSteps: steps.length,
119
+ passedSteps: steps.filter((s) => s.status === "passed").length,
120
+ failedSteps: steps.filter((s) => s.status === "failed").length,
121
+ skippedSteps: steps.filter((s) => s.status === "skipped").length,
122
+ errorCount,
123
+ warningCount,
124
+ },
125
+ };
126
+ }
127
+ /**
128
+ * Walk a directory recursively and return all file paths relative to root.
129
+ */
130
+ async function walkFiles(dir, root) {
131
+ const results = [];
132
+ let entries;
133
+ try {
134
+ entries = await readdir(dir, { withFileTypes: true });
135
+ }
136
+ catch {
137
+ return results;
138
+ }
139
+ for (const entry of entries) {
140
+ const full = join(dir, entry.name);
141
+ if (entry.isDirectory()) {
142
+ const nested = await walkFiles(full, root);
143
+ results.push(...nested);
144
+ }
145
+ else if (entry.isFile()) {
146
+ results.push(relative(root, full));
147
+ }
148
+ }
149
+ return results;
150
+ }
151
+ /**
152
+ * Suggest an actionable repair hint based on a TypeScript error message.
153
+ */
154
+ function suggestTypeHint(message) {
155
+ if (message.includes("Cannot find module")) {
156
+ return "Check that the import path is correct and the dependency is installed.";
157
+ }
158
+ if (message.includes("is not assignable to type")) {
159
+ return "Align the value with the expected type contract.";
160
+ }
161
+ if (message.includes("Property") && message.includes("is missing")) {
162
+ return `Add the missing property to satisfy the type contract.`;
163
+ }
164
+ if (message.includes("Property") && message.includes("does not exist")) {
165
+ return "Remove the unknown property or update the type definition.";
166
+ }
167
+ if (message.includes("Cannot find name")) {
168
+ return "Import or declare the referenced identifier.";
169
+ }
170
+ return "Fix the reported TypeScript error and rerun verification.";
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // HTTP methods recognized in API route files
174
+ // ---------------------------------------------------------------------------
175
+ const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
176
+ // ---------------------------------------------------------------------------
177
+ // Step implementations
178
+ // ---------------------------------------------------------------------------
179
+ async function checkStructure(appRoot) {
180
+ const diagnostics = [];
181
+ // Config file — one of the two must exist
182
+ const hasConfigTs = await pathExists(join(appRoot, "capstan.config.ts"));
183
+ const hasConfigJs = await pathExists(join(appRoot, "capstan.config.js"));
184
+ if (!hasConfigTs && !hasConfigJs) {
185
+ diagnostics.push({
186
+ code: "missing_config",
187
+ severity: "error",
188
+ message: "Missing capstan.config.ts or capstan.config.js",
189
+ hint: "Create a capstan.config.ts that exports your app configuration via defineConfig().",
190
+ fixCategory: "missing_file",
191
+ autoFixable: true,
192
+ });
193
+ }
194
+ // Routes directory
195
+ const routesDir = join(appRoot, "app", "routes");
196
+ if (!(await isDirectory(routesDir))) {
197
+ diagnostics.push({
198
+ code: "missing_routes_dir",
199
+ severity: "error",
200
+ message: "Missing app/routes/ directory",
201
+ hint: "Create app/routes/ and add at least one route file (e.g. index.api.ts).",
202
+ fixCategory: "missing_file",
203
+ autoFixable: true,
204
+ });
205
+ }
206
+ // package.json
207
+ if (!(await pathExists(join(appRoot, "package.json")))) {
208
+ diagnostics.push({
209
+ code: "missing_package_json",
210
+ severity: "error",
211
+ message: "Missing package.json",
212
+ hint: "Run npm init or create a package.json manually.",
213
+ fixCategory: "missing_file",
214
+ autoFixable: true,
215
+ });
216
+ }
217
+ // tsconfig.json
218
+ if (!(await pathExists(join(appRoot, "tsconfig.json")))) {
219
+ diagnostics.push({
220
+ code: "missing_tsconfig",
221
+ severity: "error",
222
+ message: "Missing tsconfig.json",
223
+ hint: "Create a tsconfig.json extending @zauso-ai/capstan-core recommended settings.",
224
+ fixCategory: "missing_file",
225
+ autoFixable: true,
226
+ });
227
+ }
228
+ return diagnostics;
229
+ }
230
+ async function checkConfig(appRoot) {
231
+ const diagnostics = [];
232
+ // Find the config file
233
+ let configPath = null;
234
+ const tsPath = join(appRoot, "capstan.config.ts");
235
+ const jsPath = join(appRoot, "capstan.config.js");
236
+ if (await pathExists(tsPath)) {
237
+ configPath = tsPath;
238
+ }
239
+ else if (await pathExists(jsPath)) {
240
+ configPath = jsPath;
241
+ }
242
+ if (!configPath) {
243
+ diagnostics.push({
244
+ code: "config_not_found",
245
+ severity: "error",
246
+ message: "Config file not found (should have been caught by structure step).",
247
+ fixCategory: "missing_file",
248
+ });
249
+ return diagnostics;
250
+ }
251
+ try {
252
+ const configUrl = pathToFileURL(configPath).href;
253
+ const mod = (await import(configUrl));
254
+ if (!mod["default"] && !mod["config"]) {
255
+ diagnostics.push({
256
+ code: "config_no_export",
257
+ severity: "error",
258
+ message: `Config file ${relative(appRoot, configPath)} does not export a default or named "config" value.`,
259
+ hint: 'Export a config object via: export default defineConfig({ ... })',
260
+ file: configPath,
261
+ fixCategory: "missing_export",
262
+ autoFixable: false,
263
+ });
264
+ }
265
+ }
266
+ catch (err) {
267
+ const message = err instanceof Error ? err.message : String(err);
268
+ diagnostics.push({
269
+ code: "config_load_error",
270
+ severity: "error",
271
+ message: `Failed to load config: ${message}`,
272
+ hint: "Ensure the config file is valid TypeScript/JavaScript and all imports resolve.",
273
+ file: configPath,
274
+ fixCategory: "type_error",
275
+ });
276
+ }
277
+ return diagnostics;
278
+ }
279
+ async function checkRoutes(appRoot) {
280
+ const diagnostics = [];
281
+ const routesDir = join(appRoot, "app", "routes");
282
+ if (!(await isDirectory(routesDir))) {
283
+ return diagnostics; // Already caught by structure step
284
+ }
285
+ // Use the router scanner to discover routes
286
+ const { scanRoutes } = await import("@zauso-ai/capstan-router");
287
+ const manifest = await scanRoutes(routesDir);
288
+ const apiRoutes = manifest.routes.filter((r) => r.type === "api");
289
+ if (apiRoutes.length === 0) {
290
+ diagnostics.push({
291
+ code: "no_api_routes",
292
+ severity: "warning",
293
+ message: "No .api.ts route files found in app/routes/",
294
+ hint: "Create at least one API route (e.g. app/routes/index.api.ts) with exported HTTP handlers.",
295
+ });
296
+ return diagnostics;
297
+ }
298
+ for (const route of apiRoutes) {
299
+ const relPath = relative(appRoot, route.filePath);
300
+ // Read and analyze the route source
301
+ let source;
302
+ try {
303
+ source = await readFile(route.filePath, "utf-8");
304
+ }
305
+ catch {
306
+ diagnostics.push({
307
+ code: "route_unreadable",
308
+ severity: "error",
309
+ message: `Cannot read route file: ${relPath}`,
310
+ file: route.filePath,
311
+ fixCategory: "missing_file",
312
+ });
313
+ continue;
314
+ }
315
+ // Check that at least one HTTP method is exported
316
+ const exportedMethods = HTTP_METHODS.filter((m) => {
317
+ // Match: export const GET, export async function GET, export function GET, export { GET }
318
+ const patterns = [
319
+ new RegExp(`export\\s+(const|let|var|async\\s+function|function)\\s+${m}\\b`),
320
+ new RegExp(`export\\s*\\{[^}]*\\b${m}\\b[^}]*\\}`),
321
+ ];
322
+ return patterns.some((p) => p.test(source));
323
+ });
324
+ if (exportedMethods.length === 0) {
325
+ diagnostics.push({
326
+ code: "no_http_exports",
327
+ severity: "error",
328
+ message: `${relPath}: No HTTP method exports found (expected GET, POST, PUT, DELETE, or PATCH)`,
329
+ hint: "Export at least one handler: export const GET = defineAPI({ ... })",
330
+ file: route.filePath,
331
+ fixCategory: "missing_export",
332
+ autoFixable: false,
333
+ });
334
+ continue;
335
+ }
336
+ // For each exported method, check if it's wrapped in defineAPI()
337
+ for (const method of exportedMethods) {
338
+ // Heuristic: look for `export const METHOD = defineAPI({` patterns
339
+ const defineAPIPattern = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*defineAPI\\s*\\(`);
340
+ if (!defineAPIPattern.test(source)) {
341
+ // Also check for two-step: const METHOD = defineAPI(...); export { METHOD }
342
+ const twoStepPattern = new RegExp(`(?:const|let|var)\\s+${method}\\s*=\\s*defineAPI\\s*\\(`);
343
+ const exportPattern = new RegExp(`export\\s*\\{[^}]*\\b${method}\\b[^}]*\\}`);
344
+ if (!(twoStepPattern.test(source) && exportPattern.test(source))) {
345
+ diagnostics.push({
346
+ code: "handler_not_defineapi",
347
+ severity: "warning",
348
+ message: `${relPath}: ${method} handler is not wrapped in defineAPI()`,
349
+ hint: `Wrap the ${method} handler with defineAPI() for type-safe input/output validation and agent introspection.`,
350
+ file: route.filePath,
351
+ fixCategory: "schema_mismatch",
352
+ autoFixable: true,
353
+ });
354
+ }
355
+ }
356
+ }
357
+ // Check write capability handlers for policy field
358
+ for (const method of exportedMethods) {
359
+ // Look for defineAPI blocks that include capability: "write"
360
+ const writeCapabilityPattern = new RegExp(`(?:export\\s+const\\s+${method}|(?:const|let|var)\\s+${method})\\s*=\\s*defineAPI\\s*\\(\\s*\\{[^}]*capability\\s*:\\s*["']write["']`, "s");
361
+ if (writeCapabilityPattern.test(source)) {
362
+ // Check if the same block also has a policy field
363
+ // We grab from the defineAPI call to the closing of its argument
364
+ const blockPattern = new RegExp(`(?:export\\s+const\\s+${method}|(?:const|let|var)\\s+${method})\\s*=\\s*defineAPI\\s*\\(\\s*\\{([^]*?)handler\\s*:`, "s");
365
+ const blockMatch = source.match(blockPattern);
366
+ const blockContent = blockMatch ? blockMatch[1] ?? "" : "";
367
+ if (!blockContent.includes("policy")) {
368
+ diagnostics.push({
369
+ code: "write_missing_policy",
370
+ severity: "warning",
371
+ message: `${relPath}: ${method} handler has capability: "write" but no "policy" field`,
372
+ hint: `Add policy: "requireAuth" to protect write endpoints from unauthorized access.`,
373
+ file: route.filePath,
374
+ fixCategory: "policy_violation",
375
+ autoFixable: true,
376
+ });
377
+ }
378
+ }
379
+ }
380
+ }
381
+ return diagnostics;
382
+ }
383
+ async function checkModels(appRoot) {
384
+ const diagnostics = [];
385
+ const modelsDir = join(appRoot, "app", "models");
386
+ if (!(await isDirectory(modelsDir))) {
387
+ // Models directory is optional — not an error
388
+ return diagnostics;
389
+ }
390
+ const files = await walkFiles(modelsDir, modelsDir);
391
+ const modelFiles = files.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts") && !f.startsWith("_"));
392
+ if (modelFiles.length === 0) {
393
+ diagnostics.push({
394
+ code: "empty_models_dir",
395
+ severity: "info",
396
+ message: "app/models/ exists but contains no model files.",
397
+ });
398
+ return diagnostics;
399
+ }
400
+ for (const relFile of modelFiles) {
401
+ const fullPath = join(modelsDir, relFile);
402
+ const relFromRoot = relative(appRoot, fullPath);
403
+ let source;
404
+ try {
405
+ source = await readFile(fullPath, "utf-8");
406
+ }
407
+ catch {
408
+ diagnostics.push({
409
+ code: "model_unreadable",
410
+ severity: "error",
411
+ message: `Cannot read model file: ${relFromRoot}`,
412
+ file: fullPath,
413
+ fixCategory: "missing_file",
414
+ });
415
+ continue;
416
+ }
417
+ // Check for at least one exported schema or model definition
418
+ const hasExport = /export\s+(const|function|class|type|interface)\b/.test(source) ||
419
+ /export\s*\{/.test(source);
420
+ if (!hasExport) {
421
+ diagnostics.push({
422
+ code: "model_no_exports",
423
+ severity: "warning",
424
+ message: `${relFromRoot}: No exports found in model file`,
425
+ hint: "Model files should export at least one schema, type, or class definition.",
426
+ file: fullPath,
427
+ fixCategory: "missing_export",
428
+ });
429
+ }
430
+ // Check for common model patterns (Zod schemas, Drizzle tables, etc.)
431
+ const hasSchema = /z\.\s*(object|string|number|boolean|enum|array)\s*\(/.test(source) ||
432
+ /sqliteTable|pgTable|mysqlTable/.test(source) ||
433
+ /defineModel/.test(source);
434
+ if (!hasSchema && hasExport) {
435
+ diagnostics.push({
436
+ code: "model_no_schema",
437
+ severity: "info",
438
+ message: `${relFromRoot}: No recognized schema pattern found (Zod, Drizzle, or defineModel)`,
439
+ hint: "Consider using Zod schemas or Drizzle table definitions for type-safe models.",
440
+ file: fullPath,
441
+ });
442
+ }
443
+ }
444
+ return diagnostics;
445
+ }
446
+ async function checkTypeScript(appRoot) {
447
+ const diagnostics = [];
448
+ // Locate tsc binary — first check local node_modules, then fall back to
449
+ // the monorepo root.
450
+ let tscBinary = join(appRoot, "node_modules", ".bin", "tsc");
451
+ if (!(await pathExists(tscBinary))) {
452
+ // Try the monorepo root from @zauso-ai/capstan-core's location
453
+ const packageDir = dirname(fileURLToPath(import.meta.url));
454
+ const repoRoot = resolve(packageDir, "../../..");
455
+ const repoTsc = join(repoRoot, "node_modules", ".bin", "tsc");
456
+ if (await pathExists(repoTsc)) {
457
+ tscBinary = repoTsc;
458
+ }
459
+ else {
460
+ diagnostics.push({
461
+ code: "tsc_not_found",
462
+ severity: "error",
463
+ message: "TypeScript compiler (tsc) not found in node_modules",
464
+ hint: "Install TypeScript: npm install -D typescript",
465
+ fixCategory: "missing_file",
466
+ });
467
+ return diagnostics;
468
+ }
469
+ }
470
+ try {
471
+ await execFileAsync(tscBinary, ["--noEmit", "--pretty", "false"], {
472
+ cwd: appRoot,
473
+ timeout: 60_000,
474
+ });
475
+ // Exit code 0 — no errors
476
+ }
477
+ catch (err) {
478
+ // tsc exits with code 1+ when there are errors. The stderr/stdout
479
+ // contains the diagnostic output.
480
+ const execError = err;
481
+ const output = (execError.stdout ?? "") + (execError.stderr ?? "");
482
+ if (!output.trim()) {
483
+ diagnostics.push({
484
+ code: "tsc_unknown_failure",
485
+ severity: "error",
486
+ message: "TypeScript compiler exited with an error but produced no output.",
487
+ hint: "Run tsc --noEmit manually to see what happened.",
488
+ fixCategory: "type_error",
489
+ });
490
+ return diagnostics;
491
+ }
492
+ // Parse tsc output: file(line,col): error TSxxxx: message
493
+ const pattern = /^(?<file>.+?)\((?<line>\d+),(?<column>\d+)\): error (?<tscode>TS\d+): (?<message>.+)$/gm;
494
+ for (const match of output.matchAll(pattern)) {
495
+ const groups = match.groups;
496
+ if (!groups)
497
+ continue;
498
+ const file = groups["file"] ?? "";
499
+ const message = groups["message"] ?? "Unknown TypeScript error";
500
+ const tsCode = groups["tscode"] ?? "TS0000";
501
+ diagnostics.push({
502
+ code: `typescript_${tsCode}`,
503
+ severity: "error",
504
+ message: `${relative(appRoot, file)}:${groups["line"]} — ${message}`,
505
+ hint: suggestTypeHint(message),
506
+ file: resolve(appRoot, file),
507
+ line: Number(groups["line"]),
508
+ column: Number(groups["column"]),
509
+ fixCategory: "type_error",
510
+ autoFixable: false,
511
+ });
512
+ }
513
+ // If the pattern didn't match anything, report the raw output
514
+ if (diagnostics.length === 0) {
515
+ diagnostics.push({
516
+ code: "tsc_parse_failure",
517
+ severity: "error",
518
+ message: `TypeScript errors detected but could not be parsed. Raw output:\n${output.slice(0, 500)}`,
519
+ hint: "Run tsc --noEmit manually to see the full output.",
520
+ fixCategory: "type_error",
521
+ });
522
+ }
523
+ }
524
+ return diagnostics;
525
+ }
526
+ async function checkContracts(appRoot) {
527
+ const diagnostics = [];
528
+ const routesDir = join(appRoot, "app", "routes");
529
+ const modelsDir = join(appRoot, "app", "models");
530
+ const policiesDir = join(appRoot, "app", "policies");
531
+ // Gather route names (directory names under app/routes/)
532
+ const routeNames = new Set();
533
+ if (await isDirectory(routesDir)) {
534
+ try {
535
+ const entries = await readdir(routesDir, { withFileTypes: true });
536
+ for (const entry of entries) {
537
+ if (entry.isDirectory()) {
538
+ routeNames.add(entry.name.toLowerCase());
539
+ }
540
+ }
541
+ }
542
+ catch {
543
+ // Ignore read errors — structure step would have caught missing dir
544
+ }
545
+ }
546
+ // Gather model names (filename stems under app/models/)
547
+ const modelNames = new Set();
548
+ if (await isDirectory(modelsDir)) {
549
+ const modelFiles = await walkFiles(modelsDir, modelsDir);
550
+ for (const f of modelFiles) {
551
+ if (f.endsWith(".ts") && !f.endsWith(".d.ts")) {
552
+ // "ticket.ts" -> "ticket"
553
+ const stem = f.replace(/\.ts$/, "").split("/").pop();
554
+ if (stem)
555
+ modelNames.add(stem.toLowerCase());
556
+ }
557
+ }
558
+ }
559
+ // Gather defined policy keys
560
+ const policyKeys = new Set();
561
+ if (await isDirectory(policiesDir)) {
562
+ const policyFiles = await walkFiles(policiesDir, policiesDir);
563
+ for (const f of policyFiles) {
564
+ if (!f.endsWith(".ts") || f.endsWith(".d.ts"))
565
+ continue;
566
+ const fullPath = join(policiesDir, f);
567
+ try {
568
+ const source = await readFile(fullPath, "utf-8");
569
+ // Match: definePolicy({ key: "someKey" ... })
570
+ const keyPattern = /definePolicy\s*\(\s*\{[^}]*key\s*:\s*["']([^"']+)["']/g;
571
+ for (const match of source.matchAll(keyPattern)) {
572
+ if (match[1])
573
+ policyKeys.add(match[1]);
574
+ }
575
+ }
576
+ catch {
577
+ // Skip unreadable files
578
+ }
579
+ }
580
+ }
581
+ // Cross-reference models and routes: if model "ticket" exists and route "tickets" exists,
582
+ // check they reference each other (informational)
583
+ for (const model of modelNames) {
584
+ // Simple pluralization: "ticket" -> "tickets"
585
+ const plural = model.endsWith("s") ? model : model + "s";
586
+ if (routeNames.has(plural) || routeNames.has(model)) {
587
+ // This is expected — no diagnostic needed, they match.
588
+ continue;
589
+ }
590
+ // Model exists without matching route — informational
591
+ diagnostics.push({
592
+ code: "model_without_route",
593
+ severity: "info",
594
+ message: `Model "${model}" has no matching route directory (expected "${plural}" or "${model}")`,
595
+ hint: `Consider creating app/routes/${plural}/ with API handlers for this model.`,
596
+ fixCategory: "contract_drift",
597
+ });
598
+ }
599
+ // Check API route files for meta.resource references to models
600
+ if (await isDirectory(routesDir)) {
601
+ const { scanRoutes } = await import("@zauso-ai/capstan-router");
602
+ const manifest = await scanRoutes(routesDir);
603
+ const apiRoutes = manifest.routes.filter((r) => r.type === "api");
604
+ for (const route of apiRoutes) {
605
+ const relPath = relative(appRoot, route.filePath);
606
+ let source;
607
+ try {
608
+ source = await readFile(route.filePath, "utf-8");
609
+ }
610
+ catch {
611
+ continue;
612
+ }
613
+ // Check resource references: resource: "ticket"
614
+ const resourcePattern = /resource\s*:\s*["']([^"']+)["']/g;
615
+ for (const match of source.matchAll(resourcePattern)) {
616
+ const resource = match[1]?.toLowerCase();
617
+ if (resource && !modelNames.has(resource)) {
618
+ diagnostics.push({
619
+ code: "resource_no_model",
620
+ severity: "warning",
621
+ message: `${relPath}: references resource "${resource}" but no matching model file found`,
622
+ hint: `Create app/models/${resource}.ts with the schema for this resource.`,
623
+ file: route.filePath,
624
+ fixCategory: "contract_drift",
625
+ });
626
+ }
627
+ }
628
+ // Check policy references: policy: "requireAuth"
629
+ const policyPattern = /policy\s*:\s*["']([^"']+)["']/g;
630
+ for (const match of source.matchAll(policyPattern)) {
631
+ const policyRef = match[1];
632
+ if (policyRef && !policyKeys.has(policyRef)) {
633
+ diagnostics.push({
634
+ code: "policy_not_defined",
635
+ severity: "error",
636
+ message: `${relPath}: references policy "${policyRef}" but it is not defined`,
637
+ hint: `Define the "${policyRef}" policy in app/policies/index.ts using definePolicy().`,
638
+ file: route.filePath,
639
+ fixCategory: "policy_violation",
640
+ });
641
+ }
642
+ }
643
+ }
644
+ }
645
+ return diagnostics;
646
+ }
647
+ async function checkManifest(appRoot) {
648
+ const diagnostics = [];
649
+ const routesDir = join(appRoot, "app", "routes");
650
+ if (!(await isDirectory(routesDir))) {
651
+ return diagnostics;
652
+ }
653
+ // Generate a fresh manifest from the current routes
654
+ const { scanRoutes } = await import("@zauso-ai/capstan-router");
655
+ const { generateRouteManifest } = await import("@zauso-ai/capstan-router");
656
+ const routeManifest = await scanRoutes(routesDir);
657
+ const { apiRoutes } = generateRouteManifest(routeManifest);
658
+ if (apiRoutes.length === 0) {
659
+ diagnostics.push({
660
+ code: "manifest_empty",
661
+ severity: "warning",
662
+ message: "Agent manifest has no API routes.",
663
+ hint: "Add at least one .api.ts file under app/routes/ to generate capabilities.",
664
+ });
665
+ return diagnostics;
666
+ }
667
+ // For each API route, verify basic expectations
668
+ for (const apiRoute of apiRoutes) {
669
+ const relPath = relative(appRoot, apiRoute.filePath);
670
+ // Verify the route file actually exists
671
+ if (!(await pathExists(apiRoute.filePath))) {
672
+ diagnostics.push({
673
+ code: "manifest_orphan_route",
674
+ severity: "error",
675
+ message: `Manifest references ${apiRoute.method} ${apiRoute.path} but file is missing: ${relPath}`,
676
+ file: apiRoute.filePath,
677
+ fixCategory: "contract_drift",
678
+ });
679
+ }
680
+ }
681
+ // Check for API route files that exist on disk but are NOT in the manifest
682
+ const manifestFilePaths = new Set(apiRoutes.map((r) => r.filePath));
683
+ const diskApiRoutes = routeManifest.routes.filter((r) => r.type === "api");
684
+ for (const route of diskApiRoutes) {
685
+ if (!manifestFilePaths.has(route.filePath)) {
686
+ diagnostics.push({
687
+ code: "manifest_missing_route",
688
+ severity: "warning",
689
+ message: `API route ${relative(appRoot, route.filePath)} exists on disk but not in manifest`,
690
+ file: route.filePath,
691
+ fixCategory: "contract_drift",
692
+ });
693
+ }
694
+ }
695
+ // Check that each route file exports input/output schemas (informational)
696
+ for (const apiRoute of apiRoutes) {
697
+ let source;
698
+ try {
699
+ source = await readFile(apiRoute.filePath, "utf-8");
700
+ }
701
+ catch {
702
+ continue;
703
+ }
704
+ // Look for defineAPI calls with input and output schemas
705
+ const hasInput = /\binput\s*:/.test(source);
706
+ const hasOutput = /\boutput\s*:/.test(source);
707
+ if (!hasInput || !hasOutput) {
708
+ const missing = [];
709
+ if (!hasInput)
710
+ missing.push("input");
711
+ if (!hasOutput)
712
+ missing.push("output");
713
+ diagnostics.push({
714
+ code: "manifest_missing_schema",
715
+ severity: "info",
716
+ message: `${relative(appRoot, apiRoute.filePath)}: ${apiRoute.method} handler missing ${missing.join(" and ")} schema`,
717
+ hint: "Add Zod input/output schemas to defineAPI() for full agent introspection.",
718
+ file: apiRoute.filePath,
719
+ fixCategory: "schema_mismatch",
720
+ });
721
+ }
722
+ }
723
+ return diagnostics;
724
+ }
725
+ // ---------------------------------------------------------------------------
726
+ // Main entry point
727
+ // ---------------------------------------------------------------------------
728
+ /**
729
+ * Verify a Capstan runtime application.
730
+ *
731
+ * Runs a cascade of checks: structure -> config -> routes -> models ->
732
+ * typecheck -> contracts -> manifest. If an early step fails, dependent
733
+ * steps are skipped. Returns a structured VerifyReport suitable for both
734
+ * human display and AI agent consumption.
735
+ */
736
+ export async function verifyCapstanApp(appRoot) {
737
+ const root = resolve(appRoot);
738
+ const steps = [];
739
+ // Step 1: structure
740
+ const structureStep = await measureStep("structure", () => checkStructure(root));
741
+ steps.push(structureStep);
742
+ if (structureStep.status === "failed") {
743
+ steps.push(skippedStep("config", "Skipped: structure check failed."));
744
+ steps.push(skippedStep("routes", "Skipped: structure check failed."));
745
+ steps.push(skippedStep("models", "Skipped: structure check failed."));
746
+ steps.push(skippedStep("typecheck", "Skipped: structure check failed."));
747
+ steps.push(skippedStep("contracts", "Skipped: structure check failed."));
748
+ steps.push(skippedStep("manifest", "Skipped: structure check failed."));
749
+ return buildReport(root, steps);
750
+ }
751
+ // Step 2: config
752
+ const configStep = await measureStep("config", () => checkConfig(root));
753
+ steps.push(configStep);
754
+ if (configStep.status === "failed") {
755
+ steps.push(skippedStep("routes", "Skipped: config check failed."));
756
+ steps.push(skippedStep("models", "Skipped: config check failed."));
757
+ steps.push(skippedStep("typecheck", "Skipped: config check failed."));
758
+ steps.push(skippedStep("contracts", "Skipped: config check failed."));
759
+ steps.push(skippedStep("manifest", "Skipped: config check failed."));
760
+ return buildReport(root, steps);
761
+ }
762
+ // Step 3: routes
763
+ const routesStep = await measureStep("routes", () => checkRoutes(root));
764
+ steps.push(routesStep);
765
+ // Step 4: models — runs even if routes fail (independent check)
766
+ const modelsStep = await measureStep("models", () => checkModels(root));
767
+ steps.push(modelsStep);
768
+ // Step 5: typecheck — runs even if routes/models have warnings, but skip
769
+ // if routes had hard errors (broken files will cause tsc noise)
770
+ if (routesStep.status === "failed") {
771
+ steps.push(skippedStep("typecheck", "Skipped: routes check failed."));
772
+ steps.push(skippedStep("contracts", "Skipped: routes check failed."));
773
+ steps.push(skippedStep("manifest", "Skipped: routes check failed."));
774
+ return buildReport(root, steps);
775
+ }
776
+ const typecheckStep = await measureStep("typecheck", () => checkTypeScript(root));
777
+ steps.push(typecheckStep);
778
+ if (typecheckStep.status === "failed") {
779
+ steps.push(skippedStep("contracts", "Skipped: typecheck failed."));
780
+ steps.push(skippedStep("manifest", "Skipped: typecheck failed."));
781
+ return buildReport(root, steps);
782
+ }
783
+ // Step 6: contracts
784
+ const contractsStep = await measureStep("contracts", () => checkContracts(root));
785
+ steps.push(contractsStep);
786
+ if (contractsStep.status === "failed") {
787
+ steps.push(skippedStep("manifest", "Skipped: contracts check failed."));
788
+ return buildReport(root, steps);
789
+ }
790
+ // Step 7: manifest
791
+ const manifestStep = await measureStep("manifest", () => checkManifest(root));
792
+ steps.push(manifestStep);
793
+ return buildReport(root, steps);
794
+ }
795
+ // ---------------------------------------------------------------------------
796
+ // Human-readable report renderer
797
+ // ---------------------------------------------------------------------------
798
+ /**
799
+ * Render a VerifyReport as human-readable text output.
800
+ *
801
+ * Uses simple ASCII indicators: check mark for pass, x for fail, dash for skip.
802
+ */
803
+ export function renderRuntimeVerifyText(report) {
804
+ const lines = [];
805
+ lines.push("Capstan Verify");
806
+ lines.push("");
807
+ for (const step of report.steps) {
808
+ const icon = step.status === "passed" ? "\u2713" : step.status === "failed" ? "\u2717" : "-";
809
+ const durationLabel = step.status === "skipped" ? "skipped" : `${step.durationMs}ms`;
810
+ lines.push(` ${icon} ${step.name.padEnd(14)} (${durationLabel})`);
811
+ // Show error/warning diagnostics inline
812
+ for (const d of step.diagnostics) {
813
+ if (d.severity === "info")
814
+ continue;
815
+ const marker = d.severity === "error" ? "\u2717" : "!";
816
+ lines.push(` ${marker} ${d.message}`);
817
+ if (d.hint) {
818
+ lines.push(` \u2192 ${d.hint}`);
819
+ }
820
+ }
821
+ }
822
+ lines.push("");
823
+ lines.push(` ${report.summary.errorCount} error${report.summary.errorCount !== 1 ? "s" : ""}, ${report.summary.warningCount} warning${report.summary.warningCount !== 1 ? "s" : ""}`);
824
+ if (report.repairChecklist.length > 0) {
825
+ lines.push("");
826
+ lines.push(" Repair Checklist:");
827
+ for (const item of report.repairChecklist) {
828
+ lines.push(` ${item.index}. [${item.step}] ${item.message}`);
829
+ if (item.hint) {
830
+ lines.push(` \u2192 ${item.hint}`);
831
+ }
832
+ }
833
+ }
834
+ lines.push("");
835
+ return lines.join("\n");
836
+ }
837
+ //# sourceMappingURL=verify.js.map