@schemasentry/cli 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/index.js +430 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -56,6 +56,31 @@ pnpm schemasentry audit \
|
|
|
56
56
|
--output ./report.html
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
### `collect`
|
|
60
|
+
|
|
61
|
+
Collect JSON-LD blocks from built HTML output and emit schema data JSON:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm schemasentry collect --root ./out --output ./schema-sentry.data.json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Check collected output against your current data file (CI drift guard):
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm schemasentry collect --root ./out --check --data ./schema-sentry.data.json
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Collect and compare only selected routes, failing if any required route is missing:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pnpm schemasentry collect \
|
|
77
|
+
--root ./out \
|
|
78
|
+
--routes / /blog /faq \
|
|
79
|
+
--strict-routes \
|
|
80
|
+
--check \
|
|
81
|
+
--data ./schema-sentry.data.json
|
|
82
|
+
```
|
|
83
|
+
|
|
59
84
|
## Options
|
|
60
85
|
|
|
61
86
|
| Option | Description |
|
|
@@ -63,6 +88,10 @@ pnpm schemasentry audit \
|
|
|
63
88
|
| `--format json\|html` | Output format |
|
|
64
89
|
| `--annotations none\|github` | CI annotations |
|
|
65
90
|
| `-o, --output <path>` | Write output to file |
|
|
91
|
+
| `--root <path>` | Root directory to scan for HTML output (`collect`) |
|
|
92
|
+
| `--routes <routes...>` | Collect only specific routes (`collect`) |
|
|
93
|
+
| `--strict-routes` | Fail when any route passed to `--routes` is missing (`collect`) |
|
|
94
|
+
| `--check` | Compare collected output with existing data and fail on drift (`collect`) |
|
|
66
95
|
| `--recommended / --no-recommended` | Enable recommended field checks |
|
|
67
96
|
|
|
68
97
|
## Documentation
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
import { Command } from "commander";
|
|
5
5
|
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
6
6
|
import { readFileSync } from "fs";
|
|
7
|
-
import
|
|
8
|
-
import { stableStringify as
|
|
7
|
+
import path5 from "path";
|
|
8
|
+
import { stableStringify as stableStringify3 } from "@schemasentry/core";
|
|
9
9
|
|
|
10
10
|
// src/report.ts
|
|
11
11
|
import {
|
|
@@ -654,6 +654,229 @@ var emitGitHubAnnotations = (report, commandLabel) => {
|
|
|
654
654
|
}
|
|
655
655
|
};
|
|
656
656
|
|
|
657
|
+
// src/collect.ts
|
|
658
|
+
import { promises as fs4 } from "fs";
|
|
659
|
+
import path4 from "path";
|
|
660
|
+
import { stableStringify as stableStringify2 } from "@schemasentry/core";
|
|
661
|
+
var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([".git", "node_modules", ".pnpm-store"]);
|
|
662
|
+
var SCRIPT_TAG_REGEX = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
663
|
+
var JSON_LD_TYPE_REGEX = /\btype\s*=\s*(?:"application\/ld\+json"|'application\/ld\+json'|application\/ld\+json)/i;
|
|
664
|
+
var collectSchemaData = async (options) => {
|
|
665
|
+
const rootDir = path4.resolve(options.rootDir);
|
|
666
|
+
const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
|
|
667
|
+
const htmlFiles = (await walkHtmlFiles(rootDir)).sort((a, b) => a.localeCompare(b));
|
|
668
|
+
const routes = {};
|
|
669
|
+
const warnings = [];
|
|
670
|
+
let blockCount = 0;
|
|
671
|
+
let invalidBlocks = 0;
|
|
672
|
+
for (const filePath of htmlFiles) {
|
|
673
|
+
const route = filePathToRoute(rootDir, filePath);
|
|
674
|
+
if (!route) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const html = await fs4.readFile(filePath, "utf8");
|
|
678
|
+
const extracted = extractSchemaNodes(html, filePath);
|
|
679
|
+
if (extracted.nodes.length > 0) {
|
|
680
|
+
routes[route] = [...routes[route] ?? [], ...extracted.nodes];
|
|
681
|
+
blockCount += extracted.nodes.length;
|
|
682
|
+
}
|
|
683
|
+
invalidBlocks += extracted.invalidBlocks;
|
|
684
|
+
warnings.push(...extracted.warnings);
|
|
685
|
+
}
|
|
686
|
+
const missingRoutes = [];
|
|
687
|
+
const filteredRoutes = requestedRoutes.length > 0 ? filterRoutesByAllowlist(routes, requestedRoutes) : routes;
|
|
688
|
+
if (requestedRoutes.length > 0) {
|
|
689
|
+
for (const route of requestedRoutes) {
|
|
690
|
+
if (!Object.prototype.hasOwnProperty.call(filteredRoutes, route)) {
|
|
691
|
+
missingRoutes.push(route);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const filteredBlockCount = Object.values(filteredRoutes).reduce(
|
|
696
|
+
(total, nodes) => total + nodes.length,
|
|
697
|
+
0
|
|
698
|
+
);
|
|
699
|
+
return {
|
|
700
|
+
data: {
|
|
701
|
+
routes: sortRoutes(filteredRoutes)
|
|
702
|
+
},
|
|
703
|
+
stats: {
|
|
704
|
+
htmlFiles: htmlFiles.length,
|
|
705
|
+
routes: Object.keys(filteredRoutes).length,
|
|
706
|
+
blocks: filteredBlockCount,
|
|
707
|
+
invalidBlocks
|
|
708
|
+
},
|
|
709
|
+
warnings,
|
|
710
|
+
requestedRoutes,
|
|
711
|
+
missingRoutes
|
|
712
|
+
};
|
|
713
|
+
};
|
|
714
|
+
var compareSchemaData = (existing, collected) => {
|
|
715
|
+
const existingRoutes = existing.routes ?? {};
|
|
716
|
+
const collectedRoutes = collected.routes ?? {};
|
|
717
|
+
const existingKeys = Object.keys(existingRoutes);
|
|
718
|
+
const collectedKeys = Object.keys(collectedRoutes);
|
|
719
|
+
const addedRoutes = collectedKeys.filter((route) => !Object.prototype.hasOwnProperty.call(existingRoutes, route)).sort();
|
|
720
|
+
const removedRoutes = existingKeys.filter((route) => !Object.prototype.hasOwnProperty.call(collectedRoutes, route)).sort();
|
|
721
|
+
const changedRoutes = existingKeys.filter((route) => Object.prototype.hasOwnProperty.call(collectedRoutes, route)).filter(
|
|
722
|
+
(route) => stableStringify2(existingRoutes[route]) !== stableStringify2(collectedRoutes[route])
|
|
723
|
+
).sort();
|
|
724
|
+
const changedRouteDetails = changedRoutes.map(
|
|
725
|
+
(route) => buildRouteDriftDetail(route, existingRoutes[route] ?? [], collectedRoutes[route] ?? [])
|
|
726
|
+
);
|
|
727
|
+
return {
|
|
728
|
+
hasChanges: addedRoutes.length > 0 || removedRoutes.length > 0 || changedRoutes.length > 0,
|
|
729
|
+
addedRoutes,
|
|
730
|
+
removedRoutes,
|
|
731
|
+
changedRoutes,
|
|
732
|
+
changedRouteDetails
|
|
733
|
+
};
|
|
734
|
+
};
|
|
735
|
+
var formatSchemaDataDrift = (drift, maxRoutes = 5) => {
|
|
736
|
+
if (!drift.hasChanges) {
|
|
737
|
+
return "No schema data drift detected.";
|
|
738
|
+
}
|
|
739
|
+
const lines = [
|
|
740
|
+
`Schema data drift detected: added_routes=${drift.addedRoutes.length} removed_routes=${drift.removedRoutes.length} changed_routes=${drift.changedRoutes.length}`
|
|
741
|
+
];
|
|
742
|
+
if (drift.addedRoutes.length > 0) {
|
|
743
|
+
lines.push(formatRoutePreview("Added routes", drift.addedRoutes, maxRoutes));
|
|
744
|
+
}
|
|
745
|
+
if (drift.removedRoutes.length > 0) {
|
|
746
|
+
lines.push(formatRoutePreview("Removed routes", drift.removedRoutes, maxRoutes));
|
|
747
|
+
}
|
|
748
|
+
if (drift.changedRoutes.length > 0) {
|
|
749
|
+
lines.push(formatRoutePreview("Changed routes", drift.changedRoutes, maxRoutes));
|
|
750
|
+
const details = drift.changedRouteDetails.slice(0, maxRoutes).map((detail) => formatRouteDriftDetail(detail));
|
|
751
|
+
if (details.length > 0) {
|
|
752
|
+
lines.push("Changed route details:");
|
|
753
|
+
for (const detail of details) {
|
|
754
|
+
lines.push(`- ${detail}`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return lines.join("\n");
|
|
759
|
+
};
|
|
760
|
+
var formatRoutePreview = (label, routes, maxRoutes) => {
|
|
761
|
+
const preview = routes.slice(0, maxRoutes);
|
|
762
|
+
const suffix = routes.length > maxRoutes ? ` (+${routes.length - maxRoutes} more)` : "";
|
|
763
|
+
return `${label}: ${preview.join(", ")}${suffix}`;
|
|
764
|
+
};
|
|
765
|
+
var formatRouteDriftDetail = (detail) => {
|
|
766
|
+
const added = detail.addedTypes.length > 0 ? detail.addedTypes.join(",") : "(none)";
|
|
767
|
+
const removed = detail.removedTypes.length > 0 ? detail.removedTypes.join(",") : "(none)";
|
|
768
|
+
return `${detail.route} blocks ${detail.beforeBlocks}->${detail.afterBlocks} | +types ${added} | -types ${removed}`;
|
|
769
|
+
};
|
|
770
|
+
var sortRoutes = (routes) => Object.fromEntries(
|
|
771
|
+
Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))
|
|
772
|
+
);
|
|
773
|
+
var filterRoutesByAllowlist = (routes, allowlist) => {
|
|
774
|
+
const filtered = {};
|
|
775
|
+
for (const route of allowlist) {
|
|
776
|
+
if (Object.prototype.hasOwnProperty.call(routes, route)) {
|
|
777
|
+
filtered[route] = routes[route];
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return filtered;
|
|
781
|
+
};
|
|
782
|
+
var normalizeRouteFilter = (input2) => {
|
|
783
|
+
const normalized = input2.flatMap((entry) => entry.split(",")).map((route) => route.trim()).filter((route) => route.length > 0);
|
|
784
|
+
return Array.from(new Set(normalized)).sort();
|
|
785
|
+
};
|
|
786
|
+
var walkHtmlFiles = async (rootDir) => {
|
|
787
|
+
const entries = await fs4.readdir(rootDir, { withFileTypes: true });
|
|
788
|
+
const files = [];
|
|
789
|
+
for (const entry of entries) {
|
|
790
|
+
if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const resolved = path4.join(rootDir, entry.name);
|
|
794
|
+
if (entry.isDirectory()) {
|
|
795
|
+
files.push(...await walkHtmlFiles(resolved));
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (entry.isFile() && entry.name.endsWith(".html")) {
|
|
799
|
+
files.push(resolved);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return files;
|
|
803
|
+
};
|
|
804
|
+
var filePathToRoute = (rootDir, filePath) => {
|
|
805
|
+
const relative = path4.relative(rootDir, filePath).replace(/\\/g, "/");
|
|
806
|
+
if (relative === "index.html") {
|
|
807
|
+
return "/";
|
|
808
|
+
}
|
|
809
|
+
if (relative.endsWith("/index.html")) {
|
|
810
|
+
return `/${relative.slice(0, -"/index.html".length)}`;
|
|
811
|
+
}
|
|
812
|
+
if (relative.endsWith(".html")) {
|
|
813
|
+
return `/${relative.slice(0, -".html".length)}`;
|
|
814
|
+
}
|
|
815
|
+
return null;
|
|
816
|
+
};
|
|
817
|
+
var extractSchemaNodes = (html, filePath) => {
|
|
818
|
+
const nodes = [];
|
|
819
|
+
const warnings = [];
|
|
820
|
+
let invalidBlocks = 0;
|
|
821
|
+
let scriptIndex = 0;
|
|
822
|
+
for (const match of html.matchAll(SCRIPT_TAG_REGEX)) {
|
|
823
|
+
scriptIndex += 1;
|
|
824
|
+
const attributes = match[1] ?? "";
|
|
825
|
+
if (!JSON_LD_TYPE_REGEX.test(attributes)) {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const scriptBody = (match[2] ?? "").trim();
|
|
829
|
+
if (!scriptBody) {
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
let parsed;
|
|
833
|
+
try {
|
|
834
|
+
parsed = JSON.parse(scriptBody);
|
|
835
|
+
} catch {
|
|
836
|
+
invalidBlocks += 1;
|
|
837
|
+
warnings.push({
|
|
838
|
+
file: filePath,
|
|
839
|
+
message: `Invalid JSON-LD block at script #${scriptIndex}`
|
|
840
|
+
});
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const normalized = normalizeParsedBlock(parsed);
|
|
844
|
+
nodes.push(...normalized);
|
|
845
|
+
}
|
|
846
|
+
return { nodes, invalidBlocks, warnings };
|
|
847
|
+
};
|
|
848
|
+
var normalizeParsedBlock = (value) => {
|
|
849
|
+
if (Array.isArray(value)) {
|
|
850
|
+
return value.filter(isJsonObject);
|
|
851
|
+
}
|
|
852
|
+
if (!isJsonObject(value)) {
|
|
853
|
+
return [];
|
|
854
|
+
}
|
|
855
|
+
const graph = value["@graph"];
|
|
856
|
+
if (Array.isArray(graph)) {
|
|
857
|
+
return graph.filter(isJsonObject);
|
|
858
|
+
}
|
|
859
|
+
return [value];
|
|
860
|
+
};
|
|
861
|
+
var isJsonObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
862
|
+
var buildRouteDriftDetail = (route, beforeNodes, afterNodes) => {
|
|
863
|
+
const beforeTypes = new Set(beforeNodes.map((node) => schemaTypeLabel(node)));
|
|
864
|
+
const afterTypes = new Set(afterNodes.map((node) => schemaTypeLabel(node)));
|
|
865
|
+
const addedTypes = Array.from(afterTypes).filter((type) => !beforeTypes.has(type)).sort();
|
|
866
|
+
const removedTypes = Array.from(beforeTypes).filter((type) => !afterTypes.has(type)).sort();
|
|
867
|
+
return {
|
|
868
|
+
route,
|
|
869
|
+
beforeBlocks: beforeNodes.length,
|
|
870
|
+
afterBlocks: afterNodes.length,
|
|
871
|
+
addedTypes,
|
|
872
|
+
removedTypes
|
|
873
|
+
};
|
|
874
|
+
};
|
|
875
|
+
var schemaTypeLabel = (node) => {
|
|
876
|
+
const type = node["@type"];
|
|
877
|
+
return typeof type === "string" && type.trim().length > 0 ? type : "(unknown)";
|
|
878
|
+
};
|
|
879
|
+
|
|
657
880
|
// src/index.ts
|
|
658
881
|
import { createInterface } from "readline/promises";
|
|
659
882
|
import { stdin as input, stdout as output } from "process";
|
|
@@ -672,8 +895,8 @@ program.command("validate").description("Validate schema coverage and rules").op
|
|
|
672
895
|
const format = resolveOutputFormat(options.format);
|
|
673
896
|
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
674
897
|
const recommended = await resolveRecommendedOption(options.config);
|
|
675
|
-
const manifestPath =
|
|
676
|
-
const dataPath =
|
|
898
|
+
const manifestPath = path5.resolve(process.cwd(), options.manifest);
|
|
899
|
+
const dataPath = path5.resolve(process.cwd(), options.data);
|
|
677
900
|
let raw;
|
|
678
901
|
try {
|
|
679
902
|
raw = await readFile(manifestPath, "utf8");
|
|
@@ -760,12 +983,12 @@ program.command("init").description("Interactive setup wizard").option(
|
|
|
760
983
|
"Path to schema data JSON",
|
|
761
984
|
"schema-sentry.data.json"
|
|
762
985
|
).option("-y, --yes", "Use defaults and skip prompts").option("-f, --force", "Overwrite existing files").option("--scan", "Scan the filesystem for routes and add WebPage entries").option("--root <path>", "Project root for scanning", ".").action(async (options) => {
|
|
763
|
-
const manifestPath =
|
|
764
|
-
const dataPath =
|
|
986
|
+
const manifestPath = path5.resolve(process.cwd(), options.manifest);
|
|
987
|
+
const dataPath = path5.resolve(process.cwd(), options.data);
|
|
765
988
|
const force = options.force ?? false;
|
|
766
989
|
const useDefaults = options.yes ?? false;
|
|
767
990
|
const answers = useDefaults ? getDefaultAnswers() : await promptAnswers();
|
|
768
|
-
const scannedRoutes = options.scan ? await scanRoutes({ rootDir:
|
|
991
|
+
const scannedRoutes = options.scan ? await scanRoutes({ rootDir: path5.resolve(process.cwd(), options.root ?? ".") }) : [];
|
|
769
992
|
if (options.scan && scannedRoutes.length === 0) {
|
|
770
993
|
console.error("No routes found during scan.");
|
|
771
994
|
}
|
|
@@ -794,7 +1017,7 @@ program.command("audit").description("Analyze schema health and report issues").
|
|
|
794
1017
|
const format = resolveOutputFormat(options.format);
|
|
795
1018
|
const annotationsMode = resolveAnnotationsMode(options.annotations);
|
|
796
1019
|
const recommended = await resolveRecommendedOption(options.config);
|
|
797
|
-
const dataPath =
|
|
1020
|
+
const dataPath = path5.resolve(process.cwd(), options.data);
|
|
798
1021
|
let dataRaw;
|
|
799
1022
|
try {
|
|
800
1023
|
dataRaw = await readFile(dataPath, "utf8");
|
|
@@ -830,7 +1053,7 @@ program.command("audit").description("Analyze schema health and report issues").
|
|
|
830
1053
|
}
|
|
831
1054
|
let manifest;
|
|
832
1055
|
if (options.manifest) {
|
|
833
|
-
const manifestPath =
|
|
1056
|
+
const manifestPath = path5.resolve(process.cwd(), options.manifest);
|
|
834
1057
|
let manifestRaw;
|
|
835
1058
|
try {
|
|
836
1059
|
manifestRaw = await readFile(manifestPath, "utf8");
|
|
@@ -864,7 +1087,7 @@ program.command("audit").description("Analyze schema health and report issues").
|
|
|
864
1087
|
return;
|
|
865
1088
|
}
|
|
866
1089
|
}
|
|
867
|
-
const requiredRoutes = options.scan ? await scanRoutes({ rootDir:
|
|
1090
|
+
const requiredRoutes = options.scan ? await scanRoutes({ rootDir: path5.resolve(process.cwd(), options.root ?? ".") }) : [];
|
|
868
1091
|
if (options.scan && requiredRoutes.length === 0) {
|
|
869
1092
|
console.error("No routes found during scan.");
|
|
870
1093
|
}
|
|
@@ -883,6 +1106,125 @@ program.command("audit").description("Analyze schema health and report issues").
|
|
|
883
1106
|
printAuditSummary(report, Boolean(manifest), Date.now() - start);
|
|
884
1107
|
process.exit(report.ok ? 0 : 1);
|
|
885
1108
|
});
|
|
1109
|
+
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(
|
|
1110
|
+
"-d, --data <path>",
|
|
1111
|
+
"Path to existing schema data JSON for --check",
|
|
1112
|
+
"schema-sentry.data.json"
|
|
1113
|
+
).action(async (options) => {
|
|
1114
|
+
const start = Date.now();
|
|
1115
|
+
const format = resolveCollectOutputFormat(options.format);
|
|
1116
|
+
const rootDir = path5.resolve(process.cwd(), options.root ?? ".");
|
|
1117
|
+
const check = options.check ?? false;
|
|
1118
|
+
const requestedRoutes = normalizeRouteFilter(options.routes ?? []);
|
|
1119
|
+
const strictRoutes = options.strictRoutes ?? false;
|
|
1120
|
+
let collected;
|
|
1121
|
+
try {
|
|
1122
|
+
collected = await collectSchemaData({ rootDir, routes: requestedRoutes });
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
1125
|
+
printCliError(
|
|
1126
|
+
"collect.scan_failed",
|
|
1127
|
+
`Could not scan HTML output at ${rootDir}: ${reason}`,
|
|
1128
|
+
"Point --root to a directory containing built HTML output."
|
|
1129
|
+
);
|
|
1130
|
+
process.exit(1);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (collected.stats.htmlFiles === 0) {
|
|
1134
|
+
printCliError(
|
|
1135
|
+
"collect.no_html",
|
|
1136
|
+
`No HTML files found under ${rootDir}`,
|
|
1137
|
+
"Point --root to a static output directory (for example ./out)."
|
|
1138
|
+
);
|
|
1139
|
+
process.exit(1);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (strictRoutes && collected.missingRoutes.length > 0) {
|
|
1143
|
+
printCliError(
|
|
1144
|
+
"collect.missing_required_routes",
|
|
1145
|
+
`Required routes were not found in collected HTML: ${collected.missingRoutes.join(", ")}`,
|
|
1146
|
+
"Rebuild output, adjust --root, or update --routes."
|
|
1147
|
+
);
|
|
1148
|
+
process.exit(1);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
let driftDetected = false;
|
|
1152
|
+
if (check) {
|
|
1153
|
+
const existingPath = path5.resolve(process.cwd(), options.data);
|
|
1154
|
+
let existingRaw;
|
|
1155
|
+
try {
|
|
1156
|
+
existingRaw = await readFile(existingPath, "utf8");
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
printCliError(
|
|
1159
|
+
"data.not_found",
|
|
1160
|
+
`Schema data not found at ${existingPath}`,
|
|
1161
|
+
"Run `schemasentry collect --output ./schema-sentry.data.json` to generate it."
|
|
1162
|
+
);
|
|
1163
|
+
process.exit(1);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
let existingData;
|
|
1167
|
+
try {
|
|
1168
|
+
existingData = JSON.parse(existingRaw);
|
|
1169
|
+
} catch (error) {
|
|
1170
|
+
printCliError(
|
|
1171
|
+
"data.invalid_json",
|
|
1172
|
+
"Schema data is not valid JSON",
|
|
1173
|
+
"Check the JSON syntax or regenerate with `schemasentry collect --output`."
|
|
1174
|
+
);
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
if (!isSchemaData(existingData)) {
|
|
1179
|
+
printCliError(
|
|
1180
|
+
"data.invalid_shape",
|
|
1181
|
+
"Schema data must contain a 'routes' object with array values",
|
|
1182
|
+
"Ensure each route maps to an array of JSON-LD blocks."
|
|
1183
|
+
);
|
|
1184
|
+
process.exit(1);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
const existingDataForCompare = requestedRoutes.length > 0 ? filterSchemaDataByRoutes(existingData, requestedRoutes) : existingData;
|
|
1188
|
+
const drift = compareSchemaData(existingDataForCompare, collected.data);
|
|
1189
|
+
driftDetected = drift.hasChanges;
|
|
1190
|
+
if (driftDetected) {
|
|
1191
|
+
console.error(formatSchemaDataDrift(drift));
|
|
1192
|
+
} else {
|
|
1193
|
+
console.error("collect | No schema data drift detected.");
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const content = formatCollectOutput(collected.data, format);
|
|
1197
|
+
if (options.output) {
|
|
1198
|
+
const resolvedPath = path5.resolve(process.cwd(), options.output);
|
|
1199
|
+
try {
|
|
1200
|
+
await mkdir(path5.dirname(resolvedPath), { recursive: true });
|
|
1201
|
+
await writeFile(resolvedPath, `${content}
|
|
1202
|
+
`, "utf8");
|
|
1203
|
+
console.error(`Collected data written to ${resolvedPath}`);
|
|
1204
|
+
} catch (error) {
|
|
1205
|
+
const reason = error instanceof Error && error.message.length > 0 ? error.message : "Unknown file system error";
|
|
1206
|
+
printCliError(
|
|
1207
|
+
"output.write_failed",
|
|
1208
|
+
`Could not write collected data to ${resolvedPath}: ${reason}`
|
|
1209
|
+
);
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
} else if (!check) {
|
|
1214
|
+
console.log(content);
|
|
1215
|
+
}
|
|
1216
|
+
printCollectWarnings(collected.warnings);
|
|
1217
|
+
printCollectSummary({
|
|
1218
|
+
stats: collected.stats,
|
|
1219
|
+
durationMs: Date.now() - start,
|
|
1220
|
+
checked: check,
|
|
1221
|
+
driftDetected,
|
|
1222
|
+
requestedRoutes: collected.requestedRoutes,
|
|
1223
|
+
missingRoutes: collected.missingRoutes,
|
|
1224
|
+
strictRoutes
|
|
1225
|
+
});
|
|
1226
|
+
process.exit(driftDetected ? 1 : 0);
|
|
1227
|
+
});
|
|
886
1228
|
function isManifest(value) {
|
|
887
1229
|
if (!value || typeof value !== "object") {
|
|
888
1230
|
return false;
|
|
@@ -939,11 +1281,30 @@ function resolveAnnotationsMode(value) {
|
|
|
939
1281
|
process.exit(1);
|
|
940
1282
|
return "none";
|
|
941
1283
|
}
|
|
1284
|
+
function resolveCollectOutputFormat(value) {
|
|
1285
|
+
const format = (value ?? "json").trim().toLowerCase();
|
|
1286
|
+
if (format === "json") {
|
|
1287
|
+
return format;
|
|
1288
|
+
}
|
|
1289
|
+
printCliError(
|
|
1290
|
+
"output.invalid_format",
|
|
1291
|
+
`Unsupported collect output format '${value ?? ""}'`,
|
|
1292
|
+
"Use --format json."
|
|
1293
|
+
);
|
|
1294
|
+
process.exit(1);
|
|
1295
|
+
return "json";
|
|
1296
|
+
}
|
|
942
1297
|
function formatReportOutput(report, format, title) {
|
|
943
1298
|
if (format === "html") {
|
|
944
1299
|
return renderHtmlReport(report, { title });
|
|
945
1300
|
}
|
|
946
|
-
return
|
|
1301
|
+
return stableStringify3(report);
|
|
1302
|
+
}
|
|
1303
|
+
function formatCollectOutput(data, format) {
|
|
1304
|
+
if (format === "json") {
|
|
1305
|
+
return stableStringify3(data);
|
|
1306
|
+
}
|
|
1307
|
+
return stableStringify3(data);
|
|
947
1308
|
}
|
|
948
1309
|
async function emitReport(options) {
|
|
949
1310
|
const { report, format, outputPath, title } = options;
|
|
@@ -952,9 +1313,9 @@ async function emitReport(options) {
|
|
|
952
1313
|
console.log(content);
|
|
953
1314
|
return;
|
|
954
1315
|
}
|
|
955
|
-
const resolvedPath =
|
|
1316
|
+
const resolvedPath = path5.resolve(process.cwd(), outputPath);
|
|
956
1317
|
try {
|
|
957
|
-
await mkdir(
|
|
1318
|
+
await mkdir(path5.dirname(resolvedPath), { recursive: true });
|
|
958
1319
|
await writeFile(resolvedPath, content, "utf8");
|
|
959
1320
|
console.error(`Report written to ${resolvedPath}`);
|
|
960
1321
|
} catch (error) {
|
|
@@ -974,7 +1335,7 @@ function emitAnnotations(report, mode, commandLabel) {
|
|
|
974
1335
|
}
|
|
975
1336
|
function printCliError(code, message, suggestion) {
|
|
976
1337
|
console.error(
|
|
977
|
-
|
|
1338
|
+
stableStringify3({
|
|
978
1339
|
ok: false,
|
|
979
1340
|
errors: [
|
|
980
1341
|
{
|
|
@@ -1040,6 +1401,61 @@ function printAuditSummary(report, coverageEnabled, durationMs) {
|
|
|
1040
1401
|
console.error("Coverage checks skipped (no manifest provided).");
|
|
1041
1402
|
}
|
|
1042
1403
|
}
|
|
1404
|
+
function printCollectWarnings(warnings) {
|
|
1405
|
+
if (warnings.length === 0) {
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
const maxPrinted = 10;
|
|
1409
|
+
console.error(`collect | Warnings: ${warnings.length}`);
|
|
1410
|
+
for (const warning of warnings.slice(0, maxPrinted)) {
|
|
1411
|
+
console.error(`- ${warning.file}: ${warning.message}`);
|
|
1412
|
+
}
|
|
1413
|
+
if (warnings.length > maxPrinted) {
|
|
1414
|
+
console.error(`- ... ${warnings.length - maxPrinted} more warning(s)`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
function printCollectSummary(options) {
|
|
1418
|
+
const {
|
|
1419
|
+
stats,
|
|
1420
|
+
durationMs,
|
|
1421
|
+
checked,
|
|
1422
|
+
driftDetected,
|
|
1423
|
+
requestedRoutes,
|
|
1424
|
+
missingRoutes,
|
|
1425
|
+
strictRoutes
|
|
1426
|
+
} = options;
|
|
1427
|
+
const parts = [
|
|
1428
|
+
`HTML files: ${stats.htmlFiles}`,
|
|
1429
|
+
`Routes: ${stats.routes}`,
|
|
1430
|
+
`Blocks: ${stats.blocks}`,
|
|
1431
|
+
`Invalid blocks: ${stats.invalidBlocks}`,
|
|
1432
|
+
`Duration: ${formatDuration(durationMs)}`
|
|
1433
|
+
];
|
|
1434
|
+
if (checked) {
|
|
1435
|
+
parts.push(`Check: ${driftDetected ? "drift_detected" : "clean"}`);
|
|
1436
|
+
}
|
|
1437
|
+
if (requestedRoutes.length > 0) {
|
|
1438
|
+
parts.push(`Route filter: ${requestedRoutes.length}`);
|
|
1439
|
+
}
|
|
1440
|
+
if (missingRoutes.length > 0) {
|
|
1441
|
+
parts.push(`Missing filtered routes: ${missingRoutes.length}`);
|
|
1442
|
+
}
|
|
1443
|
+
if (strictRoutes) {
|
|
1444
|
+
parts.push("Strict routes: enabled");
|
|
1445
|
+
}
|
|
1446
|
+
console.error(`collect | ${parts.join(" | ")}`);
|
|
1447
|
+
}
|
|
1448
|
+
function filterSchemaDataByRoutes(data, routes) {
|
|
1449
|
+
const filteredRoutes = {};
|
|
1450
|
+
for (const route of routes) {
|
|
1451
|
+
if (Object.prototype.hasOwnProperty.call(data.routes, route)) {
|
|
1452
|
+
filteredRoutes[route] = data.routes[route];
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
routes: filteredRoutes
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1043
1459
|
async function promptAnswers() {
|
|
1044
1460
|
const defaults = getDefaultAnswers();
|
|
1045
1461
|
const rl = createInterface({ input, output });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schemasentry/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CLI for Schema Sentry validation and reporting.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"commander": "^12.0.0",
|
|
36
|
-
"@schemasentry/core": "0.
|
|
36
|
+
"@schemasentry/core": "0.4.0"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "tsup src/index.ts --format esm --dts --clean --tsconfig tsconfig.build.json",
|