@schemasentry/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +135 -45
- package/dist/index.js +1654 -1288
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,541 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { Command } from "commander";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
stableStringify,
|
|
13
|
-
validateSchema
|
|
14
|
-
} from "@schemasentry/core";
|
|
15
|
-
|
|
16
|
-
// src/coverage.ts
|
|
17
|
-
var buildCoverageResult = (input2, data) => {
|
|
18
|
-
const manifestRoutes = input2.expectedTypesByRoute ?? {};
|
|
19
|
-
const dataRoutes = data.routes ?? {};
|
|
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
|
|
24
|
-
]);
|
|
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 = [];
|
|
40
|
-
const expectedTypes = manifestRoutes[route] ?? [];
|
|
41
|
-
const nodes = dataRoutes[route] ?? [];
|
|
42
|
-
const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
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)) {
|
|
54
|
-
issues.push({
|
|
55
|
-
path: `routes["${route}"].types`,
|
|
56
|
-
message: `Missing expected schema type '${expectedType}'`,
|
|
57
|
-
severity: "error",
|
|
58
|
-
ruleId: "coverage.missing_type"
|
|
59
|
-
});
|
|
60
|
-
summary.missingTypes += 1;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
if (!manifestRoutes[route] && !requiredRoutes.has(route) && nodes.length > 0) {
|
|
64
|
-
issues.push({
|
|
65
|
-
path: `routes["${route}"]`,
|
|
66
|
-
message: "Route has schema but is missing from manifest",
|
|
67
|
-
severity: "warn",
|
|
68
|
-
ruleId: "coverage.unlisted_route"
|
|
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;
|
|
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
|
-
];
|
|
299
|
-
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
300
|
-
const warnCount = issues.filter((issue) => issue.severity === "warn").length;
|
|
301
|
-
const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
|
|
302
|
-
return {
|
|
303
|
-
route,
|
|
304
|
-
ok: errorCount === 0,
|
|
305
|
-
score,
|
|
306
|
-
issues,
|
|
307
|
-
expectedTypes,
|
|
308
|
-
foundTypes
|
|
309
|
-
};
|
|
310
|
-
});
|
|
311
|
-
const summaryErrors = routes.reduce(
|
|
312
|
-
(count, route) => count + route.issues.filter((i) => i.severity === "error").length,
|
|
313
|
-
0
|
|
314
|
-
);
|
|
315
|
-
const summaryWarnings = routes.reduce(
|
|
316
|
-
(count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
|
|
317
|
-
0
|
|
318
|
-
);
|
|
319
|
-
const summaryScore = routes.length === 0 ? 0 : Math.round(
|
|
320
|
-
routes.reduce((total, route) => total + route.score, 0) / routes.length
|
|
321
|
-
);
|
|
322
|
-
return {
|
|
323
|
-
ok: summaryErrors === 0,
|
|
324
|
-
summary: {
|
|
325
|
-
routes: routes.length,
|
|
326
|
-
errors: summaryErrors,
|
|
327
|
-
warnings: summaryWarnings,
|
|
328
|
-
score: summaryScore,
|
|
329
|
-
...coverageEnabled ? { coverage: coverage?.summary } : {}
|
|
330
|
-
},
|
|
331
|
-
routes
|
|
332
|
-
};
|
|
333
|
-
};
|
|
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
|
-
};
|
|
4
|
+
import { Command as Command6 } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/validate.ts
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { mkdir, readFile, writeFile, access } from "fs/promises";
|
|
9
|
+
import path4 from "path";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { stableStringify as stableStringify4 } from "@schemasentry/core";
|
|
539
12
|
|
|
540
13
|
// src/html.ts
|
|
541
14
|
var escapeHtml = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -654,15 +127,32 @@ var emitGitHubAnnotations = (report, commandLabel) => {
|
|
|
654
127
|
}
|
|
655
128
|
};
|
|
656
129
|
|
|
130
|
+
// src/summary.ts
|
|
131
|
+
var formatDuration = (durationMs) => {
|
|
132
|
+
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
|
133
|
+
return "0ms";
|
|
134
|
+
}
|
|
135
|
+
if (durationMs < 1e3) {
|
|
136
|
+
return `${Math.round(durationMs)}ms`;
|
|
137
|
+
}
|
|
138
|
+
const seconds = durationMs / 1e3;
|
|
139
|
+
return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// src/reality.ts
|
|
143
|
+
import {
|
|
144
|
+
validateSchema
|
|
145
|
+
} from "@schemasentry/core";
|
|
146
|
+
|
|
657
147
|
// src/collect.ts
|
|
658
|
-
import { promises as
|
|
659
|
-
import
|
|
660
|
-
import { stableStringify
|
|
148
|
+
import { promises as fs } from "fs";
|
|
149
|
+
import path from "path";
|
|
150
|
+
import { stableStringify } from "@schemasentry/core";
|
|
661
151
|
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules", ".pnpm-store"]);
|
|
662
152
|
var SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
663
153
|
var JSON_LD_TYPE_REGEX = /\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)/i;
|
|
664
154
|
var collectSchemaData = async (options) => {
|
|
665
|
-
const rootDir =
|
|
155
|
+
const rootDir = path.resolve(options.rootDir);
|
|
666
156
|
const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
|
|
667
157
|
const htmlFiles = (await walkHtmlFiles(rootDir)).sort((a, b) => a.localeCompare(b));
|
|
668
158
|
const routes = {};
|
|
@@ -674,7 +164,7 @@ var collectSchemaData = async (options) => {
|
|
|
674
164
|
if (!route) {
|
|
675
165
|
continue;
|
|
676
166
|
}
|
|
677
|
-
const html = await
|
|
167
|
+
const html = await fs.readFile(filePath, "utf8");
|
|
678
168
|
const extracted = extractSchemaNodes(html, filePath);
|
|
679
169
|
if (extracted.nodes.length > 0) {
|
|
680
170
|
routes[route] = [...routes[route] ?? [], ...extracted.nodes];
|
|
@@ -719,7 +209,7 @@ var compareSchemaData = (existing, collected) => {
|
|
|
719
209
|
const addedRoutes = collectedKeys.filter((route) => !Object.prototype.hasOwnProperty.call(existingRoutes, route)).sort();
|
|
720
210
|
const removedRoutes = existingKeys.filter((route) => !Object.prototype.hasOwnProperty.call(collectedRoutes, route)).sort();
|
|
721
211
|
const changedRoutes = existingKeys.filter((route) => Object.prototype.hasOwnProperty.call(collectedRoutes, route)).filter(
|
|
722
|
-
(route) =>
|
|
212
|
+
(route) => stableStringify(existingRoutes[route]) !== stableStringify(collectedRoutes[route])
|
|
723
213
|
).sort();
|
|
724
214
|
const changedRouteDetails = changedRoutes.map(
|
|
725
215
|
(route) => buildRouteDriftDetail(route, existingRoutes[route] ?? [], collectedRoutes[route] ?? [])
|
|
@@ -784,13 +274,13 @@ var normalizeRouteFilter = (input2) => {
|
|
|
784
274
|
return Array.from(new Set(normalized)).sort();
|
|
785
275
|
};
|
|
786
276
|
var walkHtmlFiles = async (rootDir) => {
|
|
787
|
-
const entries = await
|
|
277
|
+
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
|
788
278
|
const files = [];
|
|
789
279
|
for (const entry of entries) {
|
|
790
280
|
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
|
|
791
281
|
continue;
|
|
792
282
|
}
|
|
793
|
-
const resolved =
|
|
283
|
+
const resolved = path.join(rootDir, entry.name);
|
|
794
284
|
if (entry.isDirectory()) {
|
|
795
285
|
files.push(...await walkHtmlFiles(resolved));
|
|
796
286
|
continue;
|
|
@@ -802,7 +292,7 @@ var walkHtmlFiles = async (rootDir) => {
|
|
|
802
292
|
return files;
|
|
803
293
|
};
|
|
804
294
|
var filePathToRoute = (rootDir, filePath) => {
|
|
805
|
-
const relative =
|
|
295
|
+
const relative = path.relative(rootDir, filePath).replace(/\\/g, "/");
|
|
806
296
|
if (relative === "index.html") {
|
|
807
297
|
return "/";
|
|
808
298
|
}
|
|
@@ -877,258 +367,432 @@ var schemaTypeLabel = (node) => {
|
|
|
877
367
|
return typeof type === "string" && type.trim().length > 0 ? type : "(unknown)";
|
|
878
368
|
};
|
|
879
369
|
|
|
880
|
-
// src/
|
|
881
|
-
import { promises as
|
|
882
|
-
import
|
|
883
|
-
|
|
370
|
+
// src/source.ts
|
|
371
|
+
import { promises as fs2 } from "fs";
|
|
372
|
+
import path2 from "path";
|
|
373
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
374
|
+
".git",
|
|
375
|
+
"node_modules",
|
|
376
|
+
".pnpm-store",
|
|
377
|
+
".next",
|
|
378
|
+
"dist",
|
|
379
|
+
"build",
|
|
380
|
+
"out"
|
|
381
|
+
]);
|
|
382
|
+
var SCHEMA_IMPORT_PATTERN = /from\s+["']@schemasentry\/(?:next|core)["']/;
|
|
383
|
+
var SCHEMA_COMPONENT_PATTERN = /<Schema\s/;
|
|
384
|
+
var BUILDER_IMPORT_PATTERN = /import\s*\{([^}]+)\}\s*from\s*["']@schemasentry\/(?:next|core)["']/g;
|
|
385
|
+
var BUILDER_NAME_PATTERN = /^(Organization|Person|Place|LocalBusiness|WebSite|WebPage|Article|BlogPosting|Product|VideoObject|ImageObject|Event|Review|FAQPage|HowTo|BreadcrumbList)$/;
|
|
386
|
+
var scanSourceFiles = async (options) => {
|
|
387
|
+
const appDir = path2.resolve(options.rootDir, options.appDir ?? "app");
|
|
388
|
+
const pageFiles = await findPageFiles(appDir);
|
|
389
|
+
const routes = [];
|
|
390
|
+
for (const filePath of pageFiles) {
|
|
391
|
+
const route = filePathToRoute2(appDir, filePath);
|
|
392
|
+
if (!route) continue;
|
|
393
|
+
const content = await fs2.readFile(filePath, "utf8");
|
|
394
|
+
const info = analyzeSourceFile(filePath, route, content);
|
|
395
|
+
routes.push(info);
|
|
396
|
+
}
|
|
397
|
+
const filesWithSchema = routes.filter(
|
|
398
|
+
(r) => r.hasSchemaImport && r.hasSchemaUsage
|
|
399
|
+
).length;
|
|
400
|
+
return {
|
|
401
|
+
routes,
|
|
402
|
+
totalFiles: routes.length,
|
|
403
|
+
filesWithSchema,
|
|
404
|
+
filesMissingSchema: routes.length - filesWithSchema
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
var findPageFiles = async (dir) => {
|
|
408
|
+
const files = [];
|
|
409
|
+
try {
|
|
410
|
+
const entries = await fs2.readdir(dir, { withFileTypes: true });
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
const fullPath = path2.join(dir, entry.name);
|
|
413
|
+
if (entry.isDirectory()) {
|
|
414
|
+
if (!IGNORED_DIRS.has(entry.name)) {
|
|
415
|
+
files.push(...await findPageFiles(fullPath));
|
|
416
|
+
}
|
|
417
|
+
} else if (entry.isFile() && (entry.name === "page.tsx" || entry.name === "page.jsx" || entry.name === "page.ts" || entry.name === "page.js")) {
|
|
418
|
+
files.push(fullPath);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
}
|
|
423
|
+
return files;
|
|
424
|
+
};
|
|
425
|
+
var filePathToRoute2 = (appDir, filePath) => {
|
|
426
|
+
const relative = path2.relative(appDir, filePath).replace(/\\/g, "/");
|
|
427
|
+
const withoutExt = relative.replace(/\/page\.(tsx|jsx|ts|js)$/, "");
|
|
428
|
+
if (withoutExt === "") {
|
|
429
|
+
return "/";
|
|
430
|
+
}
|
|
431
|
+
return `/${withoutExt}`;
|
|
432
|
+
};
|
|
433
|
+
var analyzeSourceFile = (filePath, route, content) => {
|
|
434
|
+
const hasSchemaImport = SCHEMA_IMPORT_PATTERN.test(content);
|
|
435
|
+
const hasSchemaUsage = SCHEMA_COMPONENT_PATTERN.test(content);
|
|
436
|
+
const importedBuilders = [];
|
|
437
|
+
const importMatch = BUILDER_IMPORT_PATTERN.exec(content);
|
|
438
|
+
if (importMatch && importMatch[1]) {
|
|
439
|
+
const imports = importMatch[1].split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
440
|
+
for (const imp of imports) {
|
|
441
|
+
const name = imp.split(/\s+as\s+/)[0].trim();
|
|
442
|
+
if (BUILDER_NAME_PATTERN.test(name)) {
|
|
443
|
+
importedBuilders.push(name);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
route,
|
|
449
|
+
filePath,
|
|
450
|
+
hasSchemaImport,
|
|
451
|
+
hasSchemaUsage,
|
|
452
|
+
importedBuilders
|
|
453
|
+
};
|
|
454
|
+
};
|
|
884
455
|
|
|
885
|
-
// src/
|
|
886
|
-
var
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
{
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
{
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
456
|
+
// src/reality.ts
|
|
457
|
+
var performRealityCheck = async (options) => {
|
|
458
|
+
const sourceScan = await scanSourceFiles({ rootDir: options.sourceDir });
|
|
459
|
+
const collected = await collectSchemaData({ rootDir: options.builtOutputDir });
|
|
460
|
+
return buildRealityReport({
|
|
461
|
+
manifest: options.manifest,
|
|
462
|
+
sourceScan,
|
|
463
|
+
collected,
|
|
464
|
+
validationOptions: { recommended: options.recommended }
|
|
465
|
+
});
|
|
466
|
+
};
|
|
467
|
+
var buildRealityReport = (input2) => {
|
|
468
|
+
const { manifest, sourceScan, collected, validationOptions } = input2;
|
|
469
|
+
const manifestRoutes = manifest.routes ?? {};
|
|
470
|
+
const collectedRoutes = collected.data.routes ?? {};
|
|
471
|
+
const allRoutes = /* @__PURE__ */ new Set([
|
|
472
|
+
...Object.keys(manifestRoutes),
|
|
473
|
+
...sourceScan.routes.map((r) => r.route),
|
|
474
|
+
...Object.keys(collectedRoutes)
|
|
475
|
+
]);
|
|
476
|
+
const routeReports = [];
|
|
477
|
+
let validRoutes = 0;
|
|
478
|
+
let missingInHtml = 0;
|
|
479
|
+
let missingInSource = 0;
|
|
480
|
+
let missingFromManifest = 0;
|
|
481
|
+
let typeMismatches = 0;
|
|
482
|
+
let totalErrors = 0;
|
|
483
|
+
let totalWarnings = 0;
|
|
484
|
+
for (const route of allRoutes) {
|
|
485
|
+
const sourceInfo = sourceScan.routes.find((r) => r.route === route);
|
|
486
|
+
const htmlNodes = collectedRoutes[route] ?? [];
|
|
487
|
+
const expectedTypes = manifestRoutes[route] ?? [];
|
|
488
|
+
const sourceHasComponent = sourceInfo?.hasSchemaImport && sourceInfo?.hasSchemaUsage;
|
|
489
|
+
const htmlHasSchema = htmlNodes.length > 0;
|
|
490
|
+
const validation = validateSchema(htmlNodes, validationOptions);
|
|
491
|
+
const foundTypes = htmlNodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
492
|
+
let status;
|
|
493
|
+
const issues = [...validation.issues];
|
|
494
|
+
if (expectedTypes.length > 0 && !sourceHasComponent) {
|
|
495
|
+
status = "missing_in_source";
|
|
496
|
+
missingInSource++;
|
|
497
|
+
issues.push({
|
|
498
|
+
path: `routes["${route}"]`,
|
|
499
|
+
message: "Manifest expects schema but source file has no <Schema> component",
|
|
500
|
+
severity: "error",
|
|
501
|
+
ruleId: "reality.missing_source_component"
|
|
502
|
+
});
|
|
503
|
+
} else if (sourceHasComponent && !htmlHasSchema) {
|
|
504
|
+
status = "missing_in_html";
|
|
505
|
+
missingInHtml++;
|
|
506
|
+
issues.push({
|
|
507
|
+
path: `routes["${route}"]`,
|
|
508
|
+
message: "Source has <Schema> component but no JSON-LD found in built HTML. Did you build the app?",
|
|
509
|
+
severity: "error",
|
|
510
|
+
ruleId: "reality.missing_html_output"
|
|
511
|
+
});
|
|
512
|
+
} else if (htmlHasSchema && expectedTypes.length === 0) {
|
|
513
|
+
status = "missing_from_manifest";
|
|
514
|
+
missingFromManifest++;
|
|
515
|
+
issues.push({
|
|
516
|
+
path: `routes["${route}"]`,
|
|
517
|
+
message: `Route has schema in HTML but is not listed in manifest`,
|
|
518
|
+
severity: "warn",
|
|
519
|
+
ruleId: "reality.unlisted_route"
|
|
520
|
+
});
|
|
521
|
+
} else if (expectedTypes.length > 0 && htmlHasSchema && !expectedTypes.every((t) => foundTypes.includes(t))) {
|
|
522
|
+
status = "type_mismatch";
|
|
523
|
+
typeMismatches++;
|
|
524
|
+
const missingTypes = expectedTypes.filter((t) => !foundTypes.includes(t));
|
|
525
|
+
issues.push({
|
|
526
|
+
path: `routes["${route}"].types`,
|
|
527
|
+
message: `Missing expected schema types: ${missingTypes.join(", ")}`,
|
|
528
|
+
severity: "error",
|
|
529
|
+
ruleId: "reality.type_mismatch"
|
|
914
530
|
});
|
|
531
|
+
} else if (htmlHasSchema) {
|
|
532
|
+
status = "valid";
|
|
533
|
+
validRoutes++;
|
|
534
|
+
} else {
|
|
535
|
+
status = "valid";
|
|
536
|
+
validRoutes++;
|
|
915
537
|
}
|
|
538
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
539
|
+
const warnCount = issues.filter((i) => i.severity === "warn").length;
|
|
540
|
+
totalErrors += errorCount;
|
|
541
|
+
totalWarnings += warnCount;
|
|
542
|
+
const score = Math.max(0, 100 - errorCount * 10 - warnCount * 2);
|
|
543
|
+
routeReports.push({
|
|
544
|
+
route,
|
|
545
|
+
status,
|
|
546
|
+
sourceHasComponent: sourceHasComponent ?? false,
|
|
547
|
+
htmlHasSchema,
|
|
548
|
+
expectedTypes,
|
|
549
|
+
foundTypes,
|
|
550
|
+
issues,
|
|
551
|
+
score
|
|
552
|
+
});
|
|
916
553
|
}
|
|
917
|
-
|
|
918
|
-
|
|
554
|
+
routeReports.sort((a, b) => a.route.localeCompare(b.route));
|
|
555
|
+
const avgScore = routeReports.length === 0 ? 100 : Math.round(
|
|
556
|
+
routeReports.reduce((sum, r) => sum + r.score, 0) / routeReports.length
|
|
557
|
+
);
|
|
558
|
+
return {
|
|
559
|
+
ok: totalErrors === 0,
|
|
560
|
+
summary: {
|
|
561
|
+
routes: routeReports.length,
|
|
562
|
+
errors: totalErrors,
|
|
563
|
+
warnings: totalWarnings,
|
|
564
|
+
score: avgScore,
|
|
565
|
+
validRoutes,
|
|
566
|
+
missingInHtml,
|
|
567
|
+
missingInSource,
|
|
568
|
+
missingFromManifest,
|
|
569
|
+
typeMismatches
|
|
570
|
+
},
|
|
571
|
+
routes: routeReports
|
|
572
|
+
};
|
|
919
573
|
};
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
574
|
+
|
|
575
|
+
// src/commands/utils.ts
|
|
576
|
+
import { readFileSync } from "fs";
|
|
577
|
+
import { stableStringify as stableStringify3 } from "@schemasentry/core";
|
|
578
|
+
|
|
579
|
+
// src/config.ts
|
|
580
|
+
import { promises as fs3 } from "fs";
|
|
581
|
+
import path3 from "path";
|
|
582
|
+
var ConfigError = class extends Error {
|
|
583
|
+
constructor(code, message, suggestion) {
|
|
584
|
+
super(message);
|
|
585
|
+
this.code = code;
|
|
586
|
+
this.suggestion = suggestion;
|
|
927
587
|
}
|
|
928
|
-
const patternRegex = pattern.replace(/\*/g, "[^/]+").replace(/\?/g, ".");
|
|
929
|
-
const regex = new RegExp(`^${patternRegex}$`);
|
|
930
|
-
return regex.test(route);
|
|
931
588
|
};
|
|
932
|
-
var
|
|
933
|
-
|
|
934
|
-
const
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
589
|
+
var DEFAULT_CONFIG_PATH = "schema-sentry.config.json";
|
|
590
|
+
var loadConfig = async (options) => {
|
|
591
|
+
const cwd = options.cwd ?? process.cwd();
|
|
592
|
+
const explicit = Boolean(options.configPath);
|
|
593
|
+
const resolvedPath = path3.resolve(cwd, options.configPath ?? DEFAULT_CONFIG_PATH);
|
|
594
|
+
const exists = await fileExists(resolvedPath);
|
|
595
|
+
if (!exists) {
|
|
596
|
+
if (explicit) {
|
|
597
|
+
throw new ConfigError(
|
|
598
|
+
"config.not_found",
|
|
599
|
+
`Config not found at ${resolvedPath}`,
|
|
600
|
+
"Provide a valid path or remove --config."
|
|
601
|
+
);
|
|
939
602
|
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
let raw;
|
|
606
|
+
try {
|
|
607
|
+
raw = await fs3.readFile(resolvedPath, "utf8");
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw new ConfigError(
|
|
610
|
+
"config.read_failed",
|
|
611
|
+
`Failed to read config at ${resolvedPath}`,
|
|
612
|
+
"Check file permissions or re-create the config file."
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
let parsed;
|
|
616
|
+
try {
|
|
617
|
+
parsed = JSON.parse(raw);
|
|
618
|
+
} catch (error) {
|
|
619
|
+
throw new ConfigError(
|
|
620
|
+
"config.invalid_json",
|
|
621
|
+
"Config is not valid JSON",
|
|
622
|
+
"Check the JSON syntax or regenerate the file."
|
|
623
|
+
);
|
|
940
624
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
entries[route] = types;
|
|
625
|
+
if (!isConfig(parsed)) {
|
|
626
|
+
throw new ConfigError(
|
|
627
|
+
"config.invalid_shape",
|
|
628
|
+
"Config must be a JSON object with optional boolean 'recommended'",
|
|
629
|
+
'Example: { "recommended": false }'
|
|
630
|
+
);
|
|
948
631
|
}
|
|
949
|
-
return
|
|
632
|
+
return parsed;
|
|
950
633
|
};
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
const data = await loadData(options.dataPath);
|
|
956
|
-
const discoveredRoutes = await scanRoutes({ rootDir: options.rootDir });
|
|
957
|
-
const routesNeedingSchema = discoveredRoutes.filter(
|
|
958
|
-
(route) => !data.routes[route] || data.routes[route].length === 0
|
|
959
|
-
);
|
|
960
|
-
const inferredTypes = inferSchemaTypes(routesNeedingSchema, options.customPatterns);
|
|
961
|
-
const manifestEntries = generateManifestEntries(
|
|
962
|
-
routesNeedingSchema,
|
|
963
|
-
options.customPatterns
|
|
964
|
-
);
|
|
965
|
-
const generatedSchemas = /* @__PURE__ */ new Map();
|
|
966
|
-
for (const [route, types] of inferredTypes) {
|
|
967
|
-
const schemas = types.map((type) => generateSchemaStub(type, route));
|
|
968
|
-
generatedSchemas.set(route, schemas);
|
|
634
|
+
var resolveRecommended = (cliOverride, config) => cliOverride ?? config?.recommended ?? true;
|
|
635
|
+
var isConfig = (value) => {
|
|
636
|
+
if (!value || typeof value !== "object") {
|
|
637
|
+
return false;
|
|
969
638
|
}
|
|
970
|
-
const
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
wouldUpdate
|
|
976
|
-
};
|
|
639
|
+
const config = value;
|
|
640
|
+
if (config.recommended !== void 0 && typeof config.recommended !== "boolean") {
|
|
641
|
+
return false;
|
|
642
|
+
}
|
|
643
|
+
return true;
|
|
977
644
|
};
|
|
978
|
-
var
|
|
645
|
+
var fileExists = async (filePath) => {
|
|
979
646
|
try {
|
|
980
|
-
|
|
981
|
-
return
|
|
647
|
+
await fs3.access(filePath);
|
|
648
|
+
return true;
|
|
982
649
|
} catch {
|
|
983
|
-
return
|
|
650
|
+
return false;
|
|
984
651
|
}
|
|
985
652
|
};
|
|
986
|
-
|
|
653
|
+
|
|
654
|
+
// src/commands/utils.ts
|
|
655
|
+
function resolveCliVersion() {
|
|
987
656
|
try {
|
|
988
|
-
const raw =
|
|
989
|
-
|
|
657
|
+
const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
|
|
658
|
+
const parsed = JSON.parse(raw);
|
|
659
|
+
return parsed.version ?? "0.0.0";
|
|
990
660
|
} catch {
|
|
991
|
-
return
|
|
992
|
-
}
|
|
993
|
-
};
|
|
994
|
-
var generateSchemaStub = (type, route) => {
|
|
995
|
-
const base = {
|
|
996
|
-
"@context": "https://schema.org",
|
|
997
|
-
"@type": type
|
|
998
|
-
};
|
|
999
|
-
switch (type) {
|
|
1000
|
-
case "BlogPosting":
|
|
1001
|
-
return {
|
|
1002
|
-
...base,
|
|
1003
|
-
headline: "Blog Post Title",
|
|
1004
|
-
author: {
|
|
1005
|
-
"@type": "Person",
|
|
1006
|
-
name: "Author Name"
|
|
1007
|
-
},
|
|
1008
|
-
datePublished: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
1009
|
-
url: route
|
|
1010
|
-
};
|
|
1011
|
-
case "Product":
|
|
1012
|
-
return {
|
|
1013
|
-
...base,
|
|
1014
|
-
name: "Product Name",
|
|
1015
|
-
description: "Product description",
|
|
1016
|
-
offers: {
|
|
1017
|
-
"@type": "Offer",
|
|
1018
|
-
price: "0.00",
|
|
1019
|
-
priceCurrency: "USD"
|
|
1020
|
-
}
|
|
1021
|
-
};
|
|
1022
|
-
case "FAQPage":
|
|
1023
|
-
return {
|
|
1024
|
-
...base,
|
|
1025
|
-
mainEntity: []
|
|
1026
|
-
};
|
|
1027
|
-
case "HowTo":
|
|
1028
|
-
return {
|
|
1029
|
-
...base,
|
|
1030
|
-
name: "How-To Title",
|
|
1031
|
-
step: []
|
|
1032
|
-
};
|
|
1033
|
-
case "Event":
|
|
1034
|
-
return {
|
|
1035
|
-
...base,
|
|
1036
|
-
name: "Event Name",
|
|
1037
|
-
startDate: (/* @__PURE__ */ new Date()).toISOString()
|
|
1038
|
-
};
|
|
1039
|
-
case "Organization":
|
|
1040
|
-
return {
|
|
1041
|
-
...base,
|
|
1042
|
-
name: "Organization Name",
|
|
1043
|
-
url: route
|
|
1044
|
-
};
|
|
1045
|
-
case "WebSite":
|
|
1046
|
-
return {
|
|
1047
|
-
...base,
|
|
1048
|
-
name: "Website Name",
|
|
1049
|
-
url: route
|
|
1050
|
-
};
|
|
1051
|
-
case "Article":
|
|
1052
|
-
return {
|
|
1053
|
-
...base,
|
|
1054
|
-
headline: "Article Headline",
|
|
1055
|
-
author: {
|
|
1056
|
-
"@type": "Person",
|
|
1057
|
-
name: "Author Name"
|
|
1058
|
-
},
|
|
1059
|
-
datePublished: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
1060
|
-
};
|
|
1061
|
-
default:
|
|
1062
|
-
return {
|
|
1063
|
-
...base,
|
|
1064
|
-
name: `${type} Name`
|
|
1065
|
-
};
|
|
661
|
+
return "0.0.0";
|
|
1066
662
|
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
663
|
+
}
|
|
664
|
+
function resolveOutputFormat(value) {
|
|
665
|
+
const format = (value ?? "json").trim().toLowerCase();
|
|
666
|
+
if (format === "json" || format === "html") {
|
|
667
|
+
return format;
|
|
1071
668
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
""
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
669
|
+
printCliError(
|
|
670
|
+
"output.invalid_format",
|
|
671
|
+
`Unsupported report format '${value ?? ""}'`,
|
|
672
|
+
"Use --format json or --format html."
|
|
673
|
+
);
|
|
674
|
+
process.exit(1);
|
|
675
|
+
return "json";
|
|
676
|
+
}
|
|
677
|
+
function resolveCollectOutputFormat(value) {
|
|
678
|
+
const format = (value ?? "json").trim().toLowerCase();
|
|
679
|
+
if (format === "json") {
|
|
680
|
+
return format;
|
|
1080
681
|
}
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
682
|
+
printCliError(
|
|
683
|
+
"output.invalid_format",
|
|
684
|
+
`Unsupported collect output format '${value ?? ""}'`,
|
|
685
|
+
"Use --format json."
|
|
686
|
+
);
|
|
687
|
+
process.exit(1);
|
|
688
|
+
return "json";
|
|
689
|
+
}
|
|
690
|
+
function resolveAnnotationsMode(value) {
|
|
691
|
+
const mode = (value ?? "none").trim().toLowerCase();
|
|
692
|
+
if (mode === "none" || mode === "github") {
|
|
693
|
+
return mode;
|
|
1086
694
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
695
|
+
printCliError(
|
|
696
|
+
"annotations.invalid_provider",
|
|
697
|
+
`Unsupported annotations provider '${value ?? ""}'`,
|
|
698
|
+
"Use --annotations none or --annotations github."
|
|
699
|
+
);
|
|
700
|
+
process.exit(1);
|
|
701
|
+
return "none";
|
|
702
|
+
}
|
|
703
|
+
async function resolveRecommendedOption(configPath) {
|
|
704
|
+
const override = getRecommendedOverride(process.argv);
|
|
705
|
+
try {
|
|
706
|
+
const config = await loadConfig({ configPath });
|
|
707
|
+
return resolveRecommended(override, config);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
if (error instanceof ConfigError) {
|
|
710
|
+
printCliError(error.code, error.message, error.suggestion);
|
|
711
|
+
process.exit(1);
|
|
712
|
+
return true;
|
|
1092
713
|
}
|
|
714
|
+
throw error;
|
|
1093
715
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
716
|
+
}
|
|
717
|
+
function getRecommendedOverride(argv) {
|
|
718
|
+
if (argv.includes("--recommended")) {
|
|
719
|
+
return true;
|
|
1098
720
|
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
721
|
+
if (argv.includes("--no-recommended")) {
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
return void 0;
|
|
725
|
+
}
|
|
726
|
+
function printCliError(code, message, suggestion) {
|
|
727
|
+
console.error(
|
|
728
|
+
stableStringify3({
|
|
729
|
+
ok: false,
|
|
730
|
+
errors: [
|
|
731
|
+
{
|
|
732
|
+
code,
|
|
733
|
+
message,
|
|
734
|
+
...suggestion !== void 0 ? { suggestion } : {}
|
|
735
|
+
}
|
|
736
|
+
]
|
|
737
|
+
})
|
|
1105
738
|
);
|
|
1106
|
-
|
|
1107
|
-
|
|
739
|
+
}
|
|
740
|
+
function isManifest(value) {
|
|
741
|
+
if (!value || typeof value !== "object") {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
const manifest = value;
|
|
745
|
+
if (!manifest.routes || typeof manifest.routes !== "object") {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
for (const entry of Object.values(manifest.routes)) {
|
|
749
|
+
if (!Array.isArray(entry) || entry.some((item) => typeof item !== "string")) {
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
function isSchemaData(value) {
|
|
756
|
+
if (!value || typeof value !== "object") {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
const data = value;
|
|
760
|
+
if (!data.routes || typeof data.routes !== "object") {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
for (const entry of Object.values(data.routes)) {
|
|
764
|
+
if (!Array.isArray(entry)) {
|
|
765
|
+
return false;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
1108
770
|
|
|
1109
|
-
// src/
|
|
1110
|
-
|
|
1111
|
-
import { stdin as input, stdout as output } from "process";
|
|
1112
|
-
var program = new Command();
|
|
1113
|
-
program.name("schemasentry").description("Schema Sentry CLI").version(resolveCliVersion());
|
|
1114
|
-
program.command("validate").description("Validate schema coverage and rules").option(
|
|
771
|
+
// src/commands/validate.ts
|
|
772
|
+
var validateCommand = new Command("validate").description("Validate schema by checking built HTML output against manifest (validates reality, not just config files)").option(
|
|
1115
773
|
"-m, --manifest <path>",
|
|
1116
774
|
"Path to manifest JSON",
|
|
1117
775
|
"schema-sentry.manifest.json"
|
|
1118
776
|
).option(
|
|
1119
|
-
"-
|
|
1120
|
-
"
|
|
1121
|
-
"
|
|
777
|
+
"-r, --root <path>",
|
|
778
|
+
"Root directory containing built HTML output (e.g., ./out or ./.next/server/app)",
|
|
779
|
+
"./out"
|
|
780
|
+
).option(
|
|
781
|
+
"--app-dir <path>",
|
|
782
|
+
"Path to Next.js app directory for source scanning",
|
|
783
|
+
"./app"
|
|
1122
784
|
).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) => {
|
|
1123
785
|
const start = Date.now();
|
|
1124
786
|
const format = resolveOutputFormat(options.format);
|
|
1125
787
|
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
1126
788
|
const recommended = await resolveRecommendedOption(options.config);
|
|
1127
|
-
const manifestPath =
|
|
1128
|
-
const
|
|
1129
|
-
|
|
789
|
+
const manifestPath = path4.resolve(process.cwd(), options.manifest);
|
|
790
|
+
const builtOutputDir = path4.resolve(process.cwd(), options.root);
|
|
791
|
+
const appDir = path4.resolve(process.cwd(), options.appDir ?? "./app");
|
|
792
|
+
let manifest;
|
|
1130
793
|
try {
|
|
1131
|
-
raw = await readFile(manifestPath, "utf8");
|
|
794
|
+
const raw = await readFile(manifestPath, "utf8");
|
|
795
|
+
manifest = JSON.parse(raw);
|
|
1132
796
|
} catch (error) {
|
|
1133
797
|
printCliError(
|
|
1134
798
|
"manifest.not_found",
|
|
@@ -1138,72 +802,399 @@ program.command("validate").description("Validate schema coverage and rules").op
|
|
|
1138
802
|
process.exit(1);
|
|
1139
803
|
return;
|
|
1140
804
|
}
|
|
1141
|
-
|
|
805
|
+
if (!isManifest(manifest)) {
|
|
806
|
+
printCliError(
|
|
807
|
+
"manifest.invalid_shape",
|
|
808
|
+
"Manifest must contain a 'routes' object with string array values",
|
|
809
|
+
"Ensure each route maps to an array of schema type names."
|
|
810
|
+
);
|
|
811
|
+
process.exit(1);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
console.error(chalk.blue.bold("\u{1F50D} Schema Sentry Reality Check"));
|
|
815
|
+
console.error(chalk.gray("Validating actual built HTML against manifest expectations...\n"));
|
|
1142
816
|
try {
|
|
1143
|
-
|
|
1144
|
-
} catch
|
|
817
|
+
await access(builtOutputDir);
|
|
818
|
+
} catch {
|
|
1145
819
|
printCliError(
|
|
1146
|
-
"
|
|
1147
|
-
|
|
1148
|
-
"
|
|
820
|
+
"validate.no_build_output",
|
|
821
|
+
`No built HTML output found at ${builtOutputDir}`,
|
|
822
|
+
"Build your Next.js app first with `next build`, then run validate."
|
|
1149
823
|
);
|
|
1150
824
|
process.exit(1);
|
|
1151
825
|
return;
|
|
1152
826
|
}
|
|
1153
|
-
|
|
827
|
+
let report;
|
|
828
|
+
try {
|
|
829
|
+
report = await performRealityCheck({
|
|
830
|
+
manifest,
|
|
831
|
+
builtOutputDir,
|
|
832
|
+
sourceDir: appDir,
|
|
833
|
+
recommended
|
|
834
|
+
});
|
|
835
|
+
} catch (error) {
|
|
836
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1154
837
|
printCliError(
|
|
1155
|
-
"
|
|
1156
|
-
|
|
1157
|
-
"Ensure
|
|
838
|
+
"validate.check_failed",
|
|
839
|
+
`Failed to validate: ${message}`,
|
|
840
|
+
"Ensure your app is built and the --root points to the output directory."
|
|
1158
841
|
);
|
|
1159
842
|
process.exit(1);
|
|
1160
843
|
return;
|
|
1161
844
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
845
|
+
if (format === "json") {
|
|
846
|
+
if (options.output) {
|
|
847
|
+
const outputPath = path4.resolve(process.cwd(), options.output);
|
|
848
|
+
await mkdir(path4.dirname(outputPath), { recursive: true });
|
|
849
|
+
await writeFile(outputPath, stableStringify4(report), "utf8");
|
|
850
|
+
console.error(chalk.green(`
|
|
851
|
+
\u2713 Report written to ${outputPath}`));
|
|
852
|
+
} else {
|
|
853
|
+
console.log(stableStringify4(report));
|
|
854
|
+
}
|
|
855
|
+
} else {
|
|
856
|
+
if (options.output) {
|
|
857
|
+
const outputPath = path4.resolve(process.cwd(), options.output);
|
|
858
|
+
await mkdir(path4.dirname(outputPath), { recursive: true });
|
|
859
|
+
const legacyReport = convertRealityToLegacyReport(report);
|
|
860
|
+
await writeFile(outputPath, renderHtmlReport(legacyReport, { title: "Schema Sentry Validate Report" }), "utf8");
|
|
861
|
+
console.error(chalk.green(`
|
|
862
|
+
\u2713 HTML report written to ${outputPath}`));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
printRealitySummary(report, Date.now() - start);
|
|
866
|
+
if (annotationsMode === "github") {
|
|
867
|
+
const legacyReport = convertRealityToLegacyReport(report);
|
|
868
|
+
emitGitHubAnnotations(legacyReport, "validate");
|
|
869
|
+
}
|
|
870
|
+
process.exit(report.ok ? 0 : 1);
|
|
871
|
+
});
|
|
872
|
+
function printRealitySummary(report, durationMs) {
|
|
873
|
+
console.error("");
|
|
874
|
+
if (report.ok) {
|
|
875
|
+
console.error(chalk.green.bold("\u2705 All routes pass reality check"));
|
|
876
|
+
} else {
|
|
877
|
+
console.error(chalk.red.bold("\u274C Reality check failed"));
|
|
878
|
+
}
|
|
879
|
+
console.error(chalk.gray(`
|
|
880
|
+
\u{1F4CA} Summary:`));
|
|
881
|
+
console.error(` Routes checked: ${report.summary.routes}`);
|
|
882
|
+
console.error(` Score: ${report.summary.score}/100`);
|
|
883
|
+
console.error(` Duration: ${formatDuration(durationMs)}`);
|
|
884
|
+
if (report.summary.validRoutes > 0) {
|
|
885
|
+
console.error(chalk.green(` \u2705 Valid: ${report.summary.validRoutes}`));
|
|
886
|
+
}
|
|
887
|
+
if (report.summary.missingInSource > 0) {
|
|
888
|
+
console.error(chalk.red(` \u274C Missing in source: ${report.summary.missingInSource}`));
|
|
889
|
+
}
|
|
890
|
+
if (report.summary.missingInHtml > 0) {
|
|
891
|
+
console.error(chalk.red(` \u274C Missing in HTML: ${report.summary.missingInHtml}`));
|
|
892
|
+
}
|
|
893
|
+
if (report.summary.missingFromManifest > 0) {
|
|
894
|
+
console.error(chalk.yellow(` \u26A0\uFE0F Missing from manifest: ${report.summary.missingFromManifest}`));
|
|
895
|
+
}
|
|
896
|
+
if (report.summary.typeMismatches > 0) {
|
|
897
|
+
console.error(chalk.red(` \u274C Type mismatches: ${report.summary.typeMismatches}`));
|
|
898
|
+
}
|
|
899
|
+
if (report.summary.errors > 0 || report.summary.warnings > 0) {
|
|
900
|
+
console.error(chalk.gray(`
|
|
901
|
+
\u{1F4DD} Details:`));
|
|
902
|
+
console.error(` Errors: ${chalk.red(report.summary.errors.toString())}`);
|
|
903
|
+
console.error(` Warnings: ${chalk.yellow(report.summary.warnings.toString())}`);
|
|
904
|
+
}
|
|
905
|
+
const problemRoutes = report.routes.filter((r) => r.issues.length > 0);
|
|
906
|
+
if (problemRoutes.length > 0) {
|
|
907
|
+
console.error(chalk.gray(`
|
|
908
|
+
\u{1F50D} Problem routes:`));
|
|
909
|
+
for (const route of problemRoutes.slice(0, 5)) {
|
|
910
|
+
console.error(chalk.white(`
|
|
911
|
+
${route.route}`));
|
|
912
|
+
for (const issue of route.issues) {
|
|
913
|
+
const icon = issue.severity === "error" ? chalk.red("\u274C") : chalk.yellow("\u26A0\uFE0F");
|
|
914
|
+
console.error(` ${icon} ${issue.message}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (problemRoutes.length > 5) {
|
|
918
|
+
console.error(chalk.gray(`
|
|
919
|
+
... and ${problemRoutes.length - 5} more`));
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
console.error("");
|
|
923
|
+
}
|
|
924
|
+
function convertRealityToLegacyReport(report) {
|
|
925
|
+
return {
|
|
926
|
+
ok: report.ok,
|
|
927
|
+
summary: {
|
|
928
|
+
routes: report.summary.routes,
|
|
929
|
+
errors: report.summary.errors,
|
|
930
|
+
warnings: report.summary.warnings,
|
|
931
|
+
score: report.summary.score,
|
|
932
|
+
coverage: {
|
|
933
|
+
missingRoutes: report.summary.missingInSource + report.summary.missingInHtml,
|
|
934
|
+
missingTypes: report.summary.typeMismatches,
|
|
935
|
+
unlistedRoutes: report.summary.missingFromManifest
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
routes: report.routes.map((r) => ({
|
|
939
|
+
route: r.route,
|
|
940
|
+
ok: r.status === "valid" && r.issues.filter((i) => i.severity === "error").length === 0,
|
|
941
|
+
score: r.score,
|
|
942
|
+
issues: r.issues,
|
|
943
|
+
expectedTypes: r.expectedTypes,
|
|
944
|
+
foundTypes: r.foundTypes
|
|
945
|
+
}))
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// src/commands/init.ts
|
|
950
|
+
import { Command as Command2 } from "commander";
|
|
951
|
+
import path7 from "path";
|
|
952
|
+
|
|
953
|
+
// src/init.ts
|
|
954
|
+
import { promises as fs4 } from "fs";
|
|
955
|
+
import path5 from "path";
|
|
956
|
+
import { SCHEMA_CONTEXT } from "@schemasentry/core";
|
|
957
|
+
var DEFAULT_ANSWERS = {
|
|
958
|
+
siteName: "Acme Corp",
|
|
959
|
+
siteUrl: "https://acme.com",
|
|
960
|
+
authorName: "Jane Doe"
|
|
961
|
+
};
|
|
962
|
+
var getDefaultAnswers = () => ({ ...DEFAULT_ANSWERS });
|
|
963
|
+
var buildManifest = (scannedRoutes = []) => {
|
|
964
|
+
const routes = {
|
|
965
|
+
"/": ["Organization", "WebSite"],
|
|
966
|
+
"/blog/[slug]": ["Article"]
|
|
967
|
+
};
|
|
968
|
+
for (const route of scannedRoutes) {
|
|
969
|
+
if (!routes[route]) {
|
|
970
|
+
routes[route] = ["WebPage"];
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return { routes };
|
|
974
|
+
};
|
|
975
|
+
var formatDate = (value) => value.toISOString().slice(0, 10);
|
|
976
|
+
var buildData = (answers, options = {}) => {
|
|
977
|
+
const { siteName, siteUrl, authorName } = answers;
|
|
978
|
+
const normalizedSiteUrl = normalizeUrl(siteUrl);
|
|
979
|
+
const today = options.today ?? /* @__PURE__ */ new Date();
|
|
980
|
+
const date = formatDate(today);
|
|
981
|
+
const logoUrl = new URL("/logo.png", normalizedSiteUrl).toString();
|
|
982
|
+
const articleUrl = new URL("/blog/hello-world", normalizedSiteUrl).toString();
|
|
983
|
+
const imageUrl = new URL("/blog/hello-world.png", normalizedSiteUrl).toString();
|
|
984
|
+
const routes = {
|
|
985
|
+
"/": [
|
|
986
|
+
{
|
|
987
|
+
"@context": SCHEMA_CONTEXT,
|
|
988
|
+
"@type": "Organization",
|
|
989
|
+
name: siteName,
|
|
990
|
+
url: normalizedSiteUrl,
|
|
991
|
+
logo: logoUrl,
|
|
992
|
+
description: `Official website of ${siteName}`
|
|
993
|
+
},
|
|
994
|
+
{
|
|
995
|
+
"@context": SCHEMA_CONTEXT,
|
|
996
|
+
"@type": "WebSite",
|
|
997
|
+
name: siteName,
|
|
998
|
+
url: normalizedSiteUrl,
|
|
999
|
+
description: `Learn more about ${siteName}`
|
|
1000
|
+
}
|
|
1001
|
+
],
|
|
1002
|
+
"/blog/[slug]": [
|
|
1003
|
+
{
|
|
1004
|
+
"@context": SCHEMA_CONTEXT,
|
|
1005
|
+
"@type": "Article",
|
|
1006
|
+
headline: "Hello World",
|
|
1007
|
+
author: {
|
|
1008
|
+
"@type": "Person",
|
|
1009
|
+
name: authorName
|
|
1010
|
+
},
|
|
1011
|
+
datePublished: date,
|
|
1012
|
+
dateModified: date,
|
|
1013
|
+
description: `An introduction to ${siteName}`,
|
|
1014
|
+
image: imageUrl,
|
|
1015
|
+
url: articleUrl
|
|
1016
|
+
}
|
|
1017
|
+
]
|
|
1018
|
+
};
|
|
1019
|
+
for (const route of options.scannedRoutes ?? []) {
|
|
1020
|
+
if (routes[route]) {
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
routes[route] = [
|
|
1024
|
+
{
|
|
1025
|
+
"@context": SCHEMA_CONTEXT,
|
|
1026
|
+
"@type": "WebPage",
|
|
1027
|
+
name: routeToName(route),
|
|
1028
|
+
url: new URL(route, normalizedSiteUrl).toString()
|
|
1029
|
+
}
|
|
1030
|
+
];
|
|
1031
|
+
}
|
|
1032
|
+
return { routes };
|
|
1033
|
+
};
|
|
1034
|
+
var writeInitFiles = async (options) => {
|
|
1035
|
+
const {
|
|
1036
|
+
manifestPath,
|
|
1037
|
+
dataPath,
|
|
1038
|
+
overwriteManifest,
|
|
1039
|
+
overwriteData,
|
|
1040
|
+
answers,
|
|
1041
|
+
today,
|
|
1042
|
+
scannedRoutes
|
|
1043
|
+
} = options;
|
|
1044
|
+
const manifest = buildManifest(scannedRoutes ?? []);
|
|
1045
|
+
const data = buildData(answers, { today, scannedRoutes });
|
|
1046
|
+
const manifestResult = await writeJsonFile(
|
|
1047
|
+
manifestPath,
|
|
1048
|
+
manifest,
|
|
1049
|
+
overwriteManifest
|
|
1050
|
+
);
|
|
1051
|
+
const dataResult = await writeJsonFile(dataPath, data, overwriteData);
|
|
1052
|
+
return {
|
|
1053
|
+
manifest: manifestResult,
|
|
1054
|
+
data: dataResult
|
|
1055
|
+
};
|
|
1056
|
+
};
|
|
1057
|
+
var writeJsonFile = async (filePath, payload, overwrite) => {
|
|
1058
|
+
const exists = await fileExists2(filePath);
|
|
1059
|
+
if (exists && !overwrite) {
|
|
1060
|
+
return "skipped";
|
|
1061
|
+
}
|
|
1062
|
+
await fs4.mkdir(path5.dirname(filePath), { recursive: true });
|
|
1063
|
+
const json = JSON.stringify(payload, null, 2);
|
|
1064
|
+
await fs4.writeFile(filePath, `${json}
|
|
1065
|
+
`, "utf8");
|
|
1066
|
+
return exists ? "overwritten" : "created";
|
|
1067
|
+
};
|
|
1068
|
+
var fileExists2 = async (filePath) => {
|
|
1069
|
+
try {
|
|
1070
|
+
await fs4.access(filePath);
|
|
1071
|
+
return true;
|
|
1072
|
+
} catch {
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
var normalizeUrl = (value) => {
|
|
1077
|
+
try {
|
|
1078
|
+
return new URL(value).toString();
|
|
1079
|
+
} catch {
|
|
1080
|
+
return new URL(`https://${value}`).toString();
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
var routeToName = (route) => {
|
|
1084
|
+
if (route === "/") {
|
|
1085
|
+
return "Home";
|
|
1086
|
+
}
|
|
1087
|
+
const cleaned = route.replace(/\[|\]/g, "").split("/").filter(Boolean).join(" ");
|
|
1088
|
+
return cleaned.split(/\s+/).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)).join(" ");
|
|
1089
|
+
};
|
|
1090
|
+
|
|
1091
|
+
// src/routes.ts
|
|
1092
|
+
import { promises as fs5 } from "fs";
|
|
1093
|
+
import path6 from "path";
|
|
1094
|
+
var scanRoutes = async (options) => {
|
|
1095
|
+
const rootDir = options.rootDir;
|
|
1096
|
+
const routes = /* @__PURE__ */ new Set();
|
|
1097
|
+
if (options.includeApp !== false) {
|
|
1098
|
+
const appDir = path6.join(rootDir, "app");
|
|
1099
|
+
if (await dirExists(appDir)) {
|
|
1100
|
+
const files = await walkDir(appDir);
|
|
1101
|
+
for (const file of files) {
|
|
1102
|
+
if (!isAppPageFile(file)) {
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const route = toAppRoute(appDir, file);
|
|
1106
|
+
if (route) {
|
|
1107
|
+
routes.add(route);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
if (options.includePages !== false) {
|
|
1113
|
+
const pagesDir = path6.join(rootDir, "pages");
|
|
1114
|
+
if (await dirExists(pagesDir)) {
|
|
1115
|
+
const files = await walkDir(pagesDir);
|
|
1116
|
+
for (const file of files) {
|
|
1117
|
+
if (!isPagesFile(file)) {
|
|
1118
|
+
continue;
|
|
1119
|
+
}
|
|
1120
|
+
const route = toPagesRoute(pagesDir, file);
|
|
1121
|
+
if (route) {
|
|
1122
|
+
routes.add(route);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return Array.from(routes).sort();
|
|
1128
|
+
};
|
|
1129
|
+
var isAppPageFile = (filePath) => /\/page\.(t|j)sx?$/.test(filePath) || /\/page\.mdx$/.test(filePath);
|
|
1130
|
+
var isPagesFile = (filePath) => /\.(t|j)sx?$/.test(filePath) || /\.mdx$/.test(filePath);
|
|
1131
|
+
var toAppRoute = (appDir, filePath) => {
|
|
1132
|
+
const relative = path6.relative(appDir, filePath);
|
|
1133
|
+
const segments = relative.split(path6.sep);
|
|
1134
|
+
if (segments.length === 0) {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
segments.pop();
|
|
1138
|
+
const routeSegments = segments.filter((segment) => segment.length > 0).filter((segment) => !isGroupSegment(segment)).filter((segment) => !isParallelSegment(segment));
|
|
1139
|
+
if (routeSegments.length === 0) {
|
|
1140
|
+
return "/";
|
|
1141
|
+
}
|
|
1142
|
+
return `/${routeSegments.join("/")}`;
|
|
1143
|
+
};
|
|
1144
|
+
var toPagesRoute = (pagesDir, filePath) => {
|
|
1145
|
+
const relative = path6.relative(pagesDir, filePath);
|
|
1146
|
+
const segments = relative.split(path6.sep);
|
|
1147
|
+
if (segments.length === 0) {
|
|
1148
|
+
return null;
|
|
1149
|
+
}
|
|
1150
|
+
if (segments[0] === "api") {
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
const fileName = segments.pop();
|
|
1154
|
+
if (!fileName) {
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
const baseName = fileName.replace(/\.[^/.]+$/, "");
|
|
1158
|
+
if (baseName.startsWith("_")) {
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
const filtered = segments.filter((segment) => segment.length > 0);
|
|
1162
|
+
if (baseName !== "index") {
|
|
1163
|
+
filtered.push(baseName);
|
|
1164
|
+
}
|
|
1165
|
+
if (filtered.length === 0) {
|
|
1166
|
+
return "/";
|
|
1173
1167
|
}
|
|
1174
|
-
|
|
1168
|
+
return `/${filtered.join("/")}`;
|
|
1169
|
+
};
|
|
1170
|
+
var isGroupSegment = (segment) => segment.startsWith("(") && segment.endsWith(")");
|
|
1171
|
+
var isParallelSegment = (segment) => segment.startsWith("@");
|
|
1172
|
+
var dirExists = async (dirPath) => {
|
|
1175
1173
|
try {
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
"Schema data is not valid JSON",
|
|
1181
|
-
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
1182
|
-
);
|
|
1183
|
-
process.exit(1);
|
|
1184
|
-
return;
|
|
1174
|
+
const stat = await fs5.stat(dirPath);
|
|
1175
|
+
return stat.isDirectory();
|
|
1176
|
+
} catch {
|
|
1177
|
+
return false;
|
|
1185
1178
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
);
|
|
1192
|
-
|
|
1193
|
-
|
|
1179
|
+
};
|
|
1180
|
+
var walkDir = async (dirPath) => {
|
|
1181
|
+
const entries = await fs5.readdir(dirPath, { withFileTypes: true });
|
|
1182
|
+
const files = [];
|
|
1183
|
+
for (const entry of entries) {
|
|
1184
|
+
const resolved = path6.join(dirPath, entry.name);
|
|
1185
|
+
if (entry.isDirectory()) {
|
|
1186
|
+
files.push(...await walkDir(resolved));
|
|
1187
|
+
} else if (entry.isFile()) {
|
|
1188
|
+
files.push(resolved);
|
|
1189
|
+
}
|
|
1194
1190
|
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
emitAnnotations(report, annotationsMode, "validate");
|
|
1203
|
-
printValidateSummary(report, Date.now() - start);
|
|
1204
|
-
process.exit(report.ok ? 0 : 1);
|
|
1205
|
-
});
|
|
1206
|
-
program.command("init").description("Interactive setup wizard").option(
|
|
1191
|
+
return files;
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
// src/commands/init.ts
|
|
1195
|
+
import { createInterface } from "readline/promises";
|
|
1196
|
+
import { stdin as input, stdout as output } from "process";
|
|
1197
|
+
var initCommand = new Command2("init").description("Interactive setup wizard").option(
|
|
1207
1198
|
"-m, --manifest <path>",
|
|
1208
1199
|
"Path to manifest JSON",
|
|
1209
1200
|
"schema-sentry.manifest.json"
|
|
@@ -1212,12 +1203,12 @@ program.command("init").description("Interactive setup wizard").option(
|
|
|
1212
1203
|
"Path to schema data JSON",
|
|
1213
1204
|
"schema-sentry.data.json"
|
|
1214
1205
|
).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) => {
|
|
1215
|
-
const manifestPath =
|
|
1216
|
-
const dataPath =
|
|
1206
|
+
const manifestPath = path7.resolve(process.cwd(), options.manifest);
|
|
1207
|
+
const dataPath = path7.resolve(process.cwd(), options.data);
|
|
1217
1208
|
const force = options.force ?? false;
|
|
1218
1209
|
const useDefaults = options.yes ?? false;
|
|
1219
1210
|
const answers = useDefaults ? getDefaultAnswers() : await promptAnswers();
|
|
1220
|
-
const scannedRoutes = options.scan ? await scanRoutes({ rootDir:
|
|
1211
|
+
const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path7.resolve(process.cwd(), options.root ?? ".") }) : [];
|
|
1221
1212
|
if (options.scan && scannedRoutes.length === 0) {
|
|
1222
1213
|
console.error("No routes found during scan.");
|
|
1223
1214
|
}
|
|
@@ -1237,457 +1228,484 @@ program.command("init").description("Interactive setup wizard").option(
|
|
|
1237
1228
|
});
|
|
1238
1229
|
printInitSummary({ manifestPath, dataPath, result });
|
|
1239
1230
|
});
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
"schema-sentry.data.json"
|
|
1244
|
-
).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) => {
|
|
1245
|
-
const start = Date.now();
|
|
1246
|
-
const format = resolveOutputFormat(options.format);
|
|
1247
|
-
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
1248
|
-
const recommended = await resolveRecommendedOption(options.config);
|
|
1249
|
-
const dataPath = path6.resolve(process.cwd(), options.data);
|
|
1250
|
-
let dataRaw;
|
|
1231
|
+
async function promptAnswers() {
|
|
1232
|
+
const defaults = getDefaultAnswers();
|
|
1233
|
+
const rl = createInterface({ input, output });
|
|
1251
1234
|
try {
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
);
|
|
1259
|
-
process.exit(1);
|
|
1260
|
-
return;
|
|
1235
|
+
const siteName = await ask(rl, "Site name", defaults.siteName);
|
|
1236
|
+
const siteUrl = await ask(rl, "Base URL", defaults.siteUrl);
|
|
1237
|
+
const authorName = await ask(rl, "Primary author name", defaults.authorName);
|
|
1238
|
+
return { siteName, siteUrl, authorName };
|
|
1239
|
+
} finally {
|
|
1240
|
+
rl.close();
|
|
1261
1241
|
}
|
|
1262
|
-
|
|
1242
|
+
}
|
|
1243
|
+
async function ask(rl, question, fallback) {
|
|
1244
|
+
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
1245
|
+
return answer.length > 0 ? answer : fallback;
|
|
1246
|
+
}
|
|
1247
|
+
async function resolveOverwrites(options) {
|
|
1248
|
+
const { manifestPath, dataPath, force, interactive } = options;
|
|
1249
|
+
if (force) {
|
|
1250
|
+
return [true, true];
|
|
1251
|
+
}
|
|
1252
|
+
const manifestExists = await fileExists2(manifestPath);
|
|
1253
|
+
const dataExists = await fileExists2(dataPath);
|
|
1254
|
+
if (!interactive) {
|
|
1255
|
+
return [false, false];
|
|
1256
|
+
}
|
|
1257
|
+
const rl = createInterface({ input, output });
|
|
1263
1258
|
try {
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
1270
|
-
);
|
|
1271
|
-
process.exit(1);
|
|
1272
|
-
return;
|
|
1259
|
+
const overwriteManifest = manifestExists ? await confirm(rl, `Manifest exists at ${manifestPath}. Overwrite?`, false) : false;
|
|
1260
|
+
const overwriteData = dataExists ? await confirm(rl, `Data file exists at ${dataPath}. Overwrite?`, false) : false;
|
|
1261
|
+
return [overwriteManifest, overwriteData];
|
|
1262
|
+
} finally {
|
|
1263
|
+
rl.close();
|
|
1273
1264
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
process.exit(1);
|
|
1281
|
-
return;
|
|
1265
|
+
}
|
|
1266
|
+
async function confirm(rl, question, defaultValue) {
|
|
1267
|
+
const hint = defaultValue ? "Y/n" : "y/N";
|
|
1268
|
+
const answer = (await rl.question(`${question} (${hint}): `)).trim().toLowerCase();
|
|
1269
|
+
if (!answer) {
|
|
1270
|
+
return defaultValue;
|
|
1282
1271
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
}
|
|
1290
|
-
printCliError(
|
|
1291
|
-
"manifest.not_found",
|
|
1292
|
-
`Manifest not found at ${manifestPath}`,
|
|
1293
|
-
"Run `schemasentry init` to generate starter files."
|
|
1294
|
-
);
|
|
1295
|
-
process.exit(1);
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
try {
|
|
1299
|
-
manifest = JSON.parse(manifestRaw);
|
|
1300
|
-
} catch (error) {
|
|
1301
|
-
printCliError(
|
|
1302
|
-
"manifest.invalid_json",
|
|
1303
|
-
"Manifest is not valid JSON",
|
|
1304
|
-
"Check the JSON syntax or regenerate with `schemasentry init`."
|
|
1305
|
-
);
|
|
1306
|
-
process.exit(1);
|
|
1307
|
-
return;
|
|
1308
|
-
}
|
|
1309
|
-
if (!isManifest(manifest)) {
|
|
1310
|
-
printCliError(
|
|
1311
|
-
"manifest.invalid_shape",
|
|
1312
|
-
"Manifest must contain a 'routes' object with string array values",
|
|
1313
|
-
"Ensure each route maps to an array of schema type names."
|
|
1314
|
-
);
|
|
1315
|
-
process.exit(1);
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1272
|
+
return answer === "y" || answer === "yes";
|
|
1273
|
+
}
|
|
1274
|
+
function printInitSummary(options) {
|
|
1275
|
+
const { manifestPath, dataPath, result } = options;
|
|
1276
|
+
const created = [];
|
|
1277
|
+
if (result.manifest !== "skipped") {
|
|
1278
|
+
created.push(`${manifestPath} (${result.manifest})`);
|
|
1318
1279
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
console.error("No routes found during scan.");
|
|
1280
|
+
if (result.data !== "skipped") {
|
|
1281
|
+
created.push(`${dataPath} (${result.data})`);
|
|
1322
1282
|
}
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
});
|
|
1328
|
-
await emitReport({
|
|
1329
|
-
report,
|
|
1330
|
-
format,
|
|
1331
|
-
outputPath: options.output,
|
|
1332
|
-
title: "Schema Sentry Audit Report"
|
|
1333
|
-
});
|
|
1334
|
-
emitAnnotations(report, annotationsMode, "audit");
|
|
1335
|
-
printAuditSummary(report, Boolean(manifest), Date.now() - start);
|
|
1336
|
-
process.exit(report.ok ? 0 : 1);
|
|
1337
|
-
});
|
|
1338
|
-
program.command("collect").description("Collect JSON-LD blocks from built HTML output").option("--root <path>", "Root directory to scan for HTML files", ".").option("--routes <routes...>", "Only collect specific routes (repeat or comma-separated)").option("--strict-routes", "Fail when any route passed via --routes is missing").option("--format <format>", "Output format (json)", "json").option("-o, --output <path>", "Write collected schema data to file").option("--check", "Compare collected output with an existing schema data file").option(
|
|
1339
|
-
"-d, --data <path>",
|
|
1340
|
-
"Path to existing schema data JSON for --check",
|
|
1341
|
-
"schema-sentry.data.json"
|
|
1342
|
-
).action(async (options) => {
|
|
1343
|
-
const start = Date.now();
|
|
1344
|
-
const format = resolveCollectOutputFormat(options.format);
|
|
1345
|
-
const rootDir = path6.resolve(process.cwd(), options.root ?? ".");
|
|
1346
|
-
const check = options.check ?? false;
|
|
1347
|
-
const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
|
|
1348
|
-
const strictRoutes = options.strictRoutes ?? false;
|
|
1349
|
-
let collected;
|
|
1350
|
-
try {
|
|
1351
|
-
collected = await collectSchemaData({ rootDir, routes: requestedRoutes });
|
|
1352
|
-
} catch (error) {
|
|
1353
|
-
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
1354
|
-
printCliError(
|
|
1355
|
-
"collect.scan_failed",
|
|
1356
|
-
`Could not scan HTML output at ${rootDir}: ${reason}`,
|
|
1357
|
-
"Point --root to a directory containing built HTML output."
|
|
1358
|
-
);
|
|
1359
|
-
process.exit(1);
|
|
1360
|
-
return;
|
|
1283
|
+
if (created.length > 0) {
|
|
1284
|
+
console.log("Schema Sentry init complete.");
|
|
1285
|
+
console.log(`Created ${created.length} file(s):`);
|
|
1286
|
+
created.forEach((entry) => console.log(`- ${entry}`));
|
|
1361
1287
|
}
|
|
1362
|
-
if (
|
|
1363
|
-
|
|
1364
|
-
"collect.no_html",
|
|
1365
|
-
`No HTML files found under ${rootDir}`,
|
|
1366
|
-
"Point --root to a static output directory (for example ./out)."
|
|
1367
|
-
);
|
|
1368
|
-
process.exit(1);
|
|
1369
|
-
return;
|
|
1288
|
+
if (result.manifest === "skipped" || result.data === "skipped") {
|
|
1289
|
+
console.log("Some files were skipped. Use --force to overwrite.");
|
|
1370
1290
|
}
|
|
1371
|
-
if (
|
|
1372
|
-
|
|
1373
|
-
"collect.missing_required_routes",
|
|
1374
|
-
`Required routes were not found in collected HTML: ${collected.missingRoutes.join(", ")}`,
|
|
1375
|
-
"Rebuild output, adjust --root, or update --routes."
|
|
1376
|
-
);
|
|
1377
|
-
process.exit(1);
|
|
1378
|
-
return;
|
|
1291
|
+
if (result.manifest === "skipped" && result.data === "skipped") {
|
|
1292
|
+
console.log("No files were written.");
|
|
1379
1293
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1294
|
+
if (created.length > 0) {
|
|
1295
|
+
console.log("Next: run `schemasentry validate` to verify your setup.");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// src/commands/audit.ts
|
|
1300
|
+
import { Command as Command3 } from "commander";
|
|
1301
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1302
|
+
import { promises as fs6 } from "fs";
|
|
1303
|
+
import path8 from "path";
|
|
1304
|
+
import chalk2 from "chalk";
|
|
1305
|
+
|
|
1306
|
+
// src/audit.ts
|
|
1307
|
+
import {
|
|
1308
|
+
validateSchema as validateSchema2
|
|
1309
|
+
} from "@schemasentry/core";
|
|
1310
|
+
|
|
1311
|
+
// src/coverage.ts
|
|
1312
|
+
var buildCoverageResult = (input2, data) => {
|
|
1313
|
+
const manifestRoutes = input2.expectedTypesByRoute ?? {};
|
|
1314
|
+
const dataRoutes = data.routes ?? {};
|
|
1315
|
+
const derivedRequiredRoutes = Object.entries(manifestRoutes).filter(([, types]) => (types ?? []).length > 0).map(([route]) => route);
|
|
1316
|
+
const requiredRoutes = /* @__PURE__ */ new Set([
|
|
1317
|
+
...input2.requiredRoutes ?? [],
|
|
1318
|
+
...derivedRequiredRoutes
|
|
1319
|
+
]);
|
|
1320
|
+
const allRoutes = Array.from(
|
|
1321
|
+
/* @__PURE__ */ new Set([
|
|
1322
|
+
...Object.keys(manifestRoutes),
|
|
1323
|
+
...requiredRoutes,
|
|
1324
|
+
...Object.keys(dataRoutes)
|
|
1325
|
+
])
|
|
1326
|
+
).sort();
|
|
1327
|
+
const issuesByRoute = {};
|
|
1328
|
+
const summary = {
|
|
1329
|
+
missingRoutes: 0,
|
|
1330
|
+
missingTypes: 0,
|
|
1331
|
+
unlistedRoutes: 0
|
|
1332
|
+
};
|
|
1333
|
+
for (const route of allRoutes) {
|
|
1334
|
+
const issues = [];
|
|
1335
|
+
const expectedTypes = manifestRoutes[route] ?? [];
|
|
1336
|
+
const nodes = dataRoutes[route] ?? [];
|
|
1337
|
+
const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
1338
|
+
if (requiredRoutes.has(route) && nodes.length === 0) {
|
|
1339
|
+
issues.push({
|
|
1340
|
+
path: `routes["${route}"]`,
|
|
1341
|
+
message: "No schema blocks found for route",
|
|
1342
|
+
severity: "error",
|
|
1343
|
+
ruleId: "coverage.missing_route"
|
|
1344
|
+
});
|
|
1345
|
+
summary.missingRoutes += 1;
|
|
1406
1346
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1347
|
+
for (const expectedType of expectedTypes) {
|
|
1348
|
+
if (!foundTypes.includes(expectedType)) {
|
|
1349
|
+
issues.push({
|
|
1350
|
+
path: `routes["${route}"].types`,
|
|
1351
|
+
message: `Missing expected schema type '${expectedType}'`,
|
|
1352
|
+
severity: "error",
|
|
1353
|
+
ruleId: "coverage.missing_type"
|
|
1354
|
+
});
|
|
1355
|
+
summary.missingTypes += 1;
|
|
1356
|
+
}
|
|
1415
1357
|
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1358
|
+
if (!manifestRoutes[route] && !requiredRoutes.has(route) && nodes.length > 0) {
|
|
1359
|
+
issues.push({
|
|
1360
|
+
path: `routes["${route}"]`,
|
|
1361
|
+
message: "Route has schema but is missing from manifest",
|
|
1362
|
+
severity: "warn",
|
|
1363
|
+
ruleId: "coverage.unlisted_route"
|
|
1364
|
+
});
|
|
1365
|
+
summary.unlistedRoutes += 1;
|
|
1423
1366
|
}
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
if (options.output) {
|
|
1427
|
-
const resolvedPath = path6.resolve(process.cwd(), options.output);
|
|
1428
|
-
try {
|
|
1429
|
-
await mkdir(path6.dirname(resolvedPath), { recursive: true });
|
|
1430
|
-
await writeFile(resolvedPath, `${content}
|
|
1431
|
-
`, "utf8");
|
|
1432
|
-
console.error(`Collected data written to ${resolvedPath}`);
|
|
1433
|
-
} catch (error) {
|
|
1434
|
-
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
1435
|
-
printCliError(
|
|
1436
|
-
"output.write_failed",
|
|
1437
|
-
`Could not write collected data to ${resolvedPath}: ${reason}`
|
|
1438
|
-
);
|
|
1439
|
-
process.exit(1);
|
|
1440
|
-
return;
|
|
1367
|
+
if (issues.length > 0) {
|
|
1368
|
+
issuesByRoute[route] = issues;
|
|
1441
1369
|
}
|
|
1442
|
-
} else if (!check) {
|
|
1443
|
-
console.log(content);
|
|
1444
1370
|
}
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1371
|
+
return {
|
|
1372
|
+
allRoutes,
|
|
1373
|
+
issuesByRoute,
|
|
1374
|
+
summary
|
|
1375
|
+
};
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
// src/audit.ts
|
|
1379
|
+
var buildAuditReport = (data, options = {}) => {
|
|
1380
|
+
const dataRoutes = data.routes ?? {};
|
|
1381
|
+
const manifestRoutes = options.manifest?.routes ?? {};
|
|
1382
|
+
const coverageEnabled = Boolean(options.manifest || options.requiredRoutes?.length);
|
|
1383
|
+
const coverage = coverageEnabled ? buildCoverageResult(
|
|
1384
|
+
{
|
|
1385
|
+
expectedTypesByRoute: manifestRoutes,
|
|
1386
|
+
requiredRoutes: options.requiredRoutes
|
|
1387
|
+
},
|
|
1388
|
+
data
|
|
1389
|
+
) : null;
|
|
1390
|
+
const allRoutes = coverageEnabled ? coverage?.allRoutes ?? [] : Object.keys(dataRoutes).sort();
|
|
1391
|
+
const routes = allRoutes.map((route) => {
|
|
1392
|
+
const expectedTypes = coverageEnabled ? manifestRoutes[route] ?? [] : [];
|
|
1393
|
+
const nodes = dataRoutes[route] ?? [];
|
|
1394
|
+
const foundTypes = nodes.map((node) => node["@type"]).filter((type) => typeof type === "string");
|
|
1395
|
+
const validation = validateSchema2(nodes, options);
|
|
1396
|
+
const issues = [
|
|
1397
|
+
...validation.issues,
|
|
1398
|
+
...coverage?.issuesByRoute[route] ?? []
|
|
1399
|
+
];
|
|
1400
|
+
const errorCount = issues.filter((issue) => issue.severity === "error").length;
|
|
1401
|
+
const warnCount = issues.filter((issue) => issue.severity === "warn").length;
|
|
1402
|
+
const score = Math.max(0, validation.score - errorCount * 5 - warnCount * 2);
|
|
1403
|
+
return {
|
|
1404
|
+
route,
|
|
1405
|
+
ok: errorCount === 0,
|
|
1406
|
+
score,
|
|
1407
|
+
issues,
|
|
1408
|
+
expectedTypes,
|
|
1409
|
+
foundTypes
|
|
1410
|
+
};
|
|
1454
1411
|
});
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1412
|
+
const summaryErrors = routes.reduce(
|
|
1413
|
+
(count, route) => count + route.issues.filter((i) => i.severity === "error").length,
|
|
1414
|
+
0
|
|
1415
|
+
);
|
|
1416
|
+
const summaryWarnings = routes.reduce(
|
|
1417
|
+
(count, route) => count + route.issues.filter((i) => i.severity === "warn").length,
|
|
1418
|
+
0
|
|
1419
|
+
);
|
|
1420
|
+
const summaryScore = routes.length === 0 ? 0 : Math.round(
|
|
1421
|
+
routes.reduce((total, route) => total + route.score, 0) / routes.length
|
|
1422
|
+
);
|
|
1423
|
+
return {
|
|
1424
|
+
ok: summaryErrors === 0,
|
|
1425
|
+
summary: {
|
|
1426
|
+
routes: routes.length,
|
|
1427
|
+
errors: summaryErrors,
|
|
1428
|
+
warnings: summaryWarnings,
|
|
1429
|
+
score: summaryScore,
|
|
1430
|
+
...coverageEnabled ? { coverage: coverage?.summary } : {}
|
|
1431
|
+
},
|
|
1432
|
+
routes
|
|
1433
|
+
};
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
// src/commands/audit.ts
|
|
1437
|
+
var auditCommand = new Command3("audit").description("Analyze schema health and check for ghost routes (routes in manifest without Schema components)").option(
|
|
1462
1438
|
"-d, --data <path>",
|
|
1463
|
-
"Path to schema data JSON",
|
|
1439
|
+
"Path to schema data JSON (optional, for legacy mode)",
|
|
1464
1440
|
"schema-sentry.data.json"
|
|
1465
|
-
).option("--root <path>", "Project root for scanning", ".").option("--
|
|
1441
|
+
).option("-m, --manifest <path>", "Path to manifest JSON (optional)", "schema-sentry.manifest.json").option("--scan", "Scan the filesystem for routes").option("--root <path>", "Project root for scanning", ".").option("--app-dir <path>", "Path to Next.js app directory for source scanning", "./app").option("--source-scan", "Enable source file scanning for ghost route detection", true).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) => {
|
|
1466
1442
|
const start = Date.now();
|
|
1467
|
-
const
|
|
1468
|
-
const
|
|
1469
|
-
const
|
|
1470
|
-
const
|
|
1471
|
-
const
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
console.error("\nUse --force to skip this confirmation.");
|
|
1443
|
+
const skipSourceScan = process.env.SKIP_SOURCE_SCAN === "1";
|
|
1444
|
+
const sourceScanEnabled = skipSourceScan ? false : options.sourceScan ?? true;
|
|
1445
|
+
const format = resolveOutputFormat(options.format);
|
|
1446
|
+
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
1447
|
+
const recommended = await resolveRecommendedOption(options.config);
|
|
1448
|
+
const appDir = path8.resolve(process.cwd(), options.appDir ?? "./app");
|
|
1449
|
+
console.error(chalk2.blue.bold("\u{1F50D} Schema Sentry Audit"));
|
|
1450
|
+
console.error(chalk2.gray("Analyzing schema health and checking for ghost routes...\n"));
|
|
1451
|
+
let manifest;
|
|
1452
|
+
const manifestPath = options.manifest ? path8.resolve(process.cwd(), options.manifest) : void 0;
|
|
1453
|
+
if (manifestPath) {
|
|
1454
|
+
try {
|
|
1455
|
+
const manifestRaw = await readFile2(manifestPath, "utf8");
|
|
1456
|
+
manifest = JSON.parse(manifestRaw);
|
|
1457
|
+
if (!isManifest(manifest)) {
|
|
1458
|
+
printCliError(
|
|
1459
|
+
"manifest.invalid_shape",
|
|
1460
|
+
"Manifest must contain a 'routes' object with string array values",
|
|
1461
|
+
"Ensure each route maps to an array of schema type names."
|
|
1462
|
+
);
|
|
1463
|
+
process.exit(1);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
} catch {
|
|
1467
|
+
manifest = void 0;
|
|
1468
|
+
}
|
|
1494
1469
|
}
|
|
1470
|
+
let data;
|
|
1471
|
+
const dataPath = path8.resolve(process.cwd(), options.data);
|
|
1495
1472
|
try {
|
|
1496
|
-
await
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
dryRun,
|
|
1501
|
-
force
|
|
1502
|
-
});
|
|
1503
|
-
console.error(`
|
|
1504
|
-
Scaffold complete in ${Date.now() - start}ms`);
|
|
1505
|
-
process.exit(0);
|
|
1506
|
-
} catch (error) {
|
|
1507
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1508
|
-
printCliError(
|
|
1509
|
-
"scaffold.apply_failed",
|
|
1510
|
-
`Failed to apply scaffold: ${message}`,
|
|
1511
|
-
"Check file permissions or disk space."
|
|
1512
|
-
);
|
|
1513
|
-
process.exit(1);
|
|
1514
|
-
}
|
|
1515
|
-
});
|
|
1516
|
-
function isManifest(value) {
|
|
1517
|
-
if (!value || typeof value !== "object") {
|
|
1518
|
-
return false;
|
|
1519
|
-
}
|
|
1520
|
-
const manifest = value;
|
|
1521
|
-
if (!manifest.routes || typeof manifest.routes !== "object") {
|
|
1522
|
-
return false;
|
|
1523
|
-
}
|
|
1524
|
-
for (const entry of Object.values(manifest.routes)) {
|
|
1525
|
-
if (!Array.isArray(entry) || entry.some((item) => typeof item !== "string")) {
|
|
1526
|
-
return false;
|
|
1473
|
+
const dataRaw = await readFile2(dataPath, "utf8");
|
|
1474
|
+
data = JSON.parse(dataRaw);
|
|
1475
|
+
if (!isSchemaData(data)) {
|
|
1476
|
+
data = void 0;
|
|
1527
1477
|
}
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
if (!Array.isArray(entry)) {
|
|
1541
|
-
return false;
|
|
1478
|
+
} catch {
|
|
1479
|
+
data = void 0;
|
|
1480
|
+
}
|
|
1481
|
+
const sourceScanResult = !sourceScanEnabled ? { routes: [], totalFiles: 0, filesWithSchema: 0, filesMissingSchema: 0 } : await scanSourceFiles({ rootDir: options.root ?? ".", appDir });
|
|
1482
|
+
const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path8.resolve(process.cwd(), options.root ?? ".") }) : [];
|
|
1483
|
+
const ghostRoutes = [];
|
|
1484
|
+
if (manifest && sourceScanEnabled) {
|
|
1485
|
+
for (const route of Object.keys(manifest.routes)) {
|
|
1486
|
+
const sourceInfo = sourceScanResult.routes.find((r) => r.route === route);
|
|
1487
|
+
if (!sourceInfo?.hasSchemaUsage) {
|
|
1488
|
+
ghostRoutes.push(route);
|
|
1489
|
+
}
|
|
1542
1490
|
}
|
|
1543
1491
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1492
|
+
const report = buildAuditReport(data ?? { routes: {} }, {
|
|
1493
|
+
recommended,
|
|
1494
|
+
manifest,
|
|
1495
|
+
requiredRoutes: scannedRoutes.length > 0 ? scannedRoutes : void 0
|
|
1496
|
+
});
|
|
1497
|
+
if (format === "json") {
|
|
1498
|
+
if (options.output) {
|
|
1499
|
+
const outputPath = path8.resolve(process.cwd(), options.output);
|
|
1500
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1501
|
+
} else {
|
|
1502
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1503
|
+
}
|
|
1504
|
+
} else {
|
|
1505
|
+
if (options.output) {
|
|
1506
|
+
const outputPath = path8.resolve(process.cwd(), options.output);
|
|
1507
|
+
const html = renderHtmlReport(report, { title: "Schema Sentry Audit Report" });
|
|
1508
|
+
await fs6.writeFile(outputPath, html, "utf8");
|
|
1509
|
+
console.error(chalk2.green(`
|
|
1510
|
+
\u2713 HTML report written to ${outputPath}`));
|
|
1511
|
+
}
|
|
1550
1512
|
}
|
|
1551
|
-
|
|
1552
|
-
"
|
|
1553
|
-
`Unsupported report format '${value ?? ""}'`,
|
|
1554
|
-
"Use --format json or --format html."
|
|
1555
|
-
);
|
|
1556
|
-
process.exit(1);
|
|
1557
|
-
return "json";
|
|
1558
|
-
}
|
|
1559
|
-
function resolveAnnotationsMode(value) {
|
|
1560
|
-
const mode = (value ?? "none").trim().toLowerCase();
|
|
1561
|
-
if (mode === "none" || mode === "github") {
|
|
1562
|
-
return mode;
|
|
1513
|
+
if (annotationsMode === "github") {
|
|
1514
|
+
emitGitHubAnnotations(report, "audit");
|
|
1563
1515
|
}
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1516
|
+
printEnhancedAuditSummary({
|
|
1517
|
+
report,
|
|
1518
|
+
hasManifest: Boolean(manifest),
|
|
1519
|
+
ghostRoutes,
|
|
1520
|
+
sourceScan: sourceScanResult,
|
|
1521
|
+
durationMs: Date.now() - start
|
|
1522
|
+
});
|
|
1523
|
+
process.exit(report.ok && ghostRoutes.length === 0 ? 0 : 1);
|
|
1524
|
+
});
|
|
1525
|
+
function printEnhancedAuditSummary(options) {
|
|
1526
|
+
const { report, hasManifest, ghostRoutes, sourceScan, durationMs } = options;
|
|
1527
|
+
console.error("");
|
|
1528
|
+
if (report.ok && ghostRoutes.length === 0) {
|
|
1529
|
+
console.error(chalk2.green.bold("\u2705 Audit passed"));
|
|
1530
|
+
} else {
|
|
1531
|
+
console.error(chalk2.red.bold("\u274C Audit found issues"));
|
|
1532
|
+
}
|
|
1533
|
+
console.error(chalk2.gray(`
|
|
1534
|
+
\u{1F4CA} Summary:`));
|
|
1535
|
+
console.error(` Routes analyzed: ${report.summary.routes}`);
|
|
1536
|
+
console.error(` Score: ${report.summary.score}/100`);
|
|
1537
|
+
console.error(` Duration: ${formatDuration(durationMs)}`);
|
|
1538
|
+
if (sourceScan.totalFiles > 0) {
|
|
1539
|
+
console.error(chalk2.gray(`
|
|
1540
|
+
\u{1F4C1} Source Code Analysis:`));
|
|
1541
|
+
console.error(` Page files found: ${sourceScan.totalFiles}`);
|
|
1542
|
+
console.error(chalk2.green(` \u2705 With Schema components: ${sourceScan.filesWithSchema}`));
|
|
1543
|
+
if (sourceScan.filesMissingSchema > 0) {
|
|
1544
|
+
console.error(chalk2.red(` \u274C Missing Schema: ${sourceScan.filesMissingSchema}`));
|
|
1545
|
+
}
|
|
1576
1546
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
"
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1547
|
+
if (hasManifest && ghostRoutes.length > 0) {
|
|
1548
|
+
console.error(chalk2.red(`
|
|
1549
|
+
\u{1F47B} Ghost Routes (${ghostRoutes.length}):`));
|
|
1550
|
+
console.error(chalk2.gray(" Routes in manifest but no <Schema> component in source:"));
|
|
1551
|
+
for (const route of ghostRoutes.slice(0, 5)) {
|
|
1552
|
+
console.error(chalk2.red(` \u274C ${route}`));
|
|
1553
|
+
}
|
|
1554
|
+
if (ghostRoutes.length > 5) {
|
|
1555
|
+
console.error(chalk2.gray(` ... and ${ghostRoutes.length - 5} more`));
|
|
1556
|
+
}
|
|
1557
|
+
console.error(chalk2.gray("\n These routes are in your manifest but don't have Schema components."));
|
|
1558
|
+
console.error(chalk2.gray(" Run `schemasentry scaffold` to see what code to add."));
|
|
1588
1559
|
}
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1560
|
+
if (report.summary.errors > 0 || report.summary.warnings > 0) {
|
|
1561
|
+
console.error(chalk2.gray(`
|
|
1562
|
+
\u{1F4DD} Data File Issues:`));
|
|
1563
|
+
console.error(` Errors: ${chalk2.red(report.summary.errors.toString())}`);
|
|
1564
|
+
console.error(` Warnings: ${chalk2.yellow(report.summary.warnings.toString())}`);
|
|
1594
1565
|
}
|
|
1595
|
-
|
|
1566
|
+
console.error("");
|
|
1596
1567
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1568
|
+
|
|
1569
|
+
// src/commands/collect.ts
|
|
1570
|
+
import { Command as Command4 } from "commander";
|
|
1571
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readFile as readFile3 } from "fs/promises";
|
|
1572
|
+
import path9 from "path";
|
|
1573
|
+
import { stableStringify as stableStringify5 } from "@schemasentry/core";
|
|
1574
|
+
var collectCommand = new Command4("collect").description("Collect JSON-LD blocks from built HTML output").option("--root <path>", "Root directory to scan for HTML files", ".").option("--routes <routes...>", "Only collect specific routes (repeat or comma-separated)").option("--strict-routes", "Fail when any route passed via --routes is missing").option("--format <format>", "Output format (json)", "json").option("-o, --output <path>", "Write collected schema data to file").option("--check", "Compare collected output with an existing schema data file").option(
|
|
1575
|
+
"-d, --data <path>",
|
|
1576
|
+
"Path to existing schema data JSON for --check",
|
|
1577
|
+
"schema-sentry.data.json"
|
|
1578
|
+
).action(async (options) => {
|
|
1579
|
+
const start = Date.now();
|
|
1580
|
+
const format = resolveCollectOutputFormat(options.format);
|
|
1581
|
+
const rootDir = path9.resolve(process.cwd(), options.root ?? ".");
|
|
1582
|
+
const check = options.check ?? false;
|
|
1583
|
+
const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
|
|
1584
|
+
const strictRoutes = options.strictRoutes ?? false;
|
|
1585
|
+
let collected;
|
|
1605
1586
|
try {
|
|
1606
|
-
await
|
|
1607
|
-
await writeFile(resolvedPath, content, "utf8");
|
|
1608
|
-
console.error(`Report written to ${resolvedPath}`);
|
|
1587
|
+
collected = await collectSchemaData({ rootDir, routes: requestedRoutes });
|
|
1609
1588
|
} catch (error) {
|
|
1610
1589
|
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
1611
1590
|
printCliError(
|
|
1612
|
-
"
|
|
1613
|
-
`Could not
|
|
1591
|
+
"collect.scan_failed",
|
|
1592
|
+
`Could not scan HTML output at ${rootDir}: ${reason}`,
|
|
1593
|
+
"Point --root to a directory containing built HTML output."
|
|
1614
1594
|
);
|
|
1615
1595
|
process.exit(1);
|
|
1596
|
+
return;
|
|
1616
1597
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1598
|
+
if (collected.stats.htmlFiles === 0) {
|
|
1599
|
+
printCliError(
|
|
1600
|
+
"collect.no_html",
|
|
1601
|
+
`No HTML files found under ${rootDir}`,
|
|
1602
|
+
"Point --root to a static output directory (for example ./out)."
|
|
1603
|
+
);
|
|
1604
|
+
process.exit(1);
|
|
1620
1605
|
return;
|
|
1621
1606
|
}
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
} catch (error) {
|
|
1644
|
-
if (error instanceof ConfigError) {
|
|
1645
|
-
printCliError(error.code, error.message, error.suggestion);
|
|
1607
|
+
if (strictRoutes && collected.missingRoutes.length > 0) {
|
|
1608
|
+
printCliError(
|
|
1609
|
+
"collect.missing_required_routes",
|
|
1610
|
+
`Required routes were not found in collected HTML: ${collected.missingRoutes.join(", ")}`,
|
|
1611
|
+
"Rebuild output, adjust --root, or update --routes."
|
|
1612
|
+
);
|
|
1613
|
+
process.exit(1);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
let driftDetected = false;
|
|
1617
|
+
if (check) {
|
|
1618
|
+
const existingPath = path9.resolve(process.cwd(), options.data);
|
|
1619
|
+
let existingRaw;
|
|
1620
|
+
try {
|
|
1621
|
+
existingRaw = await readFile3(existingPath, "utf8");
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
printCliError(
|
|
1624
|
+
"data.not_found",
|
|
1625
|
+
`Schema data not found at ${existingPath}`,
|
|
1626
|
+
"Run `schemasentry collect --output ./schema-sentry.data.json` to generate it."
|
|
1627
|
+
);
|
|
1646
1628
|
process.exit(1);
|
|
1647
|
-
return
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
let existingData;
|
|
1632
|
+
try {
|
|
1633
|
+
existingData = JSON.parse(existingRaw);
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
printCliError(
|
|
1636
|
+
"data.invalid_json",
|
|
1637
|
+
"Schema data is not valid JSON",
|
|
1638
|
+
"Check the JSON syntax or regenerate with `schemasentry collect --output`."
|
|
1639
|
+
);
|
|
1640
|
+
process.exit(1);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
if (!isSchemaData(existingData)) {
|
|
1644
|
+
printCliError(
|
|
1645
|
+
"data.invalid_shape",
|
|
1646
|
+
"Schema data must contain a 'routes' object with array values",
|
|
1647
|
+
"Ensure each route maps to an array of JSON-LD blocks."
|
|
1648
|
+
);
|
|
1649
|
+
process.exit(1);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
const existingDataForCompare = requestedRoutes.length > 0 ? filterSchemaDataByRoutes(existingData, requestedRoutes) : existingData;
|
|
1653
|
+
const drift = compareSchemaData(existingDataForCompare, collected.data);
|
|
1654
|
+
driftDetected = drift.hasChanges;
|
|
1655
|
+
if (driftDetected) {
|
|
1656
|
+
console.error(formatSchemaDataDrift(drift));
|
|
1657
|
+
} else {
|
|
1658
|
+
console.error("collect | No schema data drift detected.");
|
|
1648
1659
|
}
|
|
1649
|
-
throw error;
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
function getRecommendedOverride(argv) {
|
|
1653
|
-
if (argv.includes("--recommended")) {
|
|
1654
|
-
return true;
|
|
1655
1660
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1661
|
+
const content = formatCollectOutput(collected.data, format);
|
|
1662
|
+
if (options.output) {
|
|
1663
|
+
const resolvedPath = path9.resolve(process.cwd(), options.output);
|
|
1664
|
+
try {
|
|
1665
|
+
await mkdir2(path9.dirname(resolvedPath), { recursive: true });
|
|
1666
|
+
await writeFile2(resolvedPath, `${content}
|
|
1667
|
+
`, "utf8");
|
|
1668
|
+
console.error(`Collected data written to ${resolvedPath}`);
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
1671
|
+
printCliError(
|
|
1672
|
+
"output.write_failed",
|
|
1673
|
+
`Could not write collected data to ${resolvedPath}: ${reason}`
|
|
1674
|
+
);
|
|
1675
|
+
process.exit(1);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
} else if (!check) {
|
|
1679
|
+
console.log(content);
|
|
1658
1680
|
}
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1681
|
+
printCollectWarnings(collected.warnings);
|
|
1682
|
+
printCollectSummary({
|
|
1683
|
+
stats: collected.stats,
|
|
1684
|
+
durationMs: Date.now() - start,
|
|
1685
|
+
checked: check,
|
|
1686
|
+
driftDetected,
|
|
1687
|
+
requestedRoutes: collected.requestedRoutes,
|
|
1688
|
+
missingRoutes: collected.missingRoutes,
|
|
1689
|
+
strictRoutes
|
|
1690
|
+
});
|
|
1691
|
+
process.exit(driftDetected ? 1 : 0);
|
|
1692
|
+
});
|
|
1693
|
+
function formatCollectOutput(data, format) {
|
|
1694
|
+
if (format === "json") {
|
|
1695
|
+
return stableStringify5(data);
|
|
1668
1696
|
}
|
|
1697
|
+
return stableStringify5(data);
|
|
1669
1698
|
}
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
coverage: report.summary.coverage
|
|
1677
|
-
})
|
|
1678
|
-
);
|
|
1679
|
-
}
|
|
1680
|
-
function printAuditSummary(report, coverageEnabled, durationMs) {
|
|
1681
|
-
console.error(
|
|
1682
|
-
formatSummaryLine("audit", {
|
|
1683
|
-
...report.summary,
|
|
1684
|
-
durationMs,
|
|
1685
|
-
coverage: report.summary.coverage
|
|
1686
|
-
})
|
|
1687
|
-
);
|
|
1688
|
-
if (!coverageEnabled) {
|
|
1689
|
-
console.error("Coverage checks skipped (no manifest provided).");
|
|
1699
|
+
function filterSchemaDataByRoutes(data, routes) {
|
|
1700
|
+
const filteredRoutes = {};
|
|
1701
|
+
for (const route of routes) {
|
|
1702
|
+
if (Object.prototype.hasOwnProperty.call(data.routes, route)) {
|
|
1703
|
+
filteredRoutes[route] = data.routes[route];
|
|
1704
|
+
}
|
|
1690
1705
|
}
|
|
1706
|
+
return {
|
|
1707
|
+
routes: filteredRoutes
|
|
1708
|
+
};
|
|
1691
1709
|
}
|
|
1692
1710
|
function printCollectWarnings(warnings) {
|
|
1693
1711
|
if (warnings.length === 0) {
|
|
@@ -1725,89 +1743,437 @@ function printCollectSummary(options) {
|
|
|
1725
1743
|
if (requestedRoutes.length > 0) {
|
|
1726
1744
|
parts.push(`Route filter: ${requestedRoutes.length}`);
|
|
1727
1745
|
}
|
|
1728
|
-
if (missingRoutes.length > 0) {
|
|
1729
|
-
parts.push(`Missing filtered routes: ${missingRoutes.length}`);
|
|
1746
|
+
if (missingRoutes.length > 0) {
|
|
1747
|
+
parts.push(`Missing filtered routes: ${missingRoutes.length}`);
|
|
1748
|
+
}
|
|
1749
|
+
if (strictRoutes) {
|
|
1750
|
+
parts.push("Strict routes: enabled");
|
|
1751
|
+
}
|
|
1752
|
+
console.error(`collect | ${parts.join(" | ")}`);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/commands/scaffold.ts
|
|
1756
|
+
import { Command as Command5 } from "commander";
|
|
1757
|
+
import path11 from "path";
|
|
1758
|
+
import chalk4 from "chalk";
|
|
1759
|
+
|
|
1760
|
+
// src/scaffold.ts
|
|
1761
|
+
import { promises as fs7 } from "fs";
|
|
1762
|
+
import path10 from "path";
|
|
1763
|
+
import chalk3 from "chalk";
|
|
1764
|
+
import { stableStringify as stableStringify6 } from "@schemasentry/core";
|
|
1765
|
+
|
|
1766
|
+
// src/patterns.ts
|
|
1767
|
+
var DEFAULT_PATTERNS = [
|
|
1768
|
+
{ pattern: "/blog/*", schemaType: "BlogPosting", priority: 10 },
|
|
1769
|
+
{ pattern: "/blog", schemaType: "WebPage", priority: 5 },
|
|
1770
|
+
{ pattern: "/products/*", schemaType: "Product", priority: 10 },
|
|
1771
|
+
{ pattern: "/product/*", schemaType: "Product", priority: 10 },
|
|
1772
|
+
{ pattern: "/faq", schemaType: "FAQPage", priority: 10 },
|
|
1773
|
+
{ pattern: "/faqs", schemaType: "FAQPage", priority: 10 },
|
|
1774
|
+
{ pattern: "/how-to/*", schemaType: "HowTo", priority: 10 },
|
|
1775
|
+
{ pattern: "/howto/*", schemaType: "HowTo", priority: 10 },
|
|
1776
|
+
{ pattern: "/events/*", schemaType: "Event", priority: 10 },
|
|
1777
|
+
{ pattern: "/event/*", schemaType: "Event", priority: 10 },
|
|
1778
|
+
{ pattern: "/reviews/*", schemaType: "Review", priority: 10 },
|
|
1779
|
+
{ pattern: "/review/*", schemaType: "Review", priority: 10 },
|
|
1780
|
+
{ pattern: "/videos/*", schemaType: "VideoObject", priority: 10 },
|
|
1781
|
+
{ pattern: "/video/*", schemaType: "VideoObject", priority: 10 },
|
|
1782
|
+
{ pattern: "/images/*", schemaType: "ImageObject", priority: 10 },
|
|
1783
|
+
{ pattern: "/image/*", schemaType: "ImageObject", priority: 10 },
|
|
1784
|
+
{ pattern: "/about", schemaType: "WebPage", priority: 10 },
|
|
1785
|
+
{ pattern: "/contact", schemaType: "WebPage", priority: 10 },
|
|
1786
|
+
{ pattern: "/", schemaType: "WebSite", priority: 1 }
|
|
1787
|
+
];
|
|
1788
|
+
var matchRouteToPatterns = (route, patterns = DEFAULT_PATTERNS) => {
|
|
1789
|
+
const matches = [];
|
|
1790
|
+
for (const rule of patterns) {
|
|
1791
|
+
if (routeMatchesPattern(route, rule.pattern)) {
|
|
1792
|
+
matches.push({
|
|
1793
|
+
type: rule.schemaType,
|
|
1794
|
+
priority: rule.priority ?? 5
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
matches.sort((a, b) => b.priority - a.priority);
|
|
1799
|
+
return [...new Set(matches.map((m) => m.type))];
|
|
1800
|
+
};
|
|
1801
|
+
var routeMatchesPattern = (route, pattern) => {
|
|
1802
|
+
if (pattern === route) {
|
|
1803
|
+
return true;
|
|
1804
|
+
}
|
|
1805
|
+
if (pattern.endsWith("/*")) {
|
|
1806
|
+
const prefix = pattern.slice(0, -1);
|
|
1807
|
+
return route.startsWith(prefix);
|
|
1808
|
+
}
|
|
1809
|
+
const patternRegex = pattern.replace(/\*/g, "[^/]+").replace(/\?/g, ".");
|
|
1810
|
+
const regex = new RegExp(`^${patternRegex}$`);
|
|
1811
|
+
return regex.test(route);
|
|
1812
|
+
};
|
|
1813
|
+
var inferSchemaTypes = (routes, customPatterns) => {
|
|
1814
|
+
const patterns = customPatterns ?? DEFAULT_PATTERNS;
|
|
1815
|
+
const result = /* @__PURE__ */ new Map();
|
|
1816
|
+
for (const route of routes) {
|
|
1817
|
+
const types = matchRouteToPatterns(route, patterns);
|
|
1818
|
+
if (types.length > 0) {
|
|
1819
|
+
result.set(route, types);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return result;
|
|
1823
|
+
};
|
|
1824
|
+
var generateManifestEntries = (routes, customPatterns) => {
|
|
1825
|
+
const inferred = inferSchemaTypes(routes, customPatterns);
|
|
1826
|
+
const entries = {};
|
|
1827
|
+
for (const [route, types] of inferred) {
|
|
1828
|
+
entries[route] = types;
|
|
1829
|
+
}
|
|
1830
|
+
return entries;
|
|
1831
|
+
};
|
|
1832
|
+
|
|
1833
|
+
// src/scaffold.ts
|
|
1834
|
+
var scaffoldSchema = async (options) => {
|
|
1835
|
+
const manifest = await loadManifest(options.manifestPath);
|
|
1836
|
+
const data = await loadData(options.dataPath);
|
|
1837
|
+
const discoveredRoutes = await scanRoutes({ rootDir: options.rootDir });
|
|
1838
|
+
const routesNeedingSchema = discoveredRoutes.filter(
|
|
1839
|
+
(route) => !data.routes[route] || data.routes[route].length === 0
|
|
1840
|
+
);
|
|
1841
|
+
const inferredTypes = inferSchemaTypes(routesNeedingSchema, options.customPatterns);
|
|
1842
|
+
const manifestEntries = generateManifestEntries(
|
|
1843
|
+
routesNeedingSchema,
|
|
1844
|
+
options.customPatterns
|
|
1845
|
+
);
|
|
1846
|
+
const generatedSchemas = /* @__PURE__ */ new Map();
|
|
1847
|
+
for (const [route, types] of inferredTypes) {
|
|
1848
|
+
const schemas = types.map((type) => generateSchemaStub(type, route));
|
|
1849
|
+
generatedSchemas.set(route, schemas);
|
|
1850
|
+
}
|
|
1851
|
+
const wouldUpdate = routesNeedingSchema.length > 0;
|
|
1852
|
+
return {
|
|
1853
|
+
routesToScaffold: routesNeedingSchema,
|
|
1854
|
+
generatedSchemas,
|
|
1855
|
+
manifestUpdates: manifestEntries,
|
|
1856
|
+
wouldUpdate
|
|
1857
|
+
};
|
|
1858
|
+
};
|
|
1859
|
+
var loadManifest = async (manifestPath) => {
|
|
1860
|
+
try {
|
|
1861
|
+
const raw = await fs7.readFile(manifestPath, "utf8");
|
|
1862
|
+
return JSON.parse(raw);
|
|
1863
|
+
} catch {
|
|
1864
|
+
return { routes: {} };
|
|
1730
1865
|
}
|
|
1731
|
-
|
|
1732
|
-
|
|
1866
|
+
};
|
|
1867
|
+
var loadData = async (dataPath) => {
|
|
1868
|
+
try {
|
|
1869
|
+
const raw = await fs7.readFile(dataPath, "utf8");
|
|
1870
|
+
return JSON.parse(raw);
|
|
1871
|
+
} catch {
|
|
1872
|
+
return { routes: {} };
|
|
1733
1873
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1874
|
+
};
|
|
1875
|
+
var generateSchemaStub = (type, route) => {
|
|
1876
|
+
const base = {
|
|
1877
|
+
"@context": "https://schema.org",
|
|
1878
|
+
"@type": type
|
|
1879
|
+
};
|
|
1880
|
+
switch (type) {
|
|
1881
|
+
case "BlogPosting":
|
|
1882
|
+
return {
|
|
1883
|
+
...base,
|
|
1884
|
+
headline: "Blog Post Title",
|
|
1885
|
+
author: {
|
|
1886
|
+
"@type": "Person",
|
|
1887
|
+
name: "Author Name"
|
|
1888
|
+
},
|
|
1889
|
+
datePublished: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
1890
|
+
url: route
|
|
1891
|
+
};
|
|
1892
|
+
case "Product":
|
|
1893
|
+
return {
|
|
1894
|
+
...base,
|
|
1895
|
+
name: "Product Name",
|
|
1896
|
+
description: "Product description",
|
|
1897
|
+
offers: {
|
|
1898
|
+
"@type": "Offer",
|
|
1899
|
+
price: "0.00",
|
|
1900
|
+
priceCurrency: "USD"
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
case "FAQPage":
|
|
1904
|
+
return {
|
|
1905
|
+
...base,
|
|
1906
|
+
mainEntity: []
|
|
1907
|
+
};
|
|
1908
|
+
case "HowTo":
|
|
1909
|
+
return {
|
|
1910
|
+
...base,
|
|
1911
|
+
name: "How-To Title",
|
|
1912
|
+
step: []
|
|
1913
|
+
};
|
|
1914
|
+
case "Event":
|
|
1915
|
+
return {
|
|
1916
|
+
...base,
|
|
1917
|
+
name: "Event Name",
|
|
1918
|
+
startDate: (/* @__PURE__ */ new Date()).toISOString()
|
|
1919
|
+
};
|
|
1920
|
+
case "Organization":
|
|
1921
|
+
return {
|
|
1922
|
+
...base,
|
|
1923
|
+
name: "Organization Name",
|
|
1924
|
+
url: route
|
|
1925
|
+
};
|
|
1926
|
+
case "WebSite":
|
|
1927
|
+
return {
|
|
1928
|
+
...base,
|
|
1929
|
+
name: "Website Name",
|
|
1930
|
+
url: route
|
|
1931
|
+
};
|
|
1932
|
+
case "Article":
|
|
1933
|
+
return {
|
|
1934
|
+
...base,
|
|
1935
|
+
headline: "Article Headline",
|
|
1936
|
+
author: {
|
|
1937
|
+
"@type": "Person",
|
|
1938
|
+
name: "Author Name"
|
|
1939
|
+
},
|
|
1940
|
+
datePublished: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
1941
|
+
};
|
|
1942
|
+
default:
|
|
1943
|
+
return {
|
|
1944
|
+
...base,
|
|
1945
|
+
name: `${type} Name`
|
|
1946
|
+
};
|
|
1742
1947
|
}
|
|
1948
|
+
};
|
|
1949
|
+
var generateComponentCode = (route, types, siteUrl = "https://yoursite.com") => {
|
|
1950
|
+
const filePath = routeToFilePath(route);
|
|
1951
|
+
const builders = [];
|
|
1952
|
+
const imports = /* @__PURE__ */ new Set(["Schema"]);
|
|
1953
|
+
for (const type of types) {
|
|
1954
|
+
imports.add(type);
|
|
1955
|
+
const builderCode = generateBuilderCode(type, route, siteUrl);
|
|
1956
|
+
builders.push(builderCode);
|
|
1957
|
+
}
|
|
1958
|
+
const code = `import { ${Array.from(imports).join(", ")} } from "@schemasentry/next";
|
|
1959
|
+
|
|
1960
|
+
${builders.join("\n\n")}
|
|
1961
|
+
|
|
1962
|
+
export default function Page() {
|
|
1963
|
+
return (
|
|
1964
|
+
<>
|
|
1965
|
+
<Schema data={[${types.join(", ").toLowerCase()}]} />
|
|
1966
|
+
{/* Your existing page content */}
|
|
1967
|
+
</>
|
|
1968
|
+
);
|
|
1969
|
+
}`;
|
|
1743
1970
|
return {
|
|
1744
|
-
|
|
1971
|
+
route,
|
|
1972
|
+
filePath,
|
|
1973
|
+
code,
|
|
1974
|
+
imports: Array.from(imports),
|
|
1975
|
+
builders
|
|
1745
1976
|
};
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
try {
|
|
1751
|
-
const siteName = await ask(rl, "Site name", defaults.siteName);
|
|
1752
|
-
const siteUrl = await ask(rl, "Base URL", defaults.siteUrl);
|
|
1753
|
-
const authorName = await ask(rl, "Primary author name", defaults.authorName);
|
|
1754
|
-
return { siteName, siteUrl, authorName };
|
|
1755
|
-
} finally {
|
|
1756
|
-
rl.close();
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
async function ask(rl, question, fallback) {
|
|
1760
|
-
const answer = (await rl.question(`${question} (${fallback}): `)).trim();
|
|
1761
|
-
return answer.length > 0 ? answer : fallback;
|
|
1762
|
-
}
|
|
1763
|
-
async function resolveOverwrites(options) {
|
|
1764
|
-
const { manifestPath, dataPath, force, interactive } = options;
|
|
1765
|
-
if (force) {
|
|
1766
|
-
return [true, true];
|
|
1977
|
+
};
|
|
1978
|
+
var routeToFilePath = (route) => {
|
|
1979
|
+
if (route === "/") {
|
|
1980
|
+
return "app/page.tsx";
|
|
1767
1981
|
}
|
|
1768
|
-
const
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1982
|
+
const segments = route.split("/").filter(Boolean);
|
|
1983
|
+
return `app/${segments.join("/")}/page.tsx`;
|
|
1984
|
+
};
|
|
1985
|
+
var generateBuilderCode = (type, route, siteUrl) => {
|
|
1986
|
+
const normalizedRoute = route === "/" ? "" : route;
|
|
1987
|
+
const fullUrl = `${siteUrl}${normalizedRoute}`;
|
|
1988
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
1989
|
+
const varName = type.charAt(0).toLowerCase() + type.slice(1);
|
|
1990
|
+
switch (type) {
|
|
1991
|
+
case "BlogPosting":
|
|
1992
|
+
return `const ${varName} = BlogPosting({
|
|
1993
|
+
headline: "Blog Post Title",
|
|
1994
|
+
authorName: "Author Name",
|
|
1995
|
+
datePublished: "${today}",
|
|
1996
|
+
url: "${fullUrl}"
|
|
1997
|
+
});`;
|
|
1998
|
+
case "Article":
|
|
1999
|
+
return `const ${varName} = Article({
|
|
2000
|
+
headline: "Article Headline",
|
|
2001
|
+
authorName: "Author Name",
|
|
2002
|
+
datePublished: "${today}",
|
|
2003
|
+
url: "${fullUrl}"
|
|
2004
|
+
});`;
|
|
2005
|
+
case "Product":
|
|
2006
|
+
return `const ${varName} = Product({
|
|
2007
|
+
name: "Product Name",
|
|
2008
|
+
description: "Product description",
|
|
2009
|
+
url: "${fullUrl}"
|
|
2010
|
+
});`;
|
|
2011
|
+
case "Organization":
|
|
2012
|
+
return `const ${varName} = Organization({
|
|
2013
|
+
name: "Organization Name",
|
|
2014
|
+
url: "${siteUrl}"
|
|
2015
|
+
});`;
|
|
2016
|
+
case "WebSite":
|
|
2017
|
+
return `const ${varName} = WebSite({
|
|
2018
|
+
name: "Website Name",
|
|
2019
|
+
url: "${siteUrl}"
|
|
2020
|
+
});`;
|
|
2021
|
+
case "WebPage":
|
|
2022
|
+
return `const ${varName} = WebPage({
|
|
2023
|
+
name: "Page Name",
|
|
2024
|
+
url: "${fullUrl}"
|
|
2025
|
+
});`;
|
|
2026
|
+
case "FAQPage":
|
|
2027
|
+
return `const ${varName} = FAQPage({
|
|
2028
|
+
questions: [
|
|
2029
|
+
{ question: "Question 1?", answer: "Answer 1" }
|
|
2030
|
+
]
|
|
2031
|
+
});`;
|
|
2032
|
+
case "HowTo":
|
|
2033
|
+
return `const ${varName} = HowTo({
|
|
2034
|
+
name: "How-To Title",
|
|
2035
|
+
steps: [
|
|
2036
|
+
{ text: "Step 1 description" }
|
|
2037
|
+
]
|
|
2038
|
+
});`;
|
|
2039
|
+
case "Event":
|
|
2040
|
+
return `const ${varName} = Event({
|
|
2041
|
+
name: "Event Name",
|
|
2042
|
+
startDate: "${today}"
|
|
2043
|
+
});`;
|
|
2044
|
+
default:
|
|
2045
|
+
return `const ${varName} = ${type}({
|
|
2046
|
+
name: "${type} Name"
|
|
2047
|
+
});`;
|
|
1772
2048
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
return [overwriteManifest, overwriteData];
|
|
1778
|
-
} finally {
|
|
1779
|
-
rl.close();
|
|
2049
|
+
};
|
|
2050
|
+
var formatScaffoldPreview = (result) => {
|
|
2051
|
+
if (result.routesToScaffold.length === 0) {
|
|
2052
|
+
return "\u2705 No routes need schema generation. All routes have schema!";
|
|
1780
2053
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2054
|
+
const lines = [
|
|
2055
|
+
`\u{1F4CB} Found ${result.routesToScaffold.length} route(s) that need schema:
|
|
2056
|
+
`
|
|
2057
|
+
];
|
|
2058
|
+
for (const route of result.routesToScaffold.slice(0, 5)) {
|
|
2059
|
+
const types = result.manifestUpdates[route] || [];
|
|
2060
|
+
const example = generateComponentCode(route, types);
|
|
2061
|
+
lines.push(chalk3.cyan.bold(`\u{1F4C4} ${route}`));
|
|
2062
|
+
lines.push(chalk3.gray(` File: ${example.filePath}`));
|
|
2063
|
+
lines.push(chalk3.gray(` Types: ${types.join(", ") || "WebPage"}`));
|
|
2064
|
+
lines.push("");
|
|
2065
|
+
lines.push(chalk3.yellow(" Add this code to your page:"));
|
|
2066
|
+
lines.push(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
2067
|
+
lines.push(example.code.split("\n").map((l) => " " + l).join("\n"));
|
|
2068
|
+
lines.push(chalk3.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
2069
|
+
}
|
|
2070
|
+
if (result.routesToScaffold.length > 5) {
|
|
2071
|
+
lines.push(chalk3.gray(`... and ${result.routesToScaffold.length - 5} more routes`));
|
|
2072
|
+
}
|
|
2073
|
+
lines.push(chalk3.blue("\n\u{1F4A1} Next steps:"));
|
|
2074
|
+
lines.push(" 1. Copy the code above into each route's page.tsx file");
|
|
2075
|
+
lines.push(" 2. Customize the values (titles, names, URLs)");
|
|
2076
|
+
lines.push(" 3. Run `next build` to build your app");
|
|
2077
|
+
lines.push(" 4. Run `schemasentry validate` to verify\n");
|
|
2078
|
+
lines.push(chalk3.gray(" Note: Use --write to also update the manifest and data JSON files"));
|
|
2079
|
+
return lines.join("\n");
|
|
2080
|
+
};
|
|
2081
|
+
var applyScaffold = async (result, options) => {
|
|
2082
|
+
if (!result.wouldUpdate) {
|
|
2083
|
+
return;
|
|
1787
2084
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
created.push(`${manifestPath} (${result.manifest})`);
|
|
2085
|
+
const manifest = await loadManifest(options.manifestPath);
|
|
2086
|
+
const data = await loadData(options.dataPath);
|
|
2087
|
+
for (const [route, types] of Object.entries(result.manifestUpdates)) {
|
|
2088
|
+
if (!manifest.routes[route]) {
|
|
2089
|
+
manifest.routes[route] = types;
|
|
2090
|
+
}
|
|
1795
2091
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
2092
|
+
for (const [route, schemas] of result.generatedSchemas) {
|
|
2093
|
+
if (!data.routes[route]) {
|
|
2094
|
+
data.routes[route] = schemas;
|
|
2095
|
+
}
|
|
1798
2096
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2097
|
+
await fs7.mkdir(path10.dirname(options.manifestPath), { recursive: true });
|
|
2098
|
+
await fs7.mkdir(path10.dirname(options.dataPath), { recursive: true });
|
|
2099
|
+
await fs7.writeFile(
|
|
2100
|
+
options.manifestPath,
|
|
2101
|
+
stableStringify6(manifest),
|
|
2102
|
+
"utf8"
|
|
2103
|
+
);
|
|
2104
|
+
await fs7.writeFile(options.dataPath, stableStringify6(data), "utf8");
|
|
2105
|
+
};
|
|
2106
|
+
|
|
2107
|
+
// src/commands/scaffold.ts
|
|
2108
|
+
var scaffoldCommand = new Command5("scaffold").description("Generate schema stubs for routes without schema (dry-run by default)").option(
|
|
2109
|
+
"-m, --manifest <path>",
|
|
2110
|
+
"Path to manifest JSON",
|
|
2111
|
+
"schema-sentry.manifest.json"
|
|
2112
|
+
).option(
|
|
2113
|
+
"-d, --data <path>",
|
|
2114
|
+
"Path to schema data JSON",
|
|
2115
|
+
"schema-sentry.data.json"
|
|
2116
|
+
).option("--root <path>", "Project root for scanning", ".").option("--write", "Apply changes (default is dry-run)").option("-f, --force", "Skip confirmation prompts").action(async (options) => {
|
|
2117
|
+
const start = Date.now();
|
|
2118
|
+
const manifestPath = path11.resolve(process.cwd(), options.manifest);
|
|
2119
|
+
const dataPath = path11.resolve(process.cwd(), options.data);
|
|
2120
|
+
const rootDir = path11.resolve(process.cwd(), options.root ?? ".");
|
|
2121
|
+
const dryRun = !(options.write ?? false);
|
|
2122
|
+
const force = options.force ?? false;
|
|
2123
|
+
console.error(chalk4.blue.bold("\u{1F3D7}\uFE0F Schema Sentry Scaffold\n"));
|
|
2124
|
+
console.error(chalk4.gray("Scanning for routes that need schema...\n"));
|
|
2125
|
+
const result = await scaffoldSchema({
|
|
2126
|
+
manifestPath,
|
|
2127
|
+
dataPath,
|
|
2128
|
+
rootDir,
|
|
2129
|
+
dryRun,
|
|
2130
|
+
force
|
|
2131
|
+
});
|
|
2132
|
+
console.error(formatScaffoldPreview(result));
|
|
2133
|
+
if (!result.wouldUpdate) {
|
|
2134
|
+
console.error(chalk4.green("\n\u2705 All routes have schema!"));
|
|
2135
|
+
process.exit(0);
|
|
2136
|
+
return;
|
|
1803
2137
|
}
|
|
1804
|
-
if (
|
|
1805
|
-
console.
|
|
2138
|
+
if (dryRun) {
|
|
2139
|
+
console.error(chalk4.yellow("\n\u26A0\uFE0F Dry run complete."));
|
|
2140
|
+
console.error("Use --write to update JSON files (does NOT modify source code).\n");
|
|
2141
|
+
process.exit(0);
|
|
2142
|
+
return;
|
|
1806
2143
|
}
|
|
1807
|
-
if (
|
|
1808
|
-
console.
|
|
2144
|
+
if (!force) {
|
|
2145
|
+
console.error(chalk4.yellow("\n\u26A0\uFE0F Scaffolding will update:"));
|
|
2146
|
+
console.error(` - ${manifestPath}`);
|
|
2147
|
+
console.error(` - ${dataPath}`);
|
|
2148
|
+
console.error("\nUse --force to skip this confirmation.");
|
|
1809
2149
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
2150
|
+
try {
|
|
2151
|
+
await applyScaffold(result, {
|
|
2152
|
+
manifestPath,
|
|
2153
|
+
dataPath,
|
|
2154
|
+
rootDir,
|
|
2155
|
+
dryRun,
|
|
2156
|
+
force
|
|
2157
|
+
});
|
|
2158
|
+
console.error(chalk4.green(`
|
|
2159
|
+
\u2713 Scaffold complete in ${Date.now() - start}ms`));
|
|
2160
|
+
console.error(chalk4.gray("\nRemember: You still need to add the Schema components to your source files!"));
|
|
2161
|
+
process.exit(0);
|
|
2162
|
+
} catch (error) {
|
|
2163
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2164
|
+
console.error(chalk4.red(`
|
|
2165
|
+
\u274C Failed to apply scaffold: ${message}`));
|
|
2166
|
+
console.error("Check file permissions or disk space.");
|
|
2167
|
+
process.exit(1);
|
|
1812
2168
|
}
|
|
1813
|
-
}
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
// src/index.ts
|
|
2172
|
+
var program = new Command6();
|
|
2173
|
+
program.name("schemasentry").description("Schema Sentry CLI - Type-safe JSON-LD structured data for Next.js").version(resolveCliVersion());
|
|
2174
|
+
program.addCommand(initCommand);
|
|
2175
|
+
program.addCommand(validateCommand);
|
|
2176
|
+
program.addCommand(auditCommand);
|
|
2177
|
+
program.addCommand(collectCommand);
|
|
2178
|
+
program.addCommand(scaffoldCommand);
|
|
2179
|
+
program.parse();
|