@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.
Files changed (3) hide show
  1. package/README.md +135 -45
  2. package/dist/index.js +1654 -1288
  3. 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
- import { mkdir, readFile, writeFile } from "fs/promises";
6
- import { readFileSync } from "fs";
7
- import path6 from "path";
8
- import { stableStringify as stableStringify4 } from "@schemasentry/core";
9
-
10
- // src/report.ts
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@@ -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 fs4 } from "fs";
659
- import path4 from "path";
660
- import { stableStringify as stableStringify2 } from "@schemasentry/core";
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 = path4.resolve(options.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 fs4.readFile(filePath, "utf8");
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) => stableStringify2(existingRoutes[route]) !== stableStringify2(collectedRoutes[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 fs4.readdir(rootDir, { withFileTypes: true });
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 = path4.join(rootDir, entry.name);
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 = path4.relative(rootDir, filePath).replace(/\\/g, "/");
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/scaffold.ts
881
- import { promises as fs5 } from "fs";
882
- import path5 from "path";
883
- import { stableStringify as stableStringify3 } from "@schemasentry/core";
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/patterns.ts
886
- var DEFAULT_PATTERNS = [
887
- { pattern: "/blog/*", schemaType: "BlogPosting", priority: 10 },
888
- { pattern: "/blog", schemaType: "WebPage", priority: 5 },
889
- { pattern: "/products/*", schemaType: "Product", priority: 10 },
890
- { pattern: "/product/*", schemaType: "Product", priority: 10 },
891
- { pattern: "/faq", schemaType: "FAQPage", priority: 10 },
892
- { pattern: "/faqs", schemaType: "FAQPage", priority: 10 },
893
- { pattern: "/how-to/*", schemaType: "HowTo", priority: 10 },
894
- { pattern: "/howto/*", schemaType: "HowTo", priority: 10 },
895
- { pattern: "/events/*", schemaType: "Event", priority: 10 },
896
- { pattern: "/event/*", schemaType: "Event", priority: 10 },
897
- { pattern: "/reviews/*", schemaType: "Review", priority: 10 },
898
- { pattern: "/review/*", schemaType: "Review", priority: 10 },
899
- { pattern: "/videos/*", schemaType: "VideoObject", priority: 10 },
900
- { pattern: "/video/*", schemaType: "VideoObject", priority: 10 },
901
- { pattern: "/images/*", schemaType: "ImageObject", priority: 10 },
902
- { pattern: "/image/*", schemaType: "ImageObject", priority: 10 },
903
- { pattern: "/about", schemaType: "WebPage", priority: 10 },
904
- { pattern: "/contact", schemaType: "WebPage", priority: 10 },
905
- { pattern: "/", schemaType: "WebSite", priority: 1 }
906
- ];
907
- var matchRouteToPatterns = (route, patterns = DEFAULT_PATTERNS) => {
908
- const matches = [];
909
- for (const rule of patterns) {
910
- if (routeMatchesPattern(route, rule.pattern)) {
911
- matches.push({
912
- type: rule.schemaType,
913
- priority: rule.priority ?? 5
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
- matches.sort((a, b) => b.priority - a.priority);
918
- return [...new Set(matches.map((m) => m.type))];
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
- var routeMatchesPattern = (route, pattern) => {
921
- if (pattern === route) {
922
- return true;
923
- }
924
- if (pattern.endsWith("/*")) {
925
- const prefix = pattern.slice(0, -1);
926
- return route.startsWith(prefix);
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 inferSchemaTypes = (routes, customPatterns) => {
933
- const patterns = customPatterns ?? DEFAULT_PATTERNS;
934
- const result = /* @__PURE__ */ new Map();
935
- for (const route of routes) {
936
- const types = matchRouteToPatterns(route, patterns);
937
- if (types.length > 0) {
938
- result.set(route, types);
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
- return result;
942
- };
943
- var generateManifestEntries = (routes, customPatterns) => {
944
- const inferred = inferSchemaTypes(routes, customPatterns);
945
- const entries = {};
946
- for (const [route, types] of inferred) {
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 entries;
632
+ return parsed;
950
633
  };
951
-
952
- // src/scaffold.ts
953
- var scaffoldSchema = async (options) => {
954
- const manifest = await loadManifest(options.manifestPath);
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 wouldUpdate = routesNeedingSchema.length > 0;
971
- return {
972
- routesToScaffold: routesNeedingSchema,
973
- generatedSchemas,
974
- manifestUpdates: manifestEntries,
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 loadManifest = async (manifestPath) => {
645
+ var fileExists = async (filePath) => {
979
646
  try {
980
- const raw = await fs5.readFile(manifestPath, "utf8");
981
- return JSON.parse(raw);
647
+ await fs3.access(filePath);
648
+ return true;
982
649
  } catch {
983
- return { routes: {} };
650
+ return false;
984
651
  }
985
652
  };
986
- var loadData = async (dataPath) => {
653
+
654
+ // src/commands/utils.ts
655
+ function resolveCliVersion() {
987
656
  try {
988
- const raw = await fs5.readFile(dataPath, "utf8");
989
- return JSON.parse(raw);
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 { routes: {} };
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
- var formatScaffoldPreview = (result) => {
1069
- if (result.routesToScaffold.length === 0) {
1070
- return "No routes need schema generation.";
663
+ }
664
+ function resolveOutputFormat(value) {
665
+ const format = (value ?? "json").trim().toLowerCase();
666
+ if (format === "json" || format === "html") {
667
+ return format;
1071
668
  }
1072
- const lines = [
1073
- `Routes to scaffold: ${result.routesToScaffold.length}`,
1074
- ""
1075
- ];
1076
- for (const route of result.routesToScaffold) {
1077
- const types = result.manifestUpdates[route] || [];
1078
- lines.push(` ${route}`);
1079
- lines.push(` Schema types: ${types.join(", ") || "None detected"}`);
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
- return lines.join("\n");
1082
- };
1083
- var applyScaffold = async (result, options) => {
1084
- if (!result.wouldUpdate) {
1085
- return;
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
- const manifest = await loadManifest(options.manifestPath);
1088
- const data = await loadData(options.dataPath);
1089
- for (const [route, types] of Object.entries(result.manifestUpdates)) {
1090
- if (!manifest.routes[route]) {
1091
- manifest.routes[route] = types;
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
- for (const [route, schemas] of result.generatedSchemas) {
1095
- if (!data.routes[route]) {
1096
- data.routes[route] = schemas;
1097
- }
716
+ }
717
+ function getRecommendedOverride(argv) {
718
+ if (argv.includes("--recommended")) {
719
+ return true;
1098
720
  }
1099
- await fs5.mkdir(path5.dirname(options.manifestPath), { recursive: true });
1100
- await fs5.mkdir(path5.dirname(options.dataPath), { recursive: true });
1101
- await fs5.writeFile(
1102
- options.manifestPath,
1103
- stableStringify3(manifest),
1104
- "utf8"
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
- await fs5.writeFile(options.dataPath, stableStringify3(data), "utf8");
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/index.ts
1110
- import { createInterface } from "readline/promises";
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
- "-d, --data <path>",
1120
- "Path to schema data JSON",
1121
- "schema-sentry.data.json"
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 = path6.resolve(process.cwd(), options.manifest);
1128
- const dataPath = path6.resolve(process.cwd(), options.data);
1129
- let raw;
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
- let manifest;
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
- manifest = JSON.parse(raw);
1144
- } catch (error) {
817
+ await access(builtOutputDir);
818
+ } catch {
1145
819
  printCliError(
1146
- "manifest.invalid_json",
1147
- "Manifest is not valid JSON",
1148
- "Check the JSON syntax or regenerate with `schemasentry init`."
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
- if (!isManifest(manifest)) {
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
- "manifest.invalid_shape",
1156
- "Manifest must contain a 'routes' object with string array values",
1157
- "Ensure each route maps to an array of schema type names."
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
- let dataRaw;
1163
- try {
1164
- dataRaw = await readFile(dataPath, "utf8");
1165
- } catch (error) {
1166
- printCliError(
1167
- "data.not_found",
1168
- `Schema data not found at ${dataPath}`,
1169
- "Run `schemasentry init` to generate starter files."
1170
- );
1171
- process.exit(1);
1172
- return;
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
- let data;
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
- data = JSON.parse(dataRaw);
1177
- } catch (error) {
1178
- printCliError(
1179
- "data.invalid_json",
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
- if (!isSchemaData(data)) {
1187
- printCliError(
1188
- "data.invalid_shape",
1189
- "Schema data must contain a 'routes' object with array values",
1190
- "Ensure each route maps to an array of JSON-LD blocks."
1191
- );
1192
- process.exit(1);
1193
- return;
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
- const report = buildReport(manifest, data, { recommended });
1196
- await emitReport({
1197
- report,
1198
- format,
1199
- outputPath: options.output,
1200
- title: "Schema Sentry Validate Report"
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 = path6.resolve(process.cwd(), options.manifest);
1216
- const dataPath = path6.resolve(process.cwd(), options.data);
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: path6.resolve(process.cwd(), options.root ?? ".") }) : [];
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
- program.command("audit").description("Analyze schema health and report issues").option(
1241
- "-d, --data <path>",
1242
- "Path to schema data JSON",
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
- dataRaw = await readFile(dataPath, "utf8");
1253
- } catch (error) {
1254
- printCliError(
1255
- "data.not_found",
1256
- `Schema data not found at ${dataPath}`,
1257
- "Run `schemasentry init` to generate starter files."
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
- let data;
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
- data = JSON.parse(dataRaw);
1265
- } catch (error) {
1266
- printCliError(
1267
- "data.invalid_json",
1268
- "Schema data is not valid JSON",
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
- if (!isSchemaData(data)) {
1275
- printCliError(
1276
- "data.invalid_shape",
1277
- "Schema data must contain a 'routes' object with array values",
1278
- "Ensure each route maps to an array of JSON-LD blocks."
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
- let manifest;
1284
- if (options.manifest) {
1285
- const manifestPath = path6.resolve(process.cwd(), options.manifest);
1286
- let manifestRaw;
1287
- try {
1288
- manifestRaw = await readFile(manifestPath, "utf8");
1289
- } catch (error) {
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
- const requiredRoutes = options.scan ? await scanRoutes({ rootDir: path6.resolve(process.cwd(), options.root ?? ".") }) : [];
1320
- if (options.scan && requiredRoutes.length === 0) {
1321
- console.error("No routes found during scan.");
1280
+ if (result.data !== "skipped") {
1281
+ created.push(`${dataPath} (${result.data})`);
1322
1282
  }
1323
- const report = buildAuditReport(data, {
1324
- recommended,
1325
- manifest,
1326
- requiredRoutes: requiredRoutes.length > 0 ? requiredRoutes : void 0
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 (collected.stats.htmlFiles === 0) {
1363
- printCliError(
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 (strictRoutes && collected.missingRoutes.length > 0) {
1372
- printCliError(
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
- let driftDetected = false;
1381
- if (check) {
1382
- const existingPath = path6.resolve(process.cwd(), options.data);
1383
- let existingRaw;
1384
- try {
1385
- existingRaw = await readFile(existingPath, "utf8");
1386
- } catch (error) {
1387
- printCliError(
1388
- "data.not_found",
1389
- `Schema data not found at ${existingPath}`,
1390
- "Run `schemasentry collect --output ./schema-sentry.data.json` to generate it."
1391
- );
1392
- process.exit(1);
1393
- return;
1394
- }
1395
- let existingData;
1396
- try {
1397
- existingData = JSON.parse(existingRaw);
1398
- } catch (error) {
1399
- printCliError(
1400
- "data.invalid_json",
1401
- "Schema data is not valid JSON",
1402
- "Check the JSON syntax or regenerate with `schemasentry collect --output`."
1403
- );
1404
- process.exit(1);
1405
- return;
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
- if (!isSchemaData(existingData)) {
1408
- printCliError(
1409
- "data.invalid_shape",
1410
- "Schema data must contain a 'routes' object with array values",
1411
- "Ensure each route maps to an array of JSON-LD blocks."
1412
- );
1413
- process.exit(1);
1414
- return;
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
- const existingDataForCompare = requestedRoutes.length > 0 ? filterSchemaDataByRoutes(existingData, requestedRoutes) : existingData;
1417
- const drift = compareSchemaData(existingDataForCompare, collected.data);
1418
- driftDetected = drift.hasChanges;
1419
- if (driftDetected) {
1420
- console.error(formatSchemaDataDrift(drift));
1421
- } else {
1422
- console.error("collect | No schema data drift detected.");
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
- const content = formatCollectOutput(collected.data, format);
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
- printCollectWarnings(collected.warnings);
1446
- printCollectSummary({
1447
- stats: collected.stats,
1448
- durationMs: Date.now() - start,
1449
- checked: check,
1450
- driftDetected,
1451
- requestedRoutes: collected.requestedRoutes,
1452
- missingRoutes: collected.missingRoutes,
1453
- strictRoutes
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
- process.exit(driftDetected ? 1 : 0);
1456
- });
1457
- program.command("scaffold").description("Generate schema stubs for routes without schema (dry-run by default)").option(
1458
- "-m, --manifest <path>",
1459
- "Path to manifest JSON",
1460
- "schema-sentry.manifest.json"
1461
- ).option(
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("--write", "Apply changes (default is dry-run)").option("-f, --force", "Skip confirmation prompts").action(async (options) => {
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 manifestPath = path6.resolve(process.cwd(), options.manifest);
1468
- const dataPath = path6.resolve(process.cwd(), options.data);
1469
- const rootDir = path6.resolve(process.cwd(), options.root ?? ".");
1470
- const dryRun = !(options.write ?? false);
1471
- const force = options.force ?? false;
1472
- const result = await scaffoldSchema({
1473
- manifestPath,
1474
- dataPath,
1475
- rootDir,
1476
- dryRun,
1477
- force
1478
- });
1479
- console.error(formatScaffoldPreview(result));
1480
- if (!result.wouldUpdate) {
1481
- process.exit(0);
1482
- return;
1483
- }
1484
- if (dryRun) {
1485
- console.error("\nDry run complete. Use --write to apply changes.");
1486
- process.exit(0);
1487
- return;
1488
- }
1489
- if (!force) {
1490
- console.error("\nScaffolding will update:");
1491
- console.error(` - ${manifestPath}`);
1492
- console.error(` - ${dataPath}`);
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 applyScaffold(result, {
1497
- manifestPath,
1498
- dataPath,
1499
- rootDir,
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
- return true;
1530
- }
1531
- function isSchemaData(value) {
1532
- if (!value || typeof value !== "object") {
1533
- return false;
1534
- }
1535
- const data = value;
1536
- if (!data.routes || typeof data.routes !== "object") {
1537
- return false;
1538
- }
1539
- for (const entry of Object.values(data.routes)) {
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
- return true;
1545
- }
1546
- function resolveOutputFormat(value) {
1547
- const format = (value ?? "json").trim().toLowerCase();
1548
- if (format === "json" || format === "html") {
1549
- return format;
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
- printCliError(
1552
- "output.invalid_format",
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
- printCliError(
1565
- "annotations.invalid_provider",
1566
- `Unsupported annotations provider '${value ?? ""}'`,
1567
- "Use --annotations none or --annotations github."
1568
- );
1569
- process.exit(1);
1570
- return "none";
1571
- }
1572
- function resolveCollectOutputFormat(value) {
1573
- const format = (value ?? "json").trim().toLowerCase();
1574
- if (format === "json") {
1575
- return format;
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
- printCliError(
1578
- "output.invalid_format",
1579
- `Unsupported collect output format '${value ?? ""}'`,
1580
- "Use --format json."
1581
- );
1582
- process.exit(1);
1583
- return "json";
1584
- }
1585
- function formatReportOutput(report, format, title) {
1586
- if (format === "html") {
1587
- return renderHtmlReport(report, { title });
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
- return stableStringify4(report);
1590
- }
1591
- function formatCollectOutput(data, format) {
1592
- if (format === "json") {
1593
- return stableStringify4(data);
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
- return stableStringify4(data);
1566
+ console.error("");
1596
1567
  }
1597
- async function emitReport(options) {
1598
- const { report, format, outputPath, title } = options;
1599
- const content = formatReportOutput(report, format, title);
1600
- if (!outputPath) {
1601
- console.log(content);
1602
- return;
1603
- }
1604
- const resolvedPath = path6.resolve(process.cwd(), outputPath);
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 mkdir(path6.dirname(resolvedPath), { recursive: true });
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
- "output.write_failed",
1613
- `Could not write report to ${resolvedPath}: ${reason}`
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
- function emitAnnotations(report, mode, commandLabel) {
1619
- if (mode !== "github") {
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
- emitGitHubAnnotations(report, commandLabel);
1623
- }
1624
- function printCliError(code, message, suggestion) {
1625
- console.error(
1626
- stableStringify4({
1627
- ok: false,
1628
- errors: [
1629
- {
1630
- code,
1631
- message,
1632
- ...suggestion !== void 0 ? { suggestion } : {}
1633
- }
1634
- ]
1635
- })
1636
- );
1637
- }
1638
- async function resolveRecommendedOption(configPath) {
1639
- const override = getRecommendedOverride(process.argv);
1640
- try {
1641
- const config = await loadConfig({ configPath });
1642
- return resolveRecommended(override, config);
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 true;
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
- if (argv.includes("--no-recommended")) {
1657
- return false;
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
- return void 0;
1660
- }
1661
- function resolveCliVersion() {
1662
- try {
1663
- const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
1664
- const parsed = JSON.parse(raw);
1665
- return parsed.version ?? "0.0.0";
1666
- } catch {
1667
- return "0.0.0";
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
- program.parse();
1671
- function printValidateSummary(report, durationMs) {
1672
- console.error(
1673
- formatSummaryLine("validate", {
1674
- ...report.summary,
1675
- durationMs,
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
- if (strictRoutes) {
1732
- parts.push("Strict routes: enabled");
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
- console.error(`collect | ${parts.join(" | ")}`);
1735
- }
1736
- function filterSchemaDataByRoutes(data, routes) {
1737
- const filteredRoutes = {};
1738
- for (const route of routes) {
1739
- if (Object.prototype.hasOwnProperty.call(data.routes, route)) {
1740
- filteredRoutes[route] = data.routes[route];
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
- routes: filteredRoutes
1971
+ route,
1972
+ filePath,
1973
+ code,
1974
+ imports: Array.from(imports),
1975
+ builders
1745
1976
  };
1746
- }
1747
- async function promptAnswers() {
1748
- const defaults = getDefaultAnswers();
1749
- const rl = createInterface({ input, output });
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 manifestExists = await fileExists(manifestPath);
1769
- const dataExists = await fileExists(dataPath);
1770
- if (!interactive) {
1771
- return [false, false];
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
- const rl = createInterface({ input, output });
1774
- try {
1775
- const overwriteManifest = manifestExists ? await confirm(rl, `Manifest exists at ${manifestPath}. Overwrite?`, false) : false;
1776
- const overwriteData = dataExists ? await confirm(rl, `Data file exists at ${dataPath}. Overwrite?`, false) : false;
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
- async function confirm(rl, question, defaultValue) {
1783
- const hint = defaultValue ? "Y/n" : "y/N";
1784
- const answer = (await rl.question(`${question} (${hint}): `)).trim().toLowerCase();
1785
- if (!answer) {
1786
- return defaultValue;
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
- return answer === "y" || answer === "yes";
1789
- }
1790
- function printInitSummary(options) {
1791
- const { manifestPath, dataPath, result } = options;
1792
- const created = [];
1793
- if (result.manifest !== "skipped") {
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
- if (result.data !== "skipped") {
1797
- created.push(`${dataPath} (${result.data})`);
2092
+ for (const [route, schemas] of result.generatedSchemas) {
2093
+ if (!data.routes[route]) {
2094
+ data.routes[route] = schemas;
2095
+ }
1798
2096
  }
1799
- if (created.length > 0) {
1800
- console.log("Schema Sentry init complete.");
1801
- console.log(`Created ${created.length} file(s):`);
1802
- created.forEach((entry) => console.log(`- ${entry}`));
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 (result.manifest === "skipped" || result.data === "skipped") {
1805
- console.log("Some files were skipped. Use --force to overwrite.");
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 (result.manifest === "skipped" && result.data === "skipped") {
1808
- console.log("No files were written.");
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
- if (created.length > 0) {
1811
- console.log("Next: run `schemasentry validate` to verify your setup.");
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();