@schemasentry/cli 0.1.0 → 0.3.1
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/index.js +967 -99
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFile } from "fs/promises";
|
|
6
|
-
import
|
|
5
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
6
|
+
import { readFileSync } from "fs";
|
|
7
|
+
import path4 from "path";
|
|
7
8
|
import { stableStringify as stableStringify2 } from "@schemasentry/core";
|
|
8
9
|
|
|
9
10
|
// src/report.ts
|
|
@@ -11,47 +12,290 @@ import {
|
|
|
11
12
|
stableStringify,
|
|
12
13
|
validateSchema
|
|
13
14
|
} from "@schemasentry/core";
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
// src/coverage.ts
|
|
17
|
+
var buildCoverageResult = (input2, data) => {
|
|
18
|
+
const manifestRoutes = input2.expectedTypesByRoute ?? {};
|
|
16
19
|
const dataRoutes = data.routes ?? {};
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
...
|
|
20
|
+
const derivedRequiredRoutes = Object.entries(manifestRoutes).filter(([, types]) => (types ?? []).length > 0).map(([route]) => route);
|
|
21
|
+
const requiredRoutes = /* @__PURE__ */ new Set([
|
|
22
|
+
...input2.requiredRoutes ?? [],
|
|
23
|
+
...derivedRequiredRoutes
|
|
20
24
|
]);
|
|
21
|
-
const
|
|
25
|
+
const allRoutes = Array.from(
|
|
26
|
+
/* @__PURE__ */ new Set([
|
|
27
|
+
...Object.keys(manifestRoutes),
|
|
28
|
+
...requiredRoutes,
|
|
29
|
+
...Object.keys(dataRoutes)
|
|
30
|
+
])
|
|
31
|
+
).sort();
|
|
32
|
+
const issuesByRoute = {};
|
|
33
|
+
const summary = {
|
|
34
|
+
missingRoutes: 0,
|
|
35
|
+
missingTypes: 0,
|
|
36
|
+
unlistedRoutes: 0
|
|
37
|
+
};
|
|
38
|
+
for (const route of allRoutes) {
|
|
39
|
+
const issues = [];
|
|
22
40
|
const expectedTypes = manifestRoutes[route] ?? [];
|
|
23
41
|
const nodes = dataRoutes[route] ?? [];
|
|
24
42
|
const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
43
|
+
if (requiredRoutes.has(route) && nodes.length === 0) {
|
|
44
|
+
issues.push({
|
|
45
|
+
path: `routes["${route}"]`,
|
|
46
|
+
message: "No schema blocks found for route",
|
|
47
|
+
severity: "error",
|
|
48
|
+
ruleId: "coverage.missing_route"
|
|
49
|
+
});
|
|
50
|
+
summary.missingRoutes += 1;
|
|
51
|
+
}
|
|
52
|
+
for (const expectedType of expectedTypes) {
|
|
53
|
+
if (!foundTypes.includes(expectedType)) {
|
|
29
54
|
issues.push({
|
|
30
|
-
path: `routes["${route}"]`,
|
|
31
|
-
message:
|
|
55
|
+
path: `routes["${route}"].types`,
|
|
56
|
+
message: `Missing expected schema type '${expectedType}'`,
|
|
32
57
|
severity: "error",
|
|
33
|
-
ruleId: "coverage.
|
|
58
|
+
ruleId: "coverage.missing_type"
|
|
34
59
|
});
|
|
35
|
-
|
|
36
|
-
for (const expectedType of expectedTypes) {
|
|
37
|
-
if (!foundTypes.includes(expectedType)) {
|
|
38
|
-
issues.push({
|
|
39
|
-
path: `routes["${route}"].types`,
|
|
40
|
-
message: `Missing expected schema type '${expectedType}'`,
|
|
41
|
-
severity: "error",
|
|
42
|
-
ruleId: "coverage.missing_type"
|
|
43
|
-
});
|
|
44
|
-
}
|
|
60
|
+
summary.missingTypes += 1;
|
|
45
61
|
}
|
|
46
62
|
}
|
|
47
|
-
if (!manifestRoutes[route] && nodes.length > 0) {
|
|
63
|
+
if (!manifestRoutes[route] && !requiredRoutes.has(route) && nodes.length > 0) {
|
|
48
64
|
issues.push({
|
|
49
65
|
path: `routes["${route}"]`,
|
|
50
66
|
message: "Route has schema but is missing from manifest",
|
|
51
67
|
severity: "warn",
|
|
52
68
|
ruleId: "coverage.unlisted_route"
|
|
53
69
|
});
|
|
70
|
+
summary.unlistedRoutes += 1;
|
|
71
|
+
}
|
|
72
|
+
if (issues.length > 0) {
|
|
73
|
+
issuesByRoute[route] = issues;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
allRoutes,
|
|
78
|
+
issuesByRoute,
|
|
79
|
+
summary
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/report.ts
|
|
84
|
+
var buildReport = (manifest, data, options = {}) => {
|
|
85
|
+
const manifestRoutes = manifest.routes ?? {};
|
|
86
|
+
const dataRoutes = data.routes ?? {};
|
|
87
|
+
const coverage = buildCoverageResult(
|
|
88
|
+
{ expectedTypesByRoute: manifestRoutes },
|
|
89
|
+
data
|
|
90
|
+
);
|
|
91
|
+
const routes = coverage.allRoutes.map((route) => {
|
|
92
|
+
const expectedTypes = manifestRoutes[route] ?? [];
|
|
93
|
+
const nodes = dataRoutes[route] ?? [];
|
|
94
|
+
const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
95
|
+
const validation = validateSchema(nodes, options);
|
|
96
|
+
const issues = [
|
|
97
|
+
...validation.issues,
|
|
98
|
+
...coverage.issuesByRoute[route] ?? []
|
|
99
|
+
];
|
|
100
|
+
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
101
|
+
const warnCount = issues.filter((issue) => issue.severity === "warn").length;
|
|
102
|
+
const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
|
|
103
|
+
return {
|
|
104
|
+
route,
|
|
105
|
+
ok: errorCount === 0,
|
|
106
|
+
score,
|
|
107
|
+
issues,
|
|
108
|
+
expectedTypes,
|
|
109
|
+
foundTypes
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
const summaryErrors = routes.reduce(
|
|
113
|
+
(count, route) => count + route.issues.filter((i) => i.severity === "error").length,
|
|
114
|
+
0
|
|
115
|
+
);
|
|
116
|
+
const summaryWarnings = routes.reduce(
|
|
117
|
+
(count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
|
|
118
|
+
0
|
|
119
|
+
);
|
|
120
|
+
const summaryScore = routes.length === 0 ? 0 : Math.round(
|
|
121
|
+
routes.reduce((total, route) => total + route.score, 0) / routes.length
|
|
122
|
+
);
|
|
123
|
+
return {
|
|
124
|
+
ok: summaryErrors === 0,
|
|
125
|
+
summary: {
|
|
126
|
+
routes: routes.length,
|
|
127
|
+
errors: summaryErrors,
|
|
128
|
+
warnings: summaryWarnings,
|
|
129
|
+
score: summaryScore,
|
|
130
|
+
coverage: coverage.summary
|
|
131
|
+
},
|
|
132
|
+
routes
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// src/init.ts
|
|
137
|
+
import { promises as fs } from "fs";
|
|
138
|
+
import path from "path";
|
|
139
|
+
import { SCHEMA_CONTEXT } from "@schemasentry/core";
|
|
140
|
+
var DEFAULT_ANSWERS = {
|
|
141
|
+
siteName: "Acme Corp",
|
|
142
|
+
siteUrl: "https://acme.com",
|
|
143
|
+
authorName: "Jane Doe"
|
|
144
|
+
};
|
|
145
|
+
var getDefaultAnswers = () => ({ ...DEFAULT_ANSWERS });
|
|
146
|
+
var buildManifest = (scannedRoutes = []) => {
|
|
147
|
+
const routes = {
|
|
148
|
+
"/": ["Organization", "WebSite"],
|
|
149
|
+
"/blog/[slug]": ["Article"]
|
|
150
|
+
};
|
|
151
|
+
for (const route of scannedRoutes) {
|
|
152
|
+
if (!routes[route]) {
|
|
153
|
+
routes[route] = ["WebPage"];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { routes };
|
|
157
|
+
};
|
|
158
|
+
var formatDate = (value) => value.toISOString().slice(0, 10);
|
|
159
|
+
var buildData = (answers, options = {}) => {
|
|
160
|
+
const { siteName, siteUrl, authorName } = answers;
|
|
161
|
+
const normalizedSiteUrl = normalizeUrl(siteUrl);
|
|
162
|
+
const today = options.today ?? /* @__PURE__ */ new Date();
|
|
163
|
+
const date = formatDate(today);
|
|
164
|
+
const logoUrl = new URL("/logo.png", normalizedSiteUrl).toString();
|
|
165
|
+
const articleUrl = new URL("/blog/hello-world", normalizedSiteUrl).toString();
|
|
166
|
+
const imageUrl = new URL("/blog/hello-world.png", normalizedSiteUrl).toString();
|
|
167
|
+
const routes = {
|
|
168
|
+
"/": [
|
|
169
|
+
{
|
|
170
|
+
"@context": SCHEMA_CONTEXT,
|
|
171
|
+
"@type": "Organization",
|
|
172
|
+
name: siteName,
|
|
173
|
+
url: normalizedSiteUrl,
|
|
174
|
+
logo: logoUrl,
|
|
175
|
+
description: `Official website of ${siteName}`
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"@context": SCHEMA_CONTEXT,
|
|
179
|
+
"@type": "WebSite",
|
|
180
|
+
name: siteName,
|
|
181
|
+
url: normalizedSiteUrl,
|
|
182
|
+
description: `Learn more about ${siteName}`
|
|
183
|
+
}
|
|
184
|
+
],
|
|
185
|
+
"/blog/[slug]": [
|
|
186
|
+
{
|
|
187
|
+
"@context": SCHEMA_CONTEXT,
|
|
188
|
+
"@type": "Article",
|
|
189
|
+
headline: "Hello World",
|
|
190
|
+
author: {
|
|
191
|
+
"@type": "Person",
|
|
192
|
+
name: authorName
|
|
193
|
+
},
|
|
194
|
+
datePublished: date,
|
|
195
|
+
dateModified: date,
|
|
196
|
+
description: `An introduction to ${siteName}`,
|
|
197
|
+
image: imageUrl,
|
|
198
|
+
url: articleUrl
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
};
|
|
202
|
+
for (const route of options.scannedRoutes ?? []) {
|
|
203
|
+
if (routes[route]) {
|
|
204
|
+
continue;
|
|
54
205
|
}
|
|
206
|
+
routes[route] = [
|
|
207
|
+
{
|
|
208
|
+
"@context": SCHEMA_CONTEXT,
|
|
209
|
+
"@type": "WebPage",
|
|
210
|
+
name: routeToName(route),
|
|
211
|
+
url: new URL(route, normalizedSiteUrl).toString()
|
|
212
|
+
}
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
return { routes };
|
|
216
|
+
};
|
|
217
|
+
var writeInitFiles = async (options) => {
|
|
218
|
+
const {
|
|
219
|
+
manifestPath,
|
|
220
|
+
dataPath,
|
|
221
|
+
overwriteManifest,
|
|
222
|
+
overwriteData,
|
|
223
|
+
answers,
|
|
224
|
+
today,
|
|
225
|
+
scannedRoutes
|
|
226
|
+
} = options;
|
|
227
|
+
const manifest = buildManifest(scannedRoutes ?? []);
|
|
228
|
+
const data = buildData(answers, { today, scannedRoutes });
|
|
229
|
+
const manifestResult = await writeJsonFile(
|
|
230
|
+
manifestPath,
|
|
231
|
+
manifest,
|
|
232
|
+
overwriteManifest
|
|
233
|
+
);
|
|
234
|
+
const dataResult = await writeJsonFile(dataPath, data, overwriteData);
|
|
235
|
+
return {
|
|
236
|
+
manifest: manifestResult,
|
|
237
|
+
data: dataResult
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
var writeJsonFile = async (filePath, payload, overwrite) => {
|
|
241
|
+
const exists = await fileExists(filePath);
|
|
242
|
+
if (exists && !overwrite) {
|
|
243
|
+
return "skipped";
|
|
244
|
+
}
|
|
245
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
246
|
+
const json = JSON.stringify(payload, null, 2);
|
|
247
|
+
await fs.writeFile(filePath, `${json}
|
|
248
|
+
`, "utf8");
|
|
249
|
+
return exists ? "overwritten" : "created";
|
|
250
|
+
};
|
|
251
|
+
var fileExists = async (filePath) => {
|
|
252
|
+
try {
|
|
253
|
+
await fs.access(filePath);
|
|
254
|
+
return true;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
var normalizeUrl = (value) => {
|
|
260
|
+
try {
|
|
261
|
+
return new URL(value).toString();
|
|
262
|
+
} catch {
|
|
263
|
+
return new URL(`https://${value}`).toString();
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
var routeToName = (route) => {
|
|
267
|
+
if (route === "/") {
|
|
268
|
+
return "Home";
|
|
269
|
+
}
|
|
270
|
+
const cleaned = route.replace(/\[|\]/g, "").split("/").filter(Boolean).join(" ");
|
|
271
|
+
return cleaned.split(/\s+/).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// src/audit.ts
|
|
275
|
+
import {
|
|
276
|
+
validateSchema as validateSchema2
|
|
277
|
+
} from "@schemasentry/core";
|
|
278
|
+
var buildAuditReport = (data, options = {}) => {
|
|
279
|
+
const dataRoutes = data.routes ?? {};
|
|
280
|
+
const manifestRoutes = options.manifest?.routes ?? {};
|
|
281
|
+
const coverageEnabled = Boolean(options.manifest || options.requiredRoutes?.length);
|
|
282
|
+
const coverage = coverageEnabled ? buildCoverageResult(
|
|
283
|
+
{
|
|
284
|
+
expectedTypesByRoute: manifestRoutes,
|
|
285
|
+
requiredRoutes: options.requiredRoutes
|
|
286
|
+
},
|
|
287
|
+
data
|
|
288
|
+
) : null;
|
|
289
|
+
const allRoutes = coverageEnabled ? coverage?.allRoutes ?? [] : Object.keys(dataRoutes).sort();
|
|
290
|
+
const routes = allRoutes.map((route) => {
|
|
291
|
+
const expectedTypes = coverageEnabled ? manifestRoutes[route] ?? [] : [];
|
|
292
|
+
const nodes = dataRoutes[route] ?? [];
|
|
293
|
+
const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
294
|
+
const validation = validateSchema2(nodes, options);
|
|
295
|
+
const issues = [
|
|
296
|
+
...validation.issues,
|
|
297
|
+
...coverage?.issuesByRoute[route] ?? []
|
|
298
|
+
];
|
|
55
299
|
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
56
300
|
const warnCount = issues.filter((issue) => issue.severity === "warn").length;
|
|
57
301
|
const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
|
|
@@ -81,15 +325,340 @@ var buildReport = (manifest, data) => {
|
|
|
81
325
|
routes: routes.length,
|
|
82
326
|
errors: summaryErrors,
|
|
83
327
|
warnings: summaryWarnings,
|
|
84
|
-
score: summaryScore
|
|
328
|
+
score: summaryScore,
|
|
329
|
+
...coverageEnabled ? { coverage: coverage?.summary } : {}
|
|
85
330
|
},
|
|
86
331
|
routes
|
|
87
332
|
};
|
|
88
333
|
};
|
|
89
334
|
|
|
335
|
+
// src/summary.ts
|
|
336
|
+
var formatDuration = (durationMs) => {
|
|
337
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
338
|
+
return "0ms";
|
|
339
|
+
}
|
|
340
|
+
if (durationMs < 1e3) {
|
|
341
|
+
return `${Math.round(durationMs)}ms`;
|
|
342
|
+
}
|
|
343
|
+
const seconds = durationMs / 1e3;
|
|
344
|
+
return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
|
345
|
+
};
|
|
346
|
+
var formatSummaryLine = (label, stats) => {
|
|
347
|
+
const parts = [
|
|
348
|
+
`Routes: ${stats.routes}`,
|
|
349
|
+
`Errors: ${stats.errors}`,
|
|
350
|
+
`Warnings: ${stats.warnings}`,
|
|
351
|
+
`Score: ${stats.score}`,
|
|
352
|
+
`Duration: ${formatDuration(stats.durationMs)}`
|
|
353
|
+
];
|
|
354
|
+
if (stats.coverage) {
|
|
355
|
+
parts.push(
|
|
356
|
+
`Coverage: missing_routes=${stats.coverage.missingRoutes} missing_types=${stats.coverage.missingTypes} unlisted_routes=${stats.coverage.unlistedRoutes}`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return `${label} | ${parts.join(" | ")}`;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/config.ts
|
|
363
|
+
import { promises as fs2 } from "fs";
|
|
364
|
+
import path2 from "path";
|
|
365
|
+
var ConfigError = class extends Error {
|
|
366
|
+
constructor(code, message, suggestion) {
|
|
367
|
+
super(message);
|
|
368
|
+
this.code = code;
|
|
369
|
+
this.suggestion = suggestion;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
var DEFAULT_CONFIG_PATH = "schema-sentry.config.json";
|
|
373
|
+
var loadConfig = async (options) => {
|
|
374
|
+
const cwd = options.cwd ?? process.cwd();
|
|
375
|
+
const explicit = Boolean(options.configPath);
|
|
376
|
+
const resolvedPath = path2.resolve(cwd, options.configPath ?? DEFAULT_CONFIG_PATH);
|
|
377
|
+
const exists = await fileExists2(resolvedPath);
|
|
378
|
+
if (!exists) {
|
|
379
|
+
if (explicit) {
|
|
380
|
+
throw new ConfigError(
|
|
381
|
+
"config.not_found",
|
|
382
|
+
`Config not found at ${resolvedPath}`,
|
|
383
|
+
"Provide a valid path or remove --config."
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
let raw;
|
|
389
|
+
try {
|
|
390
|
+
raw = await fs2.readFile(resolvedPath, "utf8");
|
|
391
|
+
} catch (error) {
|
|
392
|
+
throw new ConfigError(
|
|
393
|
+
"config.read_failed",
|
|
394
|
+
`Failed to read config at ${resolvedPath}`,
|
|
395
|
+
"Check file permissions or re-create the config file."
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
let parsed;
|
|
399
|
+
try {
|
|
400
|
+
parsed = JSON.parse(raw);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new ConfigError(
|
|
403
|
+
"config.invalid_json",
|
|
404
|
+
"Config is not valid JSON",
|
|
405
|
+
"Check the JSON syntax or regenerate the file."
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (!isConfig(parsed)) {
|
|
409
|
+
throw new ConfigError(
|
|
410
|
+
"config.invalid_shape",
|
|
411
|
+
"Config must be a JSON object with optional boolean 'recommended'",
|
|
412
|
+
'Example: { "recommended": false }'
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return parsed;
|
|
416
|
+
};
|
|
417
|
+
var resolveRecommended = (cliOverride, config) => cliOverride ?? config?.recommended ?? true;
|
|
418
|
+
var isConfig = (value) => {
|
|
419
|
+
if (!value || typeof value !== "object") {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
const config = value;
|
|
423
|
+
if (config.recommended !== void 0 && typeof config.recommended !== "boolean") {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
};
|
|
428
|
+
var fileExists2 = async (filePath) => {
|
|
429
|
+
try {
|
|
430
|
+
await fs2.access(filePath);
|
|
431
|
+
return true;
|
|
432
|
+
} catch {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// src/routes.ts
|
|
438
|
+
import { promises as fs3 } from "fs";
|
|
439
|
+
import path3 from "path";
|
|
440
|
+
var scanRoutes = async (options) => {
|
|
441
|
+
const rootDir = options.rootDir;
|
|
442
|
+
const routes = /* @__PURE__ */ new Set();
|
|
443
|
+
if (options.includeApp !== false) {
|
|
444
|
+
const appDir = path3.join(rootDir, "app");
|
|
445
|
+
if (await dirExists(appDir)) {
|
|
446
|
+
const files = await walkDir(appDir);
|
|
447
|
+
for (const file of files) {
|
|
448
|
+
if (!isAppPageFile(file)) {
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const route = toAppRoute(appDir, file);
|
|
452
|
+
if (route) {
|
|
453
|
+
routes.add(route);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (options.includePages !== false) {
|
|
459
|
+
const pagesDir = path3.join(rootDir, "pages");
|
|
460
|
+
if (await dirExists(pagesDir)) {
|
|
461
|
+
const files = await walkDir(pagesDir);
|
|
462
|
+
for (const file of files) {
|
|
463
|
+
if (!isPagesFile(file)) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
const route = toPagesRoute(pagesDir, file);
|
|
467
|
+
if (route) {
|
|
468
|
+
routes.add(route);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return Array.from(routes).sort();
|
|
474
|
+
};
|
|
475
|
+
var isAppPageFile = (filePath) => /\/page\.(t|j)sx?$/.test(filePath) || /\/page\.mdx$/.test(filePath);
|
|
476
|
+
var isPagesFile = (filePath) => /\.(t|j)sx?$/.test(filePath) || /\.mdx$/.test(filePath);
|
|
477
|
+
var toAppRoute = (appDir, filePath) => {
|
|
478
|
+
const relative = path3.relative(appDir, filePath);
|
|
479
|
+
const segments = relative.split(path3.sep);
|
|
480
|
+
if (segments.length === 0) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
segments.pop();
|
|
484
|
+
const routeSegments = segments.filter((segment) => segment.length > 0).filter((segment) => !isGroupSegment(segment)).filter((segment) => !isParallelSegment(segment));
|
|
485
|
+
if (routeSegments.length === 0) {
|
|
486
|
+
return "/";
|
|
487
|
+
}
|
|
488
|
+
return `/${routeSegments.join("/")}`;
|
|
489
|
+
};
|
|
490
|
+
var toPagesRoute = (pagesDir, filePath) => {
|
|
491
|
+
const relative = path3.relative(pagesDir, filePath);
|
|
492
|
+
const segments = relative.split(path3.sep);
|
|
493
|
+
if (segments.length === 0) {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
if (segments[0] === "api") {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
const fileName = segments.pop();
|
|
500
|
+
if (!fileName) {
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
const baseName = fileName.replace(/\.[^/.]+$/, "");
|
|
504
|
+
if (baseName.startsWith("_")) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const filtered = segments.filter((segment) => segment.length > 0);
|
|
508
|
+
if (baseName !== "index") {
|
|
509
|
+
filtered.push(baseName);
|
|
510
|
+
}
|
|
511
|
+
if (filtered.length === 0) {
|
|
512
|
+
return "/";
|
|
513
|
+
}
|
|
514
|
+
return `/${filtered.join("/")}`;
|
|
515
|
+
};
|
|
516
|
+
var isGroupSegment = (segment) => segment.startsWith("(") && segment.endsWith(")");
|
|
517
|
+
var isParallelSegment = (segment) => segment.startsWith("@");
|
|
518
|
+
var dirExists = async (dirPath) => {
|
|
519
|
+
try {
|
|
520
|
+
const stat = await fs3.stat(dirPath);
|
|
521
|
+
return stat.isDirectory();
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
var walkDir = async (dirPath) => {
|
|
527
|
+
const entries = await fs3.readdir(dirPath, { withFileTypes: true });
|
|
528
|
+
const files = [];
|
|
529
|
+
for (const entry of entries) {
|
|
530
|
+
const resolved = path3.join(dirPath, entry.name);
|
|
531
|
+
if (entry.isDirectory()) {
|
|
532
|
+
files.push(...await walkDir(resolved));
|
|
533
|
+
} else if (entry.isFile()) {
|
|
534
|
+
files.push(resolved);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return files;
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// src/html.ts
|
|
541
|
+
var escapeHtml = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
542
|
+
var renderRouteIssues = (route) => {
|
|
543
|
+
if (route.issues.length === 0) {
|
|
544
|
+
return '<p class="muted">No issues.</p>';
|
|
545
|
+
}
|
|
546
|
+
const items = route.issues.map((issue) => {
|
|
547
|
+
const severityClass = issue.severity === "error" ? "sev-error" : "sev-warn";
|
|
548
|
+
return `<li class="${severityClass}">
|
|
549
|
+
<span class="sev">${escapeHtml(issue.severity.toUpperCase())}</span>
|
|
550
|
+
<code>${escapeHtml(issue.ruleId)}</code>
|
|
551
|
+
<span>${escapeHtml(issue.message)}</span>
|
|
552
|
+
<small>${escapeHtml(issue.path)}</small>
|
|
553
|
+
</li>`;
|
|
554
|
+
}).join("");
|
|
555
|
+
return `<ul class="issues">${items}</ul>`;
|
|
556
|
+
};
|
|
557
|
+
var renderRoute = (route) => {
|
|
558
|
+
const statusClass = route.ok ? "ok" : "fail";
|
|
559
|
+
const expected = route.expectedTypes.length ? route.expectedTypes.join(", ") : "(none)";
|
|
560
|
+
const found = route.foundTypes.length ? route.foundTypes.join(", ") : "(none)";
|
|
561
|
+
return `<section class="route">
|
|
562
|
+
<header>
|
|
563
|
+
<h3>${escapeHtml(route.route)}</h3>
|
|
564
|
+
<span class="badge ${statusClass}">${route.ok ? "OK" : "FAIL"}</span>
|
|
565
|
+
</header>
|
|
566
|
+
<p><strong>Score:</strong> ${route.score}</p>
|
|
567
|
+
<p><strong>Expected types:</strong> ${escapeHtml(expected)}</p>
|
|
568
|
+
<p><strong>Found types:</strong> ${escapeHtml(found)}</p>
|
|
569
|
+
${renderRouteIssues(route)}
|
|
570
|
+
</section>`;
|
|
571
|
+
};
|
|
572
|
+
var renderCoverage = (report) => {
|
|
573
|
+
if (!report.summary.coverage) {
|
|
574
|
+
return "<li><strong>Coverage:</strong> not enabled</li>";
|
|
575
|
+
}
|
|
576
|
+
const coverage = report.summary.coverage;
|
|
577
|
+
return `<li><strong>Coverage:</strong> missing_routes=${coverage.missingRoutes} missing_types=${coverage.missingTypes} unlisted_routes=${coverage.unlistedRoutes}</li>`;
|
|
578
|
+
};
|
|
579
|
+
var renderHtmlReport = (report, options) => {
|
|
580
|
+
const title = escapeHtml(options.title);
|
|
581
|
+
const generatedAt = escapeHtml(
|
|
582
|
+
(options.generatedAt ?? /* @__PURE__ */ new Date()).toISOString()
|
|
583
|
+
);
|
|
584
|
+
const routes = report.routes.map(renderRoute).join("");
|
|
585
|
+
return `<!doctype html>
|
|
586
|
+
<html lang="en">
|
|
587
|
+
<head>
|
|
588
|
+
<meta charset="utf-8" />
|
|
589
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
590
|
+
<title>${title}</title>
|
|
591
|
+
<style>
|
|
592
|
+
:root { color-scheme: light dark; }
|
|
593
|
+
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif; margin: 24px; line-height: 1.45; }
|
|
594
|
+
h1, h2, h3 { margin: 0 0 8px; }
|
|
595
|
+
.muted { color: #666; }
|
|
596
|
+
.summary, .routes { margin-top: 20px; }
|
|
597
|
+
.route { border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin: 12px 0; }
|
|
598
|
+
.route header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
599
|
+
.badge { padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|
600
|
+
.badge.ok { background: #dcfce7; color: #166534; }
|
|
601
|
+
.badge.fail { background: #fee2e2; color: #991b1b; }
|
|
602
|
+
.issues { margin: 10px 0 0; padding-left: 16px; }
|
|
603
|
+
.issues li { margin: 8px 0; display: grid; gap: 2px; }
|
|
604
|
+
.sev { font-weight: 700; }
|
|
605
|
+
.sev-error .sev { color: #991b1b; }
|
|
606
|
+
.sev-warn .sev { color: #92400e; }
|
|
607
|
+
code { background: #f5f5f5; padding: 1px 5px; border-radius: 4px; }
|
|
608
|
+
</style>
|
|
609
|
+
</head>
|
|
610
|
+
<body>
|
|
611
|
+
<h1>${title}</h1>
|
|
612
|
+
<p class="muted">Generated at ${generatedAt}</p>
|
|
613
|
+
|
|
614
|
+
<section class="summary">
|
|
615
|
+
<h2>Summary</h2>
|
|
616
|
+
<ul>
|
|
617
|
+
<li><strong>OK:</strong> ${report.ok}</li>
|
|
618
|
+
<li><strong>Routes:</strong> ${report.summary.routes}</li>
|
|
619
|
+
<li><strong>Errors:</strong> ${report.summary.errors}</li>
|
|
620
|
+
<li><strong>Warnings:</strong> ${report.summary.warnings}</li>
|
|
621
|
+
<li><strong>Score:</strong> ${report.summary.score}</li>
|
|
622
|
+
${renderCoverage(report)}
|
|
623
|
+
</ul>
|
|
624
|
+
</section>
|
|
625
|
+
|
|
626
|
+
<section class="routes">
|
|
627
|
+
<h2>Routes</h2>
|
|
628
|
+
${routes}
|
|
629
|
+
</section>
|
|
630
|
+
</body>
|
|
631
|
+
</html>`;
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
// src/annotations.ts
|
|
635
|
+
var escapeCommandValue = (value) => value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
636
|
+
var escapeCommandProperty = (value) => escapeCommandValue(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
637
|
+
var formatIssueMessage = (route, issue) => `[${route}] ${issue.ruleId}: ${issue.message} (${issue.path})`;
|
|
638
|
+
var buildGitHubAnnotationLines = (report, commandLabel) => {
|
|
639
|
+
const title = escapeCommandProperty(`Schema Sentry ${commandLabel}`);
|
|
640
|
+
const lines = [];
|
|
641
|
+
for (const route of report.routes) {
|
|
642
|
+
for (const issue of route.issues) {
|
|
643
|
+
const level = issue.severity === "error" ? "error" : "warning";
|
|
644
|
+
const message = escapeCommandValue(formatIssueMessage(route.route, issue));
|
|
645
|
+
lines.push(`::${level} title=${title}::${message}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return lines;
|
|
649
|
+
};
|
|
650
|
+
var emitGitHubAnnotations = (report, commandLabel) => {
|
|
651
|
+
const lines = buildGitHubAnnotationLines(report, commandLabel);
|
|
652
|
+
for (const line of lines) {
|
|
653
|
+
console.error(line);
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
|
|
90
657
|
// src/index.ts
|
|
658
|
+
import { createInterface } from "readline/promises";
|
|
659
|
+
import { stdin as input, stdout as output } from "process";
|
|
91
660
|
var program = new Command();
|
|
92
|
-
program.name("schemasentry").description("Schema Sentry CLI").version(
|
|
661
|
+
program.name("schemasentry").description("Schema Sentry CLI").version(resolveCliVersion());
|
|
93
662
|
program.command("validate").description("Validate schema coverage and rules").option(
|
|
94
663
|
"-m, --manifest <path>",
|
|
95
664
|
"Path to manifest JSON",
|
|
@@ -98,23 +667,21 @@ program.command("validate").description("Validate schema coverage and rules").op
|
|
|
98
667
|
"-d, --data <path>",
|
|
99
668
|
"Path to schema data JSON",
|
|
100
669
|
"schema-sentry.data.json"
|
|
101
|
-
).action(async (options) => {
|
|
102
|
-
const
|
|
103
|
-
const
|
|
670
|
+
).option("-c, --config <path>", "Path to config JSON").option("--format <format>", "Report format (json|html)", "json").option("--annotations <provider>", "Emit CI annotations (none|github)", "none").option("-o, --output <path>", "Write report output to file").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
|
|
671
|
+
const start = Date.now();
|
|
672
|
+
const format = resolveOutputFormat(options.format);
|
|
673
|
+
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
674
|
+
const recommended = await resolveRecommendedOption(options.config);
|
|
675
|
+
const manifestPath = path4.resolve(process.cwd(), options.manifest);
|
|
676
|
+
const dataPath = path4.resolve(process.cwd(), options.data);
|
|
104
677
|
let raw;
|
|
105
678
|
try {
|
|
106
679
|
raw = await readFile(manifestPath, "utf8");
|
|
107
680
|
} catch (error) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
{
|
|
113
|
-
code: "manifest.not_found",
|
|
114
|
-
message: `Manifest not found at ${manifestPath}`
|
|
115
|
-
}
|
|
116
|
-
]
|
|
117
|
-
})
|
|
681
|
+
printCliError(
|
|
682
|
+
"manifest.not_found",
|
|
683
|
+
`Manifest not found at ${manifestPath}`,
|
|
684
|
+
"Run `schemasentry init` to generate starter files."
|
|
118
685
|
);
|
|
119
686
|
process.exit(1);
|
|
120
687
|
return;
|
|
@@ -123,31 +690,19 @@ program.command("validate").description("Validate schema coverage and rules").op
|
|
|
123
690
|
try {
|
|
124
691
|
manifest = JSON.parse(raw);
|
|
125
692
|
} catch (error) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
{
|
|
131
|
-
code: "manifest.invalid_json",
|
|
132
|
-
message: "Manifest is not valid JSON"
|
|
133
|
-
}
|
|
134
|
-
]
|
|
135
|
-
})
|
|
693
|
+
printCliError(
|
|
694
|
+
"manifest.invalid_json",
|
|
695
|
+
"Manifest is not valid JSON",
|
|
696
|
+
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
136
697
|
);
|
|
137
698
|
process.exit(1);
|
|
138
699
|
return;
|
|
139
700
|
}
|
|
140
701
|
if (!isManifest(manifest)) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
{
|
|
146
|
-
code: "manifest.invalid_shape",
|
|
147
|
-
message: "Manifest must contain a 'routes' object with string array values"
|
|
148
|
-
}
|
|
149
|
-
]
|
|
150
|
-
})
|
|
702
|
+
printCliError(
|
|
703
|
+
"manifest.invalid_shape",
|
|
704
|
+
"Manifest must contain a 'routes' object with string array values",
|
|
705
|
+
"Ensure each route maps to an array of schema type names."
|
|
151
706
|
);
|
|
152
707
|
process.exit(1);
|
|
153
708
|
return;
|
|
@@ -156,16 +711,10 @@ program.command("validate").description("Validate schema coverage and rules").op
|
|
|
156
711
|
try {
|
|
157
712
|
dataRaw = await readFile(dataPath, "utf8");
|
|
158
713
|
} catch (error) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{
|
|
164
|
-
code: "data.not_found",
|
|
165
|
-
message: `Schema data not found at ${dataPath}`
|
|
166
|
-
}
|
|
167
|
-
]
|
|
168
|
-
})
|
|
714
|
+
printCliError(
|
|
715
|
+
"data.not_found",
|
|
716
|
+
`Schema data not found at ${dataPath}`,
|
|
717
|
+
"Run `schemasentry init` to generate starter files."
|
|
169
718
|
);
|
|
170
719
|
process.exit(1);
|
|
171
720
|
return;
|
|
@@ -174,41 +723,167 @@ program.command("validate").description("Validate schema coverage and rules").op
|
|
|
174
723
|
try {
|
|
175
724
|
data = JSON.parse(dataRaw);
|
|
176
725
|
} catch (error) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
{
|
|
182
|
-
code: "data.invalid_json",
|
|
183
|
-
message: "Schema data is not valid JSON"
|
|
184
|
-
}
|
|
185
|
-
]
|
|
186
|
-
})
|
|
726
|
+
printCliError(
|
|
727
|
+
"data.invalid_json",
|
|
728
|
+
"Schema data is not valid JSON",
|
|
729
|
+
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
187
730
|
);
|
|
188
731
|
process.exit(1);
|
|
189
732
|
return;
|
|
190
733
|
}
|
|
191
734
|
if (!isSchemaData(data)) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
{
|
|
197
|
-
code: "data.invalid_shape",
|
|
198
|
-
message: "Schema data must contain a 'routes' object with array values"
|
|
199
|
-
}
|
|
200
|
-
]
|
|
201
|
-
})
|
|
735
|
+
printCliError(
|
|
736
|
+
"data.invalid_shape",
|
|
737
|
+
"Schema data must contain a 'routes' object with array values",
|
|
738
|
+
"Ensure each route maps to an array of JSON-LD blocks."
|
|
202
739
|
);
|
|
203
740
|
process.exit(1);
|
|
204
741
|
return;
|
|
205
742
|
}
|
|
206
|
-
const report = buildReport(manifest, data);
|
|
207
|
-
|
|
743
|
+
const report = buildReport(manifest, data, { recommended });
|
|
744
|
+
await emitReport({
|
|
745
|
+
report,
|
|
746
|
+
format,
|
|
747
|
+
outputPath: options.output,
|
|
748
|
+
title: "Schema Sentry Validate Report"
|
|
749
|
+
});
|
|
750
|
+
emitAnnotations(report, annotationsMode, "validate");
|
|
751
|
+
printValidateSummary(report, Date.now() - start);
|
|
208
752
|
process.exit(report.ok ? 0 : 1);
|
|
209
753
|
});
|
|
210
|
-
program.
|
|
211
|
-
|
|
754
|
+
program.command("init").description("Interactive setup wizard").option(
|
|
755
|
+
"-m, --manifest <path>",
|
|
756
|
+
"Path to manifest JSON",
|
|
757
|
+
"schema-sentry.manifest.json"
|
|
758
|
+
).option(
|
|
759
|
+
"-d, --data <path>",
|
|
760
|
+
"Path to schema data JSON",
|
|
761
|
+
"schema-sentry.data.json"
|
|
762
|
+
).option("-y, --yes", "Use defaults and skip prompts").option("-f, --force", "Overwrite existing files").option("--scan", "Scan the filesystem for routes and add WebPage entries").option("--root <path>", "Project root for scanning", ".").action(async (options) => {
|
|
763
|
+
const manifestPath = path4.resolve(process.cwd(), options.manifest);
|
|
764
|
+
const dataPath = path4.resolve(process.cwd(), options.data);
|
|
765
|
+
const force = options.force ?? false;
|
|
766
|
+
const useDefaults = options.yes ?? false;
|
|
767
|
+
const answers = useDefaults ? getDefaultAnswers() : await promptAnswers();
|
|
768
|
+
const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path4.resolve(process.cwd(), options.root ?? ".") }) : [];
|
|
769
|
+
if (options.scan && scannedRoutes.length === 0) {
|
|
770
|
+
console.error("No routes found during scan.");
|
|
771
|
+
}
|
|
772
|
+
const [overwriteManifest, overwriteData] = await resolveOverwrites({
|
|
773
|
+
manifestPath,
|
|
774
|
+
dataPath,
|
|
775
|
+
force,
|
|
776
|
+
interactive: !useDefaults
|
|
777
|
+
});
|
|
778
|
+
const result = await writeInitFiles({
|
|
779
|
+
manifestPath,
|
|
780
|
+
dataPath,
|
|
781
|
+
overwriteManifest,
|
|
782
|
+
overwriteData,
|
|
783
|
+
answers,
|
|
784
|
+
scannedRoutes
|
|
785
|
+
});
|
|
786
|
+
printInitSummary({ manifestPath, dataPath, result });
|
|
787
|
+
});
|
|
788
|
+
program.command("audit").description("Analyze schema health and report issues").option(
|
|
789
|
+
"-d, --data <path>",
|
|
790
|
+
"Path to schema data JSON",
|
|
791
|
+
"schema-sentry.data.json"
|
|
792
|
+
).option("-m, --manifest <path>", "Path to manifest JSON (optional)").option("--scan", "Scan the filesystem for routes").option("--root <path>", "Project root for scanning", ".").option("-c, --config <path>", "Path to config JSON").option("--format <format>", "Report format (json|html)", "json").option("--annotations <provider>", "Emit CI annotations (none|github)", "none").option("-o, --output <path>", "Write report output to file").option("--recommended", "Enable recommended field checks").option("--no-recommended", "Disable recommended field checks").action(async (options) => {
|
|
793
|
+
const start = Date.now();
|
|
794
|
+
const format = resolveOutputFormat(options.format);
|
|
795
|
+
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
796
|
+
const recommended = await resolveRecommendedOption(options.config);
|
|
797
|
+
const dataPath = path4.resolve(process.cwd(), options.data);
|
|
798
|
+
let dataRaw;
|
|
799
|
+
try {
|
|
800
|
+
dataRaw = await readFile(dataPath, "utf8");
|
|
801
|
+
} catch (error) {
|
|
802
|
+
printCliError(
|
|
803
|
+
"data.not_found",
|
|
804
|
+
`Schema data not found at ${dataPath}`,
|
|
805
|
+
"Run `schemasentry init` to generate starter files."
|
|
806
|
+
);
|
|
807
|
+
process.exit(1);
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
let data;
|
|
811
|
+
try {
|
|
812
|
+
data = JSON.parse(dataRaw);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
printCliError(
|
|
815
|
+
"data.invalid_json",
|
|
816
|
+
"Schema data is not valid JSON",
|
|
817
|
+
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
818
|
+
);
|
|
819
|
+
process.exit(1);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (!isSchemaData(data)) {
|
|
823
|
+
printCliError(
|
|
824
|
+
"data.invalid_shape",
|
|
825
|
+
"Schema data must contain a 'routes' object with array values",
|
|
826
|
+
"Ensure each route maps to an array of JSON-LD blocks."
|
|
827
|
+
);
|
|
828
|
+
process.exit(1);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
let manifest;
|
|
832
|
+
if (options.manifest) {
|
|
833
|
+
const manifestPath = path4.resolve(process.cwd(), options.manifest);
|
|
834
|
+
let manifestRaw;
|
|
835
|
+
try {
|
|
836
|
+
manifestRaw = await readFile(manifestPath, "utf8");
|
|
837
|
+
} catch (error) {
|
|
838
|
+
printCliError(
|
|
839
|
+
"manifest.not_found",
|
|
840
|
+
`Manifest not found at ${manifestPath}`,
|
|
841
|
+
"Run `schemasentry init` to generate starter files."
|
|
842
|
+
);
|
|
843
|
+
process.exit(1);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
manifest = JSON.parse(manifestRaw);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
printCliError(
|
|
850
|
+
"manifest.invalid_json",
|
|
851
|
+
"Manifest is not valid JSON",
|
|
852
|
+
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
853
|
+
);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (!isManifest(manifest)) {
|
|
858
|
+
printCliError(
|
|
859
|
+
"manifest.invalid_shape",
|
|
860
|
+
"Manifest must contain a 'routes' object with string array values",
|
|
861
|
+
"Ensure each route maps to an array of schema type names."
|
|
862
|
+
);
|
|
863
|
+
process.exit(1);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
const requiredRoutes = options.scan ? await scanRoutes({ rootDir: path4.resolve(process.cwd(), options.root ?? ".") }) : [];
|
|
868
|
+
if (options.scan && requiredRoutes.length === 0) {
|
|
869
|
+
console.error("No routes found during scan.");
|
|
870
|
+
}
|
|
871
|
+
const report = buildAuditReport(data, {
|
|
872
|
+
recommended,
|
|
873
|
+
manifest,
|
|
874
|
+
requiredRoutes: requiredRoutes.length > 0 ? requiredRoutes : void 0
|
|
875
|
+
});
|
|
876
|
+
await emitReport({
|
|
877
|
+
report,
|
|
878
|
+
format,
|
|
879
|
+
outputPath: options.output,
|
|
880
|
+
title: "Schema Sentry Audit Report"
|
|
881
|
+
});
|
|
882
|
+
emitAnnotations(report, annotationsMode, "audit");
|
|
883
|
+
printAuditSummary(report, Boolean(manifest), Date.now() - start);
|
|
884
|
+
process.exit(report.ok ? 0 : 1);
|
|
885
|
+
});
|
|
886
|
+
function isManifest(value) {
|
|
212
887
|
if (!value || typeof value !== "object") {
|
|
213
888
|
return false;
|
|
214
889
|
}
|
|
@@ -222,8 +897,8 @@ var isManifest = (value) => {
|
|
|
222
897
|
}
|
|
223
898
|
}
|
|
224
899
|
return true;
|
|
225
|
-
}
|
|
226
|
-
|
|
900
|
+
}
|
|
901
|
+
function isSchemaData(value) {
|
|
227
902
|
if (!value || typeof value !== "object") {
|
|
228
903
|
return false;
|
|
229
904
|
}
|
|
@@ -237,5 +912,198 @@ var isSchemaData = (value) => {
|
|
|
237
912
|
}
|
|
238
913
|
}
|
|
239
914
|
return true;
|
|
240
|
-
}
|
|
241
|
-
|
|
915
|
+
}
|
|
916
|
+
function resolveOutputFormat(value) {
|
|
917
|
+
const format = (value ?? "json").trim().toLowerCase();
|
|
918
|
+
if (format === "json" || format === "html") {
|
|
919
|
+
return format;
|
|
920
|
+
}
|
|
921
|
+
printCliError(
|
|
922
|
+
"output.invalid_format",
|
|
923
|
+
`Unsupported report format '${value ?? ""}'`,
|
|
924
|
+
"Use --format json or --format html."
|
|
925
|
+
);
|
|
926
|
+
process.exit(1);
|
|
927
|
+
return "json";
|
|
928
|
+
}
|
|
929
|
+
function resolveAnnotationsMode(value) {
|
|
930
|
+
const mode = (value ?? "none").trim().toLowerCase();
|
|
931
|
+
if (mode === "none" || mode === "github") {
|
|
932
|
+
return mode;
|
|
933
|
+
}
|
|
934
|
+
printCliError(
|
|
935
|
+
"annotations.invalid_provider",
|
|
936
|
+
`Unsupported annotations provider '${value ?? ""}'`,
|
|
937
|
+
"Use --annotations none or --annotations github."
|
|
938
|
+
);
|
|
939
|
+
process.exit(1);
|
|
940
|
+
return "none";
|
|
941
|
+
}
|
|
942
|
+
function formatReportOutput(report, format, title) {
|
|
943
|
+
if (format === "html") {
|
|
944
|
+
return renderHtmlReport(report, { title });
|
|
945
|
+
}
|
|
946
|
+
return stableStringify2(report);
|
|
947
|
+
}
|
|
948
|
+
async function emitReport(options) {
|
|
949
|
+
const { report, format, outputPath, title } = options;
|
|
950
|
+
const content = formatReportOutput(report, format, title);
|
|
951
|
+
if (!outputPath) {
|
|
952
|
+
console.log(content);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
const resolvedPath = path4.resolve(process.cwd(), outputPath);
|
|
956
|
+
try {
|
|
957
|
+
await mkdir(path4.dirname(resolvedPath), { recursive: true });
|
|
958
|
+
await writeFile(resolvedPath, content, "utf8");
|
|
959
|
+
console.error(`Report written to ${resolvedPath}`);
|
|
960
|
+
} catch (error) {
|
|
961
|
+
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
962
|
+
printCliError(
|
|
963
|
+
"output.write_failed",
|
|
964
|
+
`Could not write report to ${resolvedPath}: ${reason}`
|
|
965
|
+
);
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function emitAnnotations(report, mode, commandLabel) {
|
|
970
|
+
if (mode !== "github") {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
emitGitHubAnnotations(report, commandLabel);
|
|
974
|
+
}
|
|
975
|
+
function printCliError(code, message, suggestion) {
|
|
976
|
+
console.error(
|
|
977
|
+
stableStringify2({
|
|
978
|
+
ok: false,
|
|
979
|
+
errors: [
|
|
980
|
+
{
|
|
981
|
+
code,
|
|
982
|
+
message,
|
|
983
|
+
...suggestion !== void 0 ? { suggestion } : {}
|
|
984
|
+
}
|
|
985
|
+
]
|
|
986
|
+
})
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
async function resolveRecommendedOption(configPath) {
|
|
990
|
+
const override = getRecommendedOverride(process.argv);
|
|
991
|
+
try {
|
|
992
|
+
const config = await loadConfig({ configPath });
|
|
993
|
+
return resolveRecommended(override, config);
|
|
994
|
+
} catch (error) {
|
|
995
|
+
if (error instanceof ConfigError) {
|
|
996
|
+
printCliError(error.code, error.message, error.suggestion);
|
|
997
|
+
process.exit(1);
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function getRecommendedOverride(argv) {
|
|
1004
|
+
if (argv.includes("--recommended")) {
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
if (argv.includes("--no-recommended")) {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
return void 0;
|
|
1011
|
+
}
|
|
1012
|
+
function resolveCliVersion() {
|
|
1013
|
+
try {
|
|
1014
|
+
const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
|
|
1015
|
+
const parsed = JSON.parse(raw);
|
|
1016
|
+
return parsed.version ?? "0.0.0";
|
|
1017
|
+
} catch {
|
|
1018
|
+
return "0.0.0";
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
program.parse();
|
|
1022
|
+
function printValidateSummary(report, durationMs) {
|
|
1023
|
+
console.error(
|
|
1024
|
+
formatSummaryLine("validate", {
|
|
1025
|
+
...report.summary,
|
|
1026
|
+
durationMs,
|
|
1027
|
+
coverage: report.summary.coverage
|
|
1028
|
+
})
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
function printAuditSummary(report, coverageEnabled, durationMs) {
|
|
1032
|
+
console.error(
|
|
1033
|
+
formatSummaryLine("audit", {
|
|
1034
|
+
...report.summary,
|
|
1035
|
+
durationMs,
|
|
1036
|
+
coverage: report.summary.coverage
|
|
1037
|
+
})
|
|
1038
|
+
);
|
|
1039
|
+
if (!coverageEnabled) {
|
|
1040
|
+
console.error("Coverage checks skipped (no manifest provided).");
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
async function promptAnswers() {
|
|
1044
|
+
const defaults = getDefaultAnswers();
|
|
1045
|
+
const rl = createInterface({ input, output });
|
|
1046
|
+
try {
|
|
1047
|
+
const siteName = await ask(rl, "Site name", defaults.siteName);
|
|
1048
|
+
const siteUrl = await ask(rl, "Base URL", defaults.siteUrl);
|
|
1049
|
+
const authorName = await ask(rl, "Primary author name", defaults.authorName);
|
|
1050
|
+
return { siteName, siteUrl, authorName };
|
|
1051
|
+
} finally {
|
|
1052
|
+
rl.close();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
async function ask(rl, question, fallback) {
|
|
1056
|
+
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
1057
|
+
return answer.length > 0 ? answer : fallback;
|
|
1058
|
+
}
|
|
1059
|
+
async function resolveOverwrites(options) {
|
|
1060
|
+
const { manifestPath, dataPath, force, interactive } = options;
|
|
1061
|
+
if (force) {
|
|
1062
|
+
return [true, true];
|
|
1063
|
+
}
|
|
1064
|
+
const manifestExists = await fileExists(manifestPath);
|
|
1065
|
+
const dataExists = await fileExists(dataPath);
|
|
1066
|
+
if (!interactive) {
|
|
1067
|
+
return [false, false];
|
|
1068
|
+
}
|
|
1069
|
+
const rl = createInterface({ input, output });
|
|
1070
|
+
try {
|
|
1071
|
+
const overwriteManifest = manifestExists ? await confirm(rl, `Manifest exists at ${manifestPath}. Overwrite?`, false) : false;
|
|
1072
|
+
const overwriteData = dataExists ? await confirm(rl, `Data file exists at ${dataPath}. Overwrite?`, false) : false;
|
|
1073
|
+
return [overwriteManifest, overwriteData];
|
|
1074
|
+
} finally {
|
|
1075
|
+
rl.close();
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
async function confirm(rl, question, defaultValue) {
|
|
1079
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
1080
|
+
const answer = (await rl.question(`${question} (${hint}): `)).trim().toLowerCase();
|
|
1081
|
+
if (!answer) {
|
|
1082
|
+
return defaultValue;
|
|
1083
|
+
}
|
|
1084
|
+
return answer === "y" || answer === "yes";
|
|
1085
|
+
}
|
|
1086
|
+
function printInitSummary(options) {
|
|
1087
|
+
const { manifestPath, dataPath, result } = options;
|
|
1088
|
+
const created = [];
|
|
1089
|
+
if (result.manifest !== "skipped") {
|
|
1090
|
+
created.push(`${manifestPath} (${result.manifest})`);
|
|
1091
|
+
}
|
|
1092
|
+
if (result.data !== "skipped") {
|
|
1093
|
+
created.push(`${dataPath} (${result.data})`);
|
|
1094
|
+
}
|
|
1095
|
+
if (created.length > 0) {
|
|
1096
|
+
console.log("Schema Sentry init complete.");
|
|
1097
|
+
console.log(`Created ${created.length} file(s):`);
|
|
1098
|
+
created.forEach((entry) => console.log(`- ${entry}`));
|
|
1099
|
+
}
|
|
1100
|
+
if (result.manifest === "skipped" || result.data === "skipped") {
|
|
1101
|
+
console.log("Some files were skipped. Use --force to overwrite.");
|
|
1102
|
+
}
|
|
1103
|
+
if (result.manifest === "skipped" && result.data === "skipped") {
|
|
1104
|
+
console.log("No files were written.");
|
|
1105
|
+
}
|
|
1106
|
+
if (created.length > 0) {
|
|
1107
|
+
console.log("Next: run `schemasentry validate` to verify your setup.");
|
|
1108
|
+
}
|
|
1109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schemasentry/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "CLI for Schema Sentry validation and reporting.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,10 +33,11 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"commander": "^12.0.0",
|
|
36
|
-
"@schemasentry/core": "0.1
|
|
36
|
+
"@schemasentry/core": "0.3.1"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsup src/index.ts --format esm --dts --clean --tsconfig tsconfig.build.json",
|
|
40
|
+
"benchmark:200": "node ./scripts/benchmark-200-routes.mjs",
|
|
40
41
|
"lint": "echo \"lint not configured\" && exit 0",
|
|
41
42
|
"test": "vitest run"
|
|
42
43
|
}
|