@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/api.d.ts +19 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +49 -0
- package/dist/api.js.map +1 -0
- package/dist/approval.d.ts +41 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +58 -0
- package/dist/approval.js.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +17 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +22 -0
- package/dist/context.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +25 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +29 -0
- package/dist/middleware.js.map +1 -0
- package/dist/policy.d.ts +22 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +45 -0
- package/dist/policy.js.map +1 -0
- package/dist/server.d.ts +32 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +276 -0
- package/dist/server.js.map +1 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +57 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +837 -0
- package/dist/verify.js.map +1 -0
- package/package.json +45 -0
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
|